88 agent-workspace.sh status [<workspace-id>]
99 agent-workspace.sh shell <workspace-id>
1010 agent-workspace.sh clean <workspace-id>
11+ agent-workspace.sh sync-tools <workspace-id>
1112USAGE
1213}
1314
@@ -32,6 +33,9 @@ cache_root_default=${XDG_CACHE_HOME:-"$HOME/.cache"}
3233workspace_root=${AI_WORKSPACES_ROOT:- " $cache_root_default /ai-workspaces" }
3334workspace_repo_root=" $workspace_root /$repo_slug "
3435
36+ tools_source_root=" ${AGENT_TOOLS_SOURCE:- $repo_root } "
37+ tools_relative_paths=(" agents.just" " rules" " scripts" )
38+
3539sanitise_workspace_id () {
3640 local id=" $1 "
3741 [[ " $id " =~ ^[A-Za-z0-9._-]+$ ]] || fail " workspace id '$id ' contains invalid characters"
@@ -48,6 +52,108 @@ metadata_path_for() {
4852 echo " $( workspace_path_for " $workspace_id " ) /.agent-workflow.json"
4953}
5054
55+ compute_tools_hash () {
56+ python - " $tools_source_root " " ${tools_relative_paths[@]} " << 'PY '
57+ import hashlib
58+ import os
59+ import sys
60+ from pathlib import Path
61+
62+ root = Path(sys.argv[1]).resolve()
63+ paths = sys.argv[2:]
64+
65+ hasher = hashlib.sha256()
66+
67+ def add_file(path: Path):
68+ rel = path.relative_to(root)
69+ hasher.update(str(rel).encode('utf-8'))
70+ hasher.update(b'\0')
71+ with path.open('rb') as fh:
72+ while True:
73+ chunk = fh.read(65536)
74+ if not chunk:
75+ break
76+ hasher.update(chunk)
77+
78+ if not paths:
79+ print('')
80+ raise SystemExit(0)
81+
82+ for rel in paths:
83+ src = root / rel
84+ if not src.exists():
85+ print(f"missing:{rel}", file=sys.stderr)
86+ raise SystemExit(1)
87+ if src.is_file():
88+ add_file(src)
89+ else:
90+ for file_path in sorted(src.rglob('*')):
91+ if file_path.is_file():
92+ add_file(file_path)
93+
94+ print(hasher.hexdigest())
95+ PY
96+ }
97+
98+ copy_tools_payload () {
99+ local dest=" $1 "
100+ python - " $tools_source_root " " $dest " " ${tools_relative_paths[@]} " << 'PY '
101+ import shutil
102+ import sys
103+ from pathlib import Path
104+
105+ root = Path(sys.argv[1]).resolve()
106+ dest = Path(sys.argv[2]).resolve()
107+ paths = sys.argv[3:]
108+
109+ for rel in paths:
110+ src = root / rel
111+ if not src.exists():
112+ raise SystemExit(f"tool path missing: {rel}")
113+ target = dest / rel
114+ if src.is_dir():
115+ shutil.copytree(src, target, dirs_exist_ok=True)
116+ else:
117+ target.parent.mkdir(parents=True, exist_ok=True)
118+ shutil.copy2(src, target)
119+ PY
120+ }
121+
122+ prepare_tools_copy () {
123+ local workspace_path=" $1 "
124+ local force_copy=" ${2:- false} "
125+
126+ local tools_dest=" $workspace_path /.agent-tools"
127+ TOOLS_COPY_PATH=" $tools_dest "
128+
129+ local desired_hash
130+ desired_hash=$( compute_tools_hash) || fail " failed to hash tool sources"
131+ TOOLS_VERSION=" $desired_hash "
132+
133+ local version_file=" $tools_dest /.version"
134+ local current_hash=" "
135+ if [[ -f " $version_file " ]]; then
136+ current_hash=$( cat " $version_file " )
137+ fi
138+
139+ if [[ " $force_copy " == " true" ]]; then
140+ current_hash=" "
141+ fi
142+
143+ if [[ " $current_hash " != " $desired_hash " ]]; then
144+ case " $tools_dest " in
145+ " $workspace_path " /* ) ;;
146+ * ) fail " tool copy path outside workspace: $tools_dest " ;;
147+ esac
148+ rm -rf " $tools_dest "
149+ mkdir -p " $tools_dest "
150+ copy_tools_payload " $tools_dest "
151+ printf ' %s\n' " $desired_hash " > " $version_file "
152+ else
153+ mkdir -p " $tools_dest "
154+ fi
155+ }
156+
51157ensure_workspace () {
52158 local workspace_id=" $1 "
53159 local workspace_path=" $2 "
@@ -75,7 +181,8 @@ update_metadata() {
75181
76182 STATUS=" $status " TIMESTAMP=" $timestamp " WORKSPACE_ID=" $WORKSPACE_ID " REPO_ROOT=" $repo_root " \
77183 WORKSPACE_PATH=" $WORKSPACE_PATH " WORKFLOW_NAME=" ${WORKFLOW_NAME:- } " COMMAND_JSON=" $COMMAND_JSON " \
78- DIRENV_ALLOWED=" $DIRENV_ALLOWED " BASE_CHANGE=" ${BASE_CHANGE:- } " \
184+ DIRENV_ALLOWED=" $DIRENV_ALLOWED " BASE_CHANGE=" ${BASE_CHANGE:- } " TOOLS_SOURCE=" ${TOOLS_SOURCE:- } " \
185+ TOOLS_COPY=" ${TOOLS_COPY:- } " TOOLS_VERSION=" ${TOOLS_VERSION:- } " \
79186 python - " $metadata_path " << 'PY '
80187import json
81188import os
@@ -91,6 +198,9 @@ workflow_name = os.environ.get("WORKFLOW_NAME") or None
91198direnv_allowed = os.environ.get("DIRENV_ALLOWED", "false").lower() == "true"
92199command = json.loads(os.environ.get("COMMAND_JSON", "[]"))
93200base_change = os.environ.get("BASE_CHANGE") or None
201+ tools_source = os.environ.get("TOOLS_SOURCE") or None
202+ tools_copy = os.environ.get("TOOLS_COPY") or None
203+ tools_version = os.environ.get("TOOLS_VERSION") or None
94204
95205try:
96206 with open(path, "r", encoding="utf-8") as fh:
@@ -121,6 +231,21 @@ if base_change is None:
121231else:
122232 data["base_change"] = base_change
123233
234+ if tools_source is None:
235+ data.pop("tools_source", None)
236+ else:
237+ data["tools_source"] = tools_source
238+
239+ if tools_copy is None:
240+ data.pop("tools_copy", None)
241+ else:
242+ data["tools_copy"] = tools_copy
243+
244+ if tools_version is None:
245+ data.pop("tools_version", None)
246+ else:
247+ data["tools_version"] = tools_version
248+
124249with open(path, "w", encoding="utf-8") as fh:
125250 json.dump(data, fh, indent=2)
126251 fh.write("\n")
@@ -192,6 +317,8 @@ run_subcommand() {
192317 (cd " $workspace_path " && jj edit " $base_change " )
193318 fi
194319
320+ prepare_tools_copy " $workspace_path "
321+
195322 local direnv_status=" false"
196323 if [[ " $use_direnv " == " true" ]]; then
197324 command -v direnv > /dev/null 2>&1 || fail " direnv is required but not installed"
@@ -202,6 +329,7 @@ run_subcommand() {
202329 direnv_status=" true"
203330 fi
204331 DIRENV_ALLOWED=" $direnv_status "
332+ TOOLS_SOURCE=" $tools_source_root "
205333
206334 COMMAND_JSON=$( python - << 'PY ' "${cmd[@]}"
207335import json
@@ -210,6 +338,7 @@ print(json.dumps(sys.argv[1:]))
210338PY
211339)
212340
341+ TOOLS_COPY=" $TOOLS_COPY_PATH "
213342 update_metadata " $metadata_path " " running"
214343
215344 local exit_code
220349 AGENT_WORKSPACE_PATH=" $workspace_path " \
221350 AGENT_WORKSPACE_METADATA=" $metadata_path " \
222351 AGENT_WORKSPACE_REPO_ROOT=" $repo_root " \
352+ AGENT_TOOL_COPY_ROOT=" $TOOLS_COPY_PATH " \
353+ AGENT_TOOLS_VERSION=" $TOOLS_VERSION " \
354+ AGENT_TOOLS_SOURCE=" $tools_source_root " \
223355 direnv exec . " ${cmd[@]} "
224356 else
225357 AGENT_WORKSPACE_ID=" $workspace_id " \
226358 AGENT_WORKSPACE_PATH=" $workspace_path " \
227359 AGENT_WORKSPACE_METADATA=" $metadata_path " \
228360 AGENT_WORKSPACE_REPO_ROOT=" $repo_root " \
361+ AGENT_TOOL_COPY_ROOT=" $TOOLS_COPY_PATH " \
362+ AGENT_TOOLS_VERSION=" $TOOLS_VERSION " \
363+ AGENT_TOOLS_SOURCE=" $tools_source_root " \
229364 " ${cmd[@]} "
230365 fi
231366 )
@@ -361,6 +496,72 @@ clean_subcommand() {
361496 fi
362497}
363498
499+ sync_tools_subcommand () {
500+ if [[ $# -ne 1 ]]; then
501+ fail " sync-tools requires a workspace id"
502+ fi
503+
504+ local workspace_id
505+ workspace_id=$( sanitise_workspace_id " $1 " )
506+ local workspace_path
507+ workspace_path=$( workspace_path_for " $workspace_id " )
508+
509+ [[ -d " $workspace_path " ]] || fail " workspace '$workspace_id ' does not exist"
510+
511+ if ! jj workspace root --workspace " $workspace_id " > /dev/null 2>&1 ; then
512+ fail " workspace '$workspace_id ' is not registered with jj"
513+ fi
514+
515+ prepare_tools_copy " $workspace_path " " true"
516+
517+ local metadata_path
518+ metadata_path=$( metadata_path_for " $workspace_id " )
519+
520+ local prev_status=" idle"
521+ local prev_workflow=" "
522+ local prev_command=" []"
523+ local prev_direnv=" false"
524+ local prev_base=" "
525+
526+ if [[ -f " $metadata_path " ]]; then
527+ local -a meta_info=()
528+ mapfile -t meta_info < <( python - " $metadata_path " << 'PY '
529+ import json
530+ import sys
531+
532+ path = sys.argv[1]
533+ with open(path, 'r', encoding='utf-8') as fh:
534+ data = json.load(fh)
535+
536+ print(data.get('status', 'idle'))
537+ print(data.get('workflow') or '')
538+ print(json.dumps(data.get('command', [])))
539+ print('true' if data.get('direnv_allowed') else 'false')
540+ print(data.get('base_change') or '')
541+ PY
542+ )
543+ if [[ ${# meta_info[@]} -ge 5 ]]; then
544+ prev_status=" ${meta_info[0]} "
545+ prev_workflow=" ${meta_info[1]} "
546+ prev_command=" ${meta_info[2]} "
547+ prev_direnv=" ${meta_info[3]} "
548+ prev_base=" ${meta_info[4]} "
549+ fi
550+ fi
551+
552+ WORKSPACE_ID=" $workspace_id "
553+ WORKSPACE_PATH=" $workspace_path "
554+ METADATA_PATH=" $metadata_path "
555+ WORKFLOW_NAME=" $prev_workflow "
556+ BASE_CHANGE=" $prev_base "
557+ COMMAND_JSON=" $prev_command "
558+ DIRENV_ALLOWED=" $prev_direnv "
559+ TOOLS_SOURCE=" $tools_source_root "
560+ TOOLS_COPY=" $TOOLS_COPY_PATH "
561+ TOOLS_VERSION=" $TOOLS_VERSION "
562+ update_metadata " $metadata_path " " $prev_status "
563+ }
564+
364565main () {
365566 if [[ $# -lt 1 ]]; then
366567 usage
@@ -384,6 +585,9 @@ main() {
384585 clean)
385586 clean_subcommand " $@ "
386587 ;;
588+ sync-tools)
589+ sync_tools_subcommand " $@ "
590+ ;;
387591 * )
388592 usage
389593 fail " unknown subcommand '$subcommand '"
0 commit comments