bone-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ local PYTHON_SCRIPT = [=[
2
+ import base64, json, os, re, shlex, subprocess, sys
3
+ from pathlib import Path
4
+
5
+ def env(name, default=""):
6
+ return os.environ.get(name, default)
7
+
8
+ def bone_dir():
9
+ xdg = env("XDG_CONFIG_HOME")
10
+ if xdg: return Path(xdg) / "bone-rust"
11
+ home = env("HOME") or env("USERPROFILE")
12
+ if home: return Path(home) / ".bone-rust"
13
+ return Path(".bone-rust").resolve()
14
+
15
+ def find_bone():
16
+ explicit = env("BONE_BIN")
17
+ if explicit and os.access(explicit, os.X_OK):
18
+ return str(Path(explicit).resolve())
19
+ for part in env("PATH").split(os.pathsep):
20
+ candidate = Path(part) / "bone"
21
+ if os.access(candidate, os.X_OK):
22
+ return str(candidate.resolve())
23
+ print("bone binary not found. Set BONE_BIN=/path/to/bone", file=sys.stderr)
24
+ sys.exit(127)
25
+
26
+ def validate_name(name):
27
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", name or ""):
28
+ fail("job name must contain only letters, numbers, '-' and '_'")
29
+
30
+ def parse_time(value):
31
+ m = re.fullmatch(r"(\d{1,2}):(\d{2})", value or "")
32
+ if not m: fail("time must be HH:MM")
33
+ hour, minute = int(m.group(1)), int(m.group(2))
34
+ if hour > 23 or minute > 59: fail("time must be between 00:00 and 23:59")
35
+ return hour, minute
36
+
37
+ def validate_approval(value):
38
+ if value not in ("read_only", "danger"):
39
+ fail("approval must be read_only or danger")
40
+ return value
41
+
42
+ def fail(message, code=2):
43
+ print(message, file=sys.stderr)
44
+ sys.exit(code)
45
+
46
+ def cron_missing():
47
+ fail("crontab not found. Install cronie or cron.", 127)
48
+
49
+ def current_crontab():
50
+ try:
51
+ p = subprocess.run(["crontab", "-l"], text=True, capture_output=True)
52
+ except FileNotFoundError:
53
+ cron_missing()
54
+ if p.returncode == 0: return p.stdout
55
+ if "no crontab" in p.stderr.lower(): return ""
56
+ fail(p.stderr.strip() or f"crontab -l exited with {p.returncode}", p.returncode)
57
+
58
+ def write_crontab(content):
59
+ try:
60
+ p = subprocess.run(["crontab", "-"], input=content, text=True, capture_output=True)
61
+ except FileNotFoundError:
62
+ cron_missing()
63
+ if p.returncode != 0:
64
+ fail(p.stderr.strip() or f"crontab exited with {p.returncode}", p.returncode)
65
+
66
+ def encode_metadata(job):
67
+ raw = json.dumps(job, separators=(",", ":")).encode()
68
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
69
+
70
+ def decode_metadata(value):
71
+ try:
72
+ padded = value + "=" * ((4 - len(value) % 4) % 4)
73
+ return json.loads(base64.urlsafe_b64decode(padded.encode()))
74
+ except Exception:
75
+ return None
76
+
77
+ def parse_cron_line(line):
78
+ marker = "# BONE:"
79
+ if marker not in line: return None
80
+ body, encoded = line.rsplit(marker, 1)
81
+ fields = body.split()
82
+ if len(fields) < 5 or fields[2:5] != ["*", "*", "*"]: return None
83
+ try:
84
+ minute, hour = int(fields[0]), int(fields[1])
85
+ except ValueError: return None
86
+ meta = decode_metadata(encoded.strip())
87
+ if not meta:
88
+ name = encoded.strip()
89
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", name): return None
90
+ meta = {"name": name, "approval": "", "cwd": "", "prompt": "", "log_path": ""}
91
+ meta["minute"] = minute
92
+ meta["hour"] = hour
93
+ return meta
94
+
95
+ def build_cron_line(job):
96
+ args = [job["bone_bin"], "run", "--approval", job["approval"]]
97
+ args.extend(["--prompt", job["prompt"]])
98
+ command = "cd " + shlex.quote(job["cwd"]) + " && " + " ".join(shlex.quote(a) for a in args)
99
+ command += " >> " + shlex.quote(job["log_path"]) + " 2>&1"
100
+ meta = {k: job[k] for k in ("name", "approval", "cwd", "prompt", "log_path")}
101
+ return f'{job["minute"]} {job["hour"]} * * * {command} # BONE:{encode_metadata(meta)}'
102
+
103
+ def list_jobs():
104
+ jobs = [j for j in (parse_cron_line(line) for line in current_crontab().splitlines()) if j]
105
+ if not jobs:
106
+ print("No bone cron jobs.")
107
+ return
108
+ print("NAME\tTIME\tAPPROVAL\tCWD\tPROMPT")
109
+ for j in jobs:
110
+ print(f'{j.get("name", "")}\t{j["hour"]:02d}:{j["minute"]:02d}\t{j.get("approval", "")}\t{j.get("cwd", "")}\t{j.get("prompt", "")}')
111
+
112
+ def add_job():
113
+ name, time, prompt = env("TOOL_NAME"), env("TOOL_TIME"), env("TOOL_PROMPT")
114
+ approval = validate_approval(env("TOOL_APPROVAL", "read_only") or "read_only")
115
+ if not name or not time or not prompt:
116
+ fail("Usage: cron add requires name, time, and prompt.")
117
+ validate_name(name)
118
+ hour, minute = parse_time(time)
119
+ cwd = str(Path(env("TOOL_CWD") or os.getcwd()).resolve())
120
+ log_dir = bone_dir() / "runs"
121
+ log_dir.mkdir(parents=True, exist_ok=True)
122
+ job = {"name": name, "hour": hour, "minute": minute, "approval": approval,
123
+ "cwd": cwd, "prompt": prompt, "log_path": str(log_dir / f"{name}.log"),
124
+ "bone_bin": find_bone()}
125
+ existing = current_crontab().splitlines()
126
+ kept = []
127
+ for line in existing:
128
+ parsed = parse_cron_line(line)
129
+ legacy_tag = line.rstrip().endswith(f"# BONE:{name}")
130
+ if (parsed and parsed.get("name") == name) or legacy_tag: continue
131
+ kept.append(line)
132
+ kept.append(build_cron_line(job))
133
+ write_crontab("\n".join(kept) + "\n")
134
+ print(f"Added cron job {name}.")
135
+
136
+ def remove_job():
137
+ name = env("TOOL_NAME")
138
+ if not name: fail("Usage: cron remove requires name.")
139
+ validate_name(name)
140
+ removed = False
141
+ kept = []
142
+ for line in current_crontab().splitlines():
143
+ parsed = parse_cron_line(line)
144
+ legacy_tag = line.rstrip().endswith(f"# BONE:{name}")
145
+ if (parsed and parsed.get("name") == name) or legacy_tag: removed = True
146
+ else: kept.append(line)
147
+ write_crontab(("\n".join(kept) + "\n") if kept else "")
148
+ print(f"Removed cron job {name}." if removed else f"No cron job named {name}.")
149
+
150
+ def show_logs():
151
+ name = env("TOOL_NAME")
152
+ if not name: fail("Usage: cron logs requires name.")
153
+ validate_name(name)
154
+ path = bone_dir() / "runs" / f"{name}.log"
155
+ try: lines = path.read_text().splitlines()
156
+ except OSError as e: fail(f"failed to read {path}: {e}", 1)
157
+ tail = env("TOOL_TAIL")
158
+ if tail:
159
+ try: n = int(tail)
160
+ except ValueError: fail("tail must be a number")
161
+ lines = lines[-n:]
162
+ print("\n".join(lines))
163
+
164
+ def help_text():
165
+ print("""Manage Bone scheduled jobs.
166
+
167
+ Examples:
168
+ cron(action=list)
169
+ cron(action=add, name=daily-clean, time=09:00, approval=danger, prompt=/clean src/main.rs)
170
+ cron(action=remove, name=daily-clean)
171
+ cron(action=logs, name=daily-clean, tail=100)""")
172
+
173
+ action = env("TOOL_ACTION")
174
+ if action == "list": list_jobs()
175
+ elif action == "add": add_job()
176
+ elif action in ("remove", "rm"): remove_job()
177
+ elif action == "logs": show_logs()
178
+ elif action in ("help", "--help", "-h", ""): help_text()
179
+ else: fail(f"Unknown cron action: {action}")
180
+ ]=]
181
+
182
+ local function execute(params, ctx)
183
+ local action = params.action or ""
184
+ local name = params.name or ""
185
+ local time = params.time or ""
186
+ local approval = params.approval or "read_only"
187
+ local prompt = params.prompt or ""
188
+ local cwd = params.cwd or ""
189
+ local tail = params.tail or ""
190
+
191
+ -- Build export commands for TOOL_* variables
192
+ local exports = {}
193
+ table.insert(exports, 'export TOOL_ACTION="' .. action:gsub('"', '\\"') .. '"')
194
+ if name ~= "" then table.insert(exports, 'export TOOL_NAME="' .. name:gsub('"', '\\"') .. '"') end
195
+ if time ~= "" then table.insert(exports, 'export TOOL_TIME="' .. time:gsub('"', '\\"') .. '"') end
196
+ if approval ~= "" then table.insert(exports, 'export TOOL_APPROVAL="' .. approval:gsub('"', '\\"') .. '"') end
197
+ if prompt ~= "" then table.insert(exports, 'export TOOL_PROMPT="' .. prompt:gsub('"', '\\"') .. '"') end
198
+ if cwd ~= "" then table.insert(exports, 'export TOOL_CWD="' .. cwd:gsub('"', '\\"') .. '"') end
199
+ if tail ~= "" then table.insert(exports, 'export TOOL_TAIL="' .. tail:gsub('"', '\\"') .. '"') end
200
+
201
+ local cmd = table.concat(exports, "; ")
202
+ cmd = cmd .. "; uv run --no-project --no-sync -- python3 <<'PYEOF'\n"
203
+ cmd = cmd .. PYTHON_SCRIPT
204
+ cmd = cmd .. "\nPYEOF"
205
+
206
+ local result = ctx.shell(cmd, { timeout_ms = 300000 })
207
+ if result.stderr and #result.stderr > 0 then
208
+ return "ERROR: " .. result.stderr
209
+ end
210
+ return result.stdout or ""
211
+ end
212
+
213
+ bone.register_tool({
214
+ name = "cron",
215
+ description = "Manage Bone scheduled jobs for the user. Use this when the user asks to schedule, list, remove, or inspect recurring Bone tasks. Fully implemented as a custom tool; supports daily HH:MM schedules.",
216
+ parameters = {
217
+ type = "object",
218
+ properties = {
219
+ action = {
220
+ type = "string",
221
+ description = "Action: add, list, remove, logs, or help.",
222
+ },
223
+ name = {
224
+ type = "string",
225
+ description = "Job name for add/remove/logs. Use letters, numbers, '-' or '_'.",
226
+ },
227
+ time = {
228
+ type = "string",
229
+ description = "Daily run time in HH:MM 24-hour local time, required for add.",
230
+ },
231
+ approval = {
232
+ type = "string",
233
+ description = "Approval mode for add: read_only or danger. Defaults to read_only.",
234
+ },
235
+ prompt = {
236
+ type = "string",
237
+ description = "Prompt or command invocation for add.",
238
+ },
239
+ cwd = {
240
+ type = "string",
241
+ description = "Working directory for add. Defaults to current directory.",
242
+ },
243
+ tail = {
244
+ type = "number",
245
+ description = "Number of log lines for logs.",
246
+ },
247
+ },
248
+ required = { "action" },
249
+ additionalProperties = false,
250
+ },
251
+ safety = "danger",
252
+ execute = execute,
253
+ })
@@ -0,0 +1,350 @@
1
+ -- Sub-agent tool: discovery, dispatch, and status reporting.
2
+ --
3
+ -- Only active when sub-agents are registered via bone.register_subagent in
4
+ -- init.lua. When no agents are registered, this file is a no-op (zero
5
+ -- overhead) — the tool is never created.
6
+ --
7
+ -- The live status pane is rendered natively in Rust (src/ui/subagent_pane.rs)
8
+ -- so it stays responsive even while this tool blocks the Lua VM.
9
+
10
+ -- cjson is a global injected by Rust (encode/decode via serde_json)
11
+
12
+ -- ---------------------------------------------------------------------------
13
+ -- Early exits
14
+ -- ---------------------------------------------------------------------------
15
+
16
+ -- Sub-agents must not spawn nested sub-agents: never register the tool
17
+ -- inside a sub-agent VM.
18
+ if bone.agent_depth and bone.agent_depth > 0 then
19
+ return
20
+ end
21
+
22
+ local subagents = bone._subagents
23
+ if not subagents or #subagents == 0 then
24
+ return
25
+ end
26
+
27
+ -- Headless mode (CLI): background job auto-injection is unavailable, so
28
+ -- dispatch must always block for results.
29
+ local headless = bone.headless and true or false
30
+
31
+ -- ---------------------------------------------------------------------------
32
+ -- Helpers
33
+ -- ---------------------------------------------------------------------------
34
+
35
+ --- Numeric part of a "job-N" id (0 when malformed).
36
+ local function job_id_number(id)
37
+ return tonumber((id or ""):match("(%d+)$")) or 0
38
+ end
39
+
40
+ --- Build a human-readable status string for a single job.
41
+ local function job_status(job)
42
+ if not job then
43
+ return "○ idle"
44
+ end
45
+
46
+ if job.status == "running" then
47
+ local elapsed = os.time() - job.started_at
48
+ local task = job.task or ""
49
+ if #task > 40 then
50
+ task = task:sub(1, 37) .. "..."
51
+ end
52
+ local sent = job.token_sent or 0
53
+ local received = job.token_received or 0
54
+ return string.format("◑ running %s (%ds) %s/%s in/out", task, elapsed, sent, received)
55
+ end
56
+ -- done → treat as idle
57
+ if job.status == "done" then
58
+ local sent = job.token_sent or 0
59
+ local received = job.token_received or 0
60
+ if sent > 0 or received > 0 then
61
+ return string.format("○ idle (%s/%s in/out)", sent, received)
62
+ end
63
+ return "○ idle"
64
+ end
65
+ -- error
66
+ return "✗ error"
67
+ end
68
+
69
+ --- Build a status summary string for one agent (latest job by id).
70
+ local function agent_status(agent, jobs)
71
+ local latest = nil
72
+ for _, j in ipairs(jobs) do
73
+ if j.agent == agent.name then
74
+ if not latest or job_id_number(j.id) > job_id_number(latest.id) then
75
+ latest = j
76
+ end
77
+ end
78
+ end
79
+ return job_status(latest)
80
+ end
81
+
82
+ -- ---------------------------------------------------------------------------
83
+ -- Build dynamic tool description
84
+ -- ---------------------------------------------------------------------------
85
+
86
+ local function build_description()
87
+ local parts = {
88
+ "Delegate tasks to registered sub-agents. Each agent runs in its own isolated context and only sees the task text you give it — write self-contained tasks (include file paths, goals, constraints, and the expected output format).",
89
+ "",
90
+ "Registered agents:",
91
+ }
92
+ for _, agent in ipairs(subagents) do
93
+ local extras = {}
94
+ if agent.approval then
95
+ extras[#extras + 1] = "approval: " .. agent.approval
96
+ end
97
+ local suffix = #extras > 0 and (" [" .. table.concat(extras, ", ") .. "]") or ""
98
+ parts[#parts + 1] = string.format(" - %s: %s%s", agent.name, agent.description, suffix)
99
+ end
100
+ parts[#parts + 1] = ""
101
+ if headless then
102
+ parts[#parts + 1] = table.concat({
103
+ "Actions:",
104
+ '- dispatch: start one or more tasks (one tasks[] entry each, run in parallel). Blocks until all dispatched tasks finish and returns their results.',
105
+ '- wait: block until previously dispatched jobs finish. Waits on the given ids[] (or all running jobs when omitted) and returns their results.',
106
+ '- status: non-blocking snapshot of job progress. Use sparingly; never call status in a loop.',
107
+ "",
108
+ "Rules:",
109
+ "- Batch independent tasks into a single dispatch call to maximize parallelism.",
110
+ "- Each agent runs one job at a time; dispatching to a busy agent is rejected.",
111
+ }, "\n")
112
+ else
113
+ parts[#parts + 1] = table.concat({
114
+ "Actions:",
115
+ '- dispatch: start one or more tasks (one tasks[] entry each, run in parallel).',
116
+ ' - If you need the results to continue, or you have nothing else productive to do, set wait=true: the call blocks and returns the results directly. This is the right choice for fan-out/fan-in work (e.g. dispatching research and then synthesizing it).',
117
+ ' - Omit wait ONLY when you have separate, independent work to do that does NOT overlap the dispatched tasks: dispatch returns immediately and you continue on that other work. Finished results are delivered automatically in a later message — do NOT poll for them, and NEVER fabricate or assume results you have not received.',
118
+ '- wait: block until previously dispatched jobs finish. Waits on the given ids[] (or all running jobs when omitted) and returns their results. Use when you reach a point where you need pending results before continuing.',
119
+ '- status: non-blocking snapshot of job progress. Use sparingly; never call status in a loop.',
120
+ "",
121
+ "Rules:",
122
+ "- Batch independent tasks into a single dispatch call to maximize parallelism.",
123
+ "- Each agent runs one job at a time; dispatching to a busy agent is rejected.",
124
+ "- NEVER duplicate the work you delegated. Once a task is dispatched, do not read the same files, run the same searches, or research the same questions yourself — that wastes context and defeats the purpose of delegating. Let the sub-agent do it.",
125
+ "- After a non-waiting dispatch, do only your separate independent work, then end your turn; the results arrive as an automated message. If you have no such independent work, you should have used wait=true instead.",
126
+ }, "\n")
127
+ end
128
+ return table.concat(parts, "\n")
129
+ end
130
+
131
+ -- ---------------------------------------------------------------------------
132
+ -- Result formatting helpers
133
+ -- ---------------------------------------------------------------------------
134
+
135
+ -- Per-job character budget for results returned by wait (mirrors the Rust
136
+ -- auto-injection limit).
137
+ local MAX_RESULT_CHARS = 16000
138
+
139
+ --- Truncate to a byte budget without splitting a UTF-8 sequence.
140
+ local function truncate_result(s, max)
141
+ if #s <= max then
142
+ return s
143
+ end
144
+ local cut = max
145
+ while cut > 0 do
146
+ local b = s:byte(cut + 1)
147
+ if not b or b < 0x80 or b >= 0xC0 then
148
+ break
149
+ end
150
+ cut = cut - 1
151
+ end
152
+ return s:sub(1, cut) .. "\n[... truncated]"
153
+ end
154
+
155
+ --- Format a ctx.agent.wait outcome into a report for the model.
156
+ local function format_wait_outcome(outcome)
157
+ local parts = {}
158
+ for _, job in ipairs(outcome.jobs or {}) do
159
+ local sym = job.status == "done" and "done" or "ERROR"
160
+ local body = truncate_result(job.result or "", MAX_RESULT_CHARS)
161
+ if job.result_file then
162
+ body = body .. string.format("\n[full output saved to: %s]", job.result_file)
163
+ end
164
+ parts[#parts + 1] = string.format(
165
+ "## %s (%s) — %s\n%s",
166
+ job.agent ~= "" and job.agent or "agent",
167
+ job.id,
168
+ sym,
169
+ body
170
+ )
171
+ end
172
+ if outcome.cancelled then
173
+ parts[#parts + 1] = "Wait cancelled by user; jobs keep running and their results will arrive automatically."
174
+ elseif outcome.timed_out then
175
+ parts[#parts + 1] = string.format(
176
+ "Wait timed out with jobs still running: %s. Their results will arrive automatically in a later message — do not assume their outcome.",
177
+ table.concat(outcome.pending or {}, ", ")
178
+ )
179
+ end
180
+ if #parts == 0 then
181
+ parts[#parts + 1] = "No jobs to wait on."
182
+ end
183
+ return table.concat(parts, "\n\n")
184
+ end
185
+
186
+ -- ---------------------------------------------------------------------------
187
+ -- Tool execute function
188
+ -- ---------------------------------------------------------------------------
189
+
190
+ local function execute(params, ctx)
191
+ local action = params.action or ""
192
+
193
+ if action == "dispatch" then
194
+ local tasks = params.tasks or {}
195
+ if #tasks == 0 then
196
+ return "ERROR: Provide tasks for 'dispatch'."
197
+ end
198
+
199
+ local results = {}
200
+ local dispatched_ids = {}
201
+ local ok_count = 0
202
+ local err_count = 0
203
+
204
+ for _, t in ipairs(tasks) do
205
+ local agent_name = t.agent or ""
206
+ local task_desc = t.task or ""
207
+
208
+ -- Look up the agent definition
209
+ local agent_def = nil
210
+ for _, a in ipairs(subagents) do
211
+ if a.name == agent_name then
212
+ agent_def = a
213
+ break
214
+ end
215
+ end
216
+
217
+ if agent_def then
218
+ -- Build spawn opts from the agent definition.
219
+ -- Busy agents are rejected atomically by the Rust registry.
220
+ local opts = {
221
+ agent = agent_name,
222
+ system_prompt = agent_def.system_prompt,
223
+ provider = agent_def.provider,
224
+ model = agent_def.model,
225
+ approval = agent_def.approval,
226
+ timeout_ms = agent_def.timeout_ms,
227
+ }
228
+
229
+ local result = ctx.agent.spawn(task_desc, opts)
230
+ if result.ok then
231
+ results[#results + 1] = string.format(
232
+ "dispatched %s → %s", result.id, agent_name
233
+ )
234
+ dispatched_ids[#dispatched_ids + 1] = result.id
235
+ ok_count = ok_count + 1
236
+ else
237
+ results[#results + 1] = string.format(
238
+ "REJECTED: %s — %s", agent_name, result.error or "unknown"
239
+ )
240
+ err_count = err_count + 1
241
+ end
242
+ else
243
+ results[#results + 1] = string.format(
244
+ "REJECTED: unknown agent '%s'", agent_name
245
+ )
246
+ err_count = err_count + 1
247
+ end
248
+ end
249
+
250
+ local summary = string.format(
251
+ "Dispatched %d, rejected %d", ok_count, err_count
252
+ )
253
+ if err_count > 0 then
254
+ summary = summary .. "\n" .. table.concat(results, "\n")
255
+ end
256
+
257
+ -- Blocking dispatch: wait for the dispatched jobs and return results.
258
+ -- Headless mode always blocks (no background auto-injection there).
259
+ local should_wait = params.wait or headless
260
+ if should_wait and #dispatched_ids > 0 then
261
+ local outcome = ctx.agent.wait(dispatched_ids, { timeout_ms = params.timeout_ms })
262
+ if outcome.ok then
263
+ summary = summary .. "\n\n" .. format_wait_outcome(outcome)
264
+ else
265
+ summary = summary .. "\n\nERROR waiting: " .. (outcome.error or "unknown")
266
+ end
267
+ end
268
+
269
+ return summary
270
+ end
271
+
272
+ if action == "wait" then
273
+ local outcome = ctx.agent.wait(params.ids, { timeout_ms = params.timeout_ms })
274
+ if not outcome.ok then
275
+ return "ERROR: " .. (outcome.error or "unknown")
276
+ end
277
+ return format_wait_outcome(outcome)
278
+ end
279
+
280
+ if action == "status" then
281
+ local jobs = ctx.agent.jobs()
282
+ local parts = { "Sub-agent status:" }
283
+ for _, agent in ipairs(subagents) do
284
+ parts[#parts + 1] = string.format(" %s: %s", agent.name, agent_status(agent, jobs))
285
+ end
286
+ return table.concat(parts, "\n")
287
+ end
288
+
289
+ return "ERROR: Action must be 'dispatch', 'wait' or 'status'."
290
+ end
291
+
292
+ -- ---------------------------------------------------------------------------
293
+ -- Register the tool
294
+ -- ---------------------------------------------------------------------------
295
+
296
+ bone.register_tool({
297
+ name = "subagent",
298
+ description = build_description(),
299
+ safety = "read_only",
300
+ parameters = {
301
+ type = "object",
302
+ properties = {
303
+ action = {
304
+ type = "string",
305
+ enum = { "dispatch", "wait", "status" },
306
+ description = "dispatch (start tasks), wait (block for results), or status (non-blocking snapshot)",
307
+ },
308
+ tasks = {
309
+ type = "array",
310
+ description = "dispatch only: list of tasks to start in parallel. Each item: {agent: string, task: string}",
311
+ items = {
312
+ type = "object",
313
+ properties = {
314
+ agent = {
315
+ type = "string",
316
+ description = "Registered agent name",
317
+ },
318
+ task = {
319
+ type = "string",
320
+ description = "Self-contained task description for the agent (it sees nothing else)",
321
+ },
322
+ },
323
+ required = { "agent", "task" },
324
+ additionalProperties = false,
325
+ },
326
+ },
327
+ wait = {
328
+ type = "boolean",
329
+ description = "dispatch only: block until the dispatched tasks finish and return their results. Use when your next step depends on them.",
330
+ },
331
+ ids = {
332
+ type = "array",
333
+ items = { type = "string" },
334
+ description = "wait only: job ids to wait for (omit to wait for all running jobs)",
335
+ },
336
+ timeout_ms = {
337
+ type = "integer",
338
+ description = "dispatch(wait=true)/wait: max time to block in milliseconds (default 300000)",
339
+ },
340
+ },
341
+ required = { "action" },
342
+ additionalProperties = false,
343
+ },
344
+ display = {
345
+ show = true,
346
+ show_result = false,
347
+ args = { "action", "tasks", "wait", "ids" },
348
+ },
349
+ execute = execute,
350
+ })