@metagptx/deepflow-suite-cli 0.2.11-dev.20261020.1 → 0.3.1

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.
Files changed (51) hide show
  1. package/README.md +44 -50
  2. package/bin/deepflow-suite.js +4 -4
  3. package/bundle/deepflow-release.json +21 -46
  4. package/bundle/skills/deepflow-suite/SKILL.md +7 -1
  5. package/bundle/skills/df-agentctrl/SKILL.md +5 -1
  6. package/bundle/skills/df-agentrt/SKILL.md +15 -0
  7. package/bundle/skills/df-agentrt-repo/SKILL.md +4 -0
  8. package/bundle/skills/df-task/SKILL.md +4 -0
  9. package/bundle/skills/df-taskdriver/SKILL.md +5 -1
  10. package/bundle/skills/df-workbench/SKILL.md +10 -2
  11. package/manifest.json +12 -30
  12. package/package.json +8 -17
  13. package/runtime/df-agentctrl/commands.mjs +93 -0
  14. package/runtime/df-agentctrl/df-agentctrl.mjs +119 -0
  15. package/runtime/df-agentctrl/release.json +1 -0
  16. package/runtime/df-agentrt/agentrt.mjs +3 -0
  17. package/runtime/df-agentrt/df-agentrt.mjs +3 -0
  18. package/runtime/df-agentrt/release.json +1 -0
  19. package/runtime/df-agentrt/result.mjs +44 -0
  20. package/runtime/df-agentrt/runtime.mjs +358 -0
  21. package/runtime/df-agentrt/usage.mjs +16 -0
  22. package/runtime/df-task/commands.mjs +188 -0
  23. package/runtime/df-task/df-task.mjs +167 -0
  24. package/runtime/df-task/protocol.mjs +3 -0
  25. package/runtime/df-task/read.mjs +127 -0
  26. package/runtime/df-task/release.json +1 -0
  27. package/runtime/df-taskdriver/df-taskdriver.mjs +131 -0
  28. package/runtime/df-taskdriver/release.json +1 -0
  29. package/runtime/{df-workbench.mjs → df-workbench/df-workbench.mjs} +11 -2
  30. package/runtime/df-workbench/release.json +1 -0
  31. package/scripts/build-package.mjs +29 -25
  32. package/src/bundle.mjs +4 -1
  33. package/src/cli-install.mjs +35 -125
  34. package/src/commands/doctor.mjs +2 -0
  35. package/src/linker.mjs +2 -2
  36. package/src/manifest.mjs +7 -13
  37. package/src/package-cli.mjs +44 -60
  38. package/bin/df-agentctrl.js +0 -10
  39. package/bin/df-agentrt.js +0 -10
  40. package/bin/df-task.js +0 -10
  41. package/bin/df-taskdriver.js +0 -10
  42. package/bin/df-workbench.js +0 -10
  43. package/bundle/skills/deepflow-suite/manifest.yaml +0 -20
  44. package/bundle/skills/df-task/skill_test.go +0 -201
  45. package/bundle/skills/df-workbench/manifest.yaml +0 -5
  46. package/scripts/build-runtime-packages.mjs +0 -38
  47. package/skills/deepflow-suite/SKILL.md +0 -81
  48. package/skills/deepflow-suite/manifest.yaml +0 -20
  49. package/src/cli-launcher.mjs +0 -71
  50. package/src/runtime-build.mjs +0 -193
  51. package/src/runtime-package.mjs +0 -31
@@ -0,0 +1,358 @@
1
+ import { readFile, lstat } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { usage } from './usage.mjs';
7
+ import { resultView, writeResultFile } from './result.mjs';
8
+
9
+ const terminal = (status) => ['succeeded', 'failed', 'cancelled'].includes(status);
10
+ const decode = (raw) => JSON.parse(raw, (_key, value, context) => typeof value === 'number' && !Number.isSafeInteger(value) && /^-?\d+$/.test(context.source) ? BigInt(context.source) : value);
11
+ const encode = (value) => JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? JSON.rawJSON(item.toString()) : item);
12
+ const truncate = (value = '', width) => { const chars = Array.from(String(value).trim().replaceAll('\n', ' ')); return chars.length <= width ? chars.join('') : chars.slice(0, width - 1).join('') + '…'; };
13
+ const shortTime = (raw = '') => { const d = new Date(raw); return Number.isNaN(+d) ? raw : `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; };
14
+ const elapsed = (from, to) => { const ms = Date.parse(to) - Date.parse(from); return !from || !to || !Number.isFinite(ms) ? '—' : ms < 1000 ? `${ms}ms` : ms < 60000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60000)}m${Math.floor(ms / 1000) % 60}s`; };
15
+ const repoLine = (repo) => `${repo.url}${repo.ref ? ` @${repo.ref}` : ''}${repo.description ? ` (${truncate(repo.description, 48)})` : ''}`;
16
+ function table(rows, out) {
17
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => Array.from(String(row[i] ?? '')).length)) + 2);
18
+ for (const row of rows) out.write(row.map((cell, i) => i === row.length - 1 ? String(cell ?? '') : String(cell ?? '') + ' '.repeat(Math.max(0, widths[i] - Array.from(String(cell ?? '')).length))).join('') + '\n');
19
+ }
20
+ class Failure extends Error { constructor(message, code = 1) { super(message); this.code = code; } }
21
+ export function flags(args, strings = [], bools = [], repeated = []) {
22
+ const values = {}, positional = [];
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+ if (arg === '--') { positional.push(...args.slice(i + 1)); break; }
26
+ if (!arg.startsWith('-') || arg === '-') { positional.push(arg); continue; }
27
+ const [key, inline] = arg.replace(/^-{1,2}/, '').split(/=(.*)/s);
28
+ if (bools.includes(key)) { if (inline !== undefined && !['true', 'false'].includes(inline)) throw new Failure(`invalid boolean value for -${key}`, 2); values[key] = inline !== 'false'; continue; }
29
+ if (!strings.includes(key) && !repeated.includes(key)) throw new Failure(`flag provided but not defined: -${key}`, 2);
30
+ const value = inline ?? args[++i];
31
+ if (value === undefined) throw new Failure(`flag needs an argument: -${key}`, 2);
32
+ if (repeated.includes(key)) (values[key] ||= []).push(value); else values[key] = value;
33
+ }
34
+ return { v: values, rest: positional };
35
+ }
36
+ const numeric = (value, name, max = 9223372036854775807n) => {
37
+ if (value !== undefined && (!/^\+?\d+$/.test(value) || BigInt(value) > max)) throw new Failure(`${name} must be a non-negative integer`, 2);
38
+ };
39
+ const query = (values) => { const pairs = Object.entries(values).filter(([, v]) => v !== undefined && v !== ''); pairs.sort(([a], [b]) => a.localeCompare(b)); const q = new URLSearchParams(pairs).toString(); return q ? '?' + q : ''; };
40
+ function jsonObject(raw, description) { let value; try { value = JSON.parse(raw); } catch { throw new Failure(`${description} must be a JSON object`, 2); } if (!value || Array.isArray(value) || typeof value !== 'object') throw new Failure(`${description} must be a JSON object`, 2); return value; }
41
+
42
+ export class RuntimeCLI {
43
+ constructor({ self = true, name = self ? 'df-agentrt' : 'agentrt', env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
44
+ Object.assign(this, { self, name, env, stdout, stderr });
45
+ }
46
+ fail(message, code = 2) { throw new Failure(message, code); }
47
+ environment(message) { this.fail(`${message}${this.self ? '\nNothing is wrong with your arguments and retrying will not help.' : ''}`, 3); }
48
+ address() {
49
+ const addr = this.self ? this.env.MULTICA_SERVER_URL : this.env.AGENTRT_ADDR || 'http://127.0.0.1:8141';
50
+ if (!addr) this.environment('MULTICA_SERVER_URL is not set, so there is no runtime API to talk to; this command only works inside a running task');
51
+ return addr.replace(/\/+$/, '');
52
+ }
53
+ async request(method, route, body, { headers = {}, raw = false, stream = false, signal, responseLimit = 0, timeout = false } = {}) {
54
+ const addr = this.address();
55
+ let response;
56
+ try {
57
+ response = await fetch(addr + route, { method, headers: { ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(stream ? { Accept: 'text/event-stream' } : {}), ...headers }, body: body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body), signal: signal || (!stream && (this.self || timeout) ? AbortSignal.timeout(30000) : undefined) });
58
+ } catch (error) { throw new Failure(`cannot reach the runtime API at ${addr}: ${error.message}`); }
59
+ if (!response.ok) {
60
+ const text = (await response.text()).slice(0, 65536); let error;
61
+ try { error = JSON.parse(text).error; } catch { /* A proxy can return plain text. */ }
62
+ throw new Failure(`${error?.code ? `${error.code}: ${error.message}` : `HTTP ${response.status}: ${text.trim() || response.statusText}`}${response.headers.get('retry-after') ? ` (retry after ${response.headers.get('retry-after')}s)` : ''}`);
63
+ }
64
+ if (stream) return response.body;
65
+ if (method === 'DELETE') { await response.body?.cancel(); return; }
66
+ let text;
67
+ if (responseLimit) {
68
+ const chunks = []; let bytes = 0;
69
+ for await (const chunk of response.body) {
70
+ bytes += chunk.length;
71
+ if (bytes > responseLimit) throw new Failure(`response exceeds ${responseLimit} bytes`);
72
+ chunks.push(chunk);
73
+ }
74
+ text = Buffer.concat(chunks).toString('utf8');
75
+ } else text = await response.text();
76
+ try { const value = decode(text); return raw ? text.trim() : value; } catch (error) { throw new Failure(`decode response: ${error.message}`); }
77
+ }
78
+ async ownSession() {
79
+ if (this.session) return this.session;
80
+ if (!this.env.MULTICA_TASK_ID) this.environment('MULTICA_TASK_ID is not set, so this process cannot tell which session it belongs to; this command only works inside a running task');
81
+ let inv;
82
+ try { inv = await this.request('GET', `/v1/invocations/${encodeURIComponent(this.env.MULTICA_TASK_ID)}`); }
83
+ catch (error) { this.environment(`look up this task: ${error.message}`); }
84
+ if (!inv.session_id) this.environment(`the runtime API knows task ${this.env.MULTICA_TASK_ID} but reports no session for it`);
85
+ return this.session = inv.session_id;
86
+ }
87
+ async takeSession(rest, form) {
88
+ if (this.self) return [await this.ownSession(), rest];
89
+ if (!rest.length) this.fail(`usage: ${this.name} ${form}`);
90
+ return [rest[0], rest.slice(1)];
91
+ }
92
+ async readSession(v, rest, form) {
93
+ if ('session' in v) { if (!v.session.trim() || rest.length) this.fail(`usage: ${this.name} ${form}`); return encodeURIComponent(v.session); }
94
+ if (this.self) { if (rest.length) this.fail(`usage: ${this.name} ${form}`); return encodeURIComponent(await this.ownSession()); }
95
+ if (rest.length !== 1 || !rest[0].trim()) this.fail(`usage: ${this.name} ${form}`);
96
+ return encodeURIComponent(rest[0]);
97
+ }
98
+ async getJSON(route) { this.stdout.write(await this.request('GET', route, undefined, { raw: true }) + '\n'); }
99
+ async invocationCmd(command, args) {
100
+ const form = `usage: ${this.name} invocation result <invocation-id> [--json] [--max-bytes N] [--output-file PATH]`;
101
+ if (command !== 'result') this.fail(form);
102
+ if (args.includes('--help') || args.includes('-h')) { this.stdout.write(form + '\n'); return; }
103
+ const { v, rest } = flags(args, ['max-bytes', 'output-file'], ['json']);
104
+ numeric(v['max-bytes'], 'max-bytes', 1n << 20n);
105
+ const maxBytes = Number(v['max-bytes'] ?? 4096);
106
+ if (rest.length !== 1 || !rest[0].trim() || maxBytes < 1) this.fail(form);
107
+ let destination;
108
+ if ('output-file' in v) {
109
+ if (!v['output-file'].trim()) this.fail('--output-file must not be empty');
110
+ destination = path.resolve(v['output-file']);
111
+ try { await lstat(destination); this.fail(`output file already exists: ${destination}`); }
112
+ catch (error) { if (error.code !== 'ENOENT') this.fail(error.message); }
113
+ }
114
+ const inv = await this.request('GET', `/v1/invocations/${encodeURIComponent(rest[0])}`, undefined, { responseLimit: 64 << 20, timeout: true });
115
+ if (!inv || inv.id !== rest[0] || typeof inv.session_id !== 'string' || !inv.session_id || typeof inv.status !== 'string' || !inv.status) throw new Failure('invalid invocation identity or status in response');
116
+ const { view, output } = resultView(inv, maxBytes);
117
+ if (destination && view.output_present) { await writeResultFile(destination, output); view.output_file = destination; }
118
+ this.stdout.write(JSON.stringify(view, null, v.json ? undefined : 2) + '\n');
119
+ }
120
+ printSession(s) {
121
+ const rows = [['id', s.id], ['provider', s.provider], ['status', s.status]];
122
+ if (s.client_ref) rows.push(['client_ref', s.client_ref]);
123
+ rows.push(['agent_key', s.agent_key]);
124
+ for (const key of ['model', 'thinking_level']) if (s.config?.[key]) rows.push([key, s.config[key]]);
125
+ if (s.config?.skills?.length) rows.push(['skills', s.config.skills.join(', ')]);
126
+ (s.config?.repos || []).forEach((repo, i) => rows.push([i ? '' : 'repos', repoLine(repo)]));
127
+ rows.push(['workdir_state', s.workdir_state]);
128
+ if (s.pinned_runtime_id) rows.push(['pinned_runtime', s.pinned_runtime_id]);
129
+ if (s.prior_session_id || s.prior_work_dir) rows.push(['', ''], ['resume: conversation', s.prior_session_id || '(none)'], ['resume: directory', s.prior_work_dir || '(none)']);
130
+ if (s.resume_unavailable) rows.push(['resume: warning', 'the conversation handle is gone; the next turn keeps the files but not the history']);
131
+ if (s.released_at) rows.push(['released_at', shortTime(s.released_at)]);
132
+ rows.push(['created_at', shortTime(s.created_at)]); table(rows, this.stdout);
133
+ }
134
+ async sessionCmd(command, args) {
135
+ if (command === 'info') {
136
+ this.address(); const [id, rest] = await this.takeSession(args, 'session info <session-id>');
137
+ if (rest.length) this.fail(`usage: ${this.name} session info${this.self ? '' : ' <session-id>'}`);
138
+ this.printSession(await this.request('GET', `/v1/sessions/${encodeURIComponent(id)}`)); return;
139
+ }
140
+ if (command === 'create') {
141
+ const { v } = flags(args, ['provider', 'model', 'thinking-level', 'instructions', 'client-ref', 'idempotency-key', 'mcp-config', 'metadata'], [], ['skill', 'repo']);
142
+ if (!v.provider) this.fail('--provider is required');
143
+ const body = { provider: v.provider };
144
+ for (const [flag, key] of [['model', 'model'], ['thinking-level', 'thinking_level'], ['instructions', 'instructions'], ['client-ref', 'client_ref'], ['skill', 'skills'], ['repo', 'repos']]) if (v[flag]) body[key] = v[flag];
145
+ const fragments = [];
146
+ if (v['mcp-config']) { const raw = await readFile(v['mcp-config'], 'utf8'); try { JSON.parse(raw); } catch { throw new Failure(`${v['mcp-config']} is not valid JSON`); } fragments.push(`"mcp_config":${raw}`); }
147
+ if (v.metadata) { jsonObject(v.metadata, 'session create --metadata'); fragments.push(`"metadata":${v.metadata}`); }
148
+ const raw = JSON.stringify(body).slice(0, -1) + (fragments.length ? ',' + fragments.join(',') : '') + '}';
149
+ const sess = await this.request('POST', '/v1/sessions', raw, { headers: v['idempotency-key'] ? { 'Idempotency-Key': v['idempotency-key'] } : {} });
150
+ this.stdout.write(sess.id + '\n'); this.stderr.write(`session ready on ${sess.provider} (agent key ${sess.agent_key})\n`);
151
+ if (this.self) this.stderr.write('This session stays open until it is released; it will not close by itself.\n'); return;
152
+ }
153
+ if (command === 'get') {
154
+ const { v, rest } = flags(args, ['metadata-source', 'metadata-query-key'], ['json']);
155
+ if (rest.length !== 1 || !rest[0].trim()) this.fail(`usage: ${this.name} session get <session-id> [--json] [--metadata-source S] [--metadata-query-key K]`);
156
+ const route = `/v1/sessions/${encodeURIComponent(rest[0])}${query({ metadata_source: v['metadata-source'], metadata_query_key: v['metadata-query-key'] })}`;
157
+ if (v.json) return this.getJSON(route);
158
+ this.printSession(await this.request('GET', route)); return;
159
+ }
160
+ if (['ls', 'list'].includes(command)) {
161
+ const { v } = flags(args, ['status', 'limit', 'metadata-source', 'metadata-query-key']); numeric(v.limit, 'limit');
162
+ const page = await this.request('GET', '/v1/sessions' + query({ status: v.status, limit: v.limit && BigInt(v.limit) > 0n ? v.limit : undefined, metadata_source: v['metadata-source'], metadata_query_key: v['metadata-query-key'] }));
163
+ if (!page.sessions?.length) { this.stderr.write('no sessions\n'); return; }
164
+ table([['ID', 'PROVIDER', 'STATUS', 'WORKDIR', 'CLIENT REF', 'CREATED'], ...page.sessions.map((s) => [s.id, s.provider, s.status, s.workdir_state, truncate(s.client_ref, 24), shortTime(s.created_at)])], this.stdout);
165
+ this.stderr.write(`${page.sessions.length} shown, ${page.total} total\n`); return;
166
+ }
167
+ if (command === 'turns') {
168
+ const { v, rest } = flags(args, ['session'], ['json']);
169
+ const id = await this.readSession(v, rest, 'session turns [--session <session-id>] [--json]');
170
+ const route = `/v1/sessions/${id}/invocations`;
171
+ if (v.json) return this.getJSON(route);
172
+ const page = await this.request('GET', route);
173
+ if (!page.invocations?.length) { this.stderr.write('no turns yet\n'); return; }
174
+ table([['SEQ', 'ID', 'STATUS', 'ELAPSED', 'RUNTIME', 'CREATED', 'MESSAGE'], ...page.invocations.map((inv) => [inv.seq, inv.id, inv.status + (inv.cancel_requested && !terminal(inv.status) ? ' (cancel 已请求)' : ''), elapsed(inv.started_at || inv.claimed_at, inv.finished_at), inv.runtime_id || '—', shortTime(inv.created_at), truncate(inv.message, 48)])], this.stdout); return;
175
+ }
176
+ if (['release', 'rm', 'delete'].includes(command)) {
177
+ if (args.length !== 1) this.fail(`usage: ${this.name} session ${command === 'release' ? 'release' : 'rm'} <session-id>`);
178
+ const route = `/v1/sessions/${encodeURIComponent(args[0])}`;
179
+ if (command !== 'release') { await this.request('DELETE', route); this.stdout.write(`${args[0]} deleted\n`); return; }
180
+ const sess = await this.request('POST', route + '/release');
181
+ this.stdout.write(`${sess.id} released, workdir_state=${sess.workdir_state}\n`);
182
+ if (sess.workdir_state === 'reclaim_requested') this.stderr.write('the daemon will remove the working directory on its next GC pass; events remain readable\n'); return;
183
+ }
184
+ this.groupUsage('session', command);
185
+ }
186
+ async repoCmd(command, args) {
187
+ if (command === 'checkout') this.fail(`there is no \`repo checkout\` here -- \`${this.name} repo add\` checks out what it adds.${this.self ? '\nTo check out a repository this session already has, use `multica repo checkout <url>`.' : ''}`);
188
+ if (command === 'search') {
189
+ if (args.length !== 1) this.fail(`usage: ${this.name} repo search <name>`);
190
+ const page = await this.request('GET', '/v1/apps' + query({ name: args[0] }));
191
+ if (!page.apps?.length) { this.stderr.write(`nothing in the application registry matches ${JSON.stringify(args[0])}\n`); if (this.self) this.stderr.write('Try a shorter fragment of the name, or pass a clone url to `repo add` directly.\n'); return; }
192
+ if (this.self) for (const app of page.apps) this.stdout.write(`${app.code}\t${app.name}\t${app.repo_url}\n`);
193
+ else table([['CODE', 'NAME', 'REPOSITORY'], ...page.apps.map((app) => [app.code, truncate(app.name, 28), app.repo_url || '—'])], this.stdout); return;
194
+ }
195
+ if (!['ls', 'list', 'add'].includes(command)) return this.groupUsage('repo', command);
196
+ const { v, rest } = flags(args, command === 'add' ? ['ref'] : []);
197
+ this.address(); const [id, targets] = await this.takeSession(rest, `repo ${command} <session-id>`);
198
+ const route = `/v1/sessions/${encodeURIComponent(id)}/repos`;
199
+ if (command !== 'add') {
200
+ if (targets.length) this.fail(`usage: ${this.name} repo ls${this.self ? '' : ' <session-id>'}`);
201
+ const page = await this.request('GET', route);
202
+ if (!page.repos?.length) this.stderr.write(`no repositories; add one with \`${this.name} repo add\`\n`);
203
+ for (const repo of page.repos || []) this.stdout.write(repoLine(repo) + '\n'); return;
204
+ }
205
+ if (!targets.length) this.fail(`usage: ${this.name} repo add${this.self ? '' : ' <session-id>'} <url|app-code> ... [--ref REF]`);
206
+ const body = {};
207
+ for (const target of targets) { if (target.includes('://') || target.includes('@')) (body.repos ||= []).push({ url: target }); else (body.apps ||= []).push(target); }
208
+ if (v.ref) { if (body.repos?.length !== 1 || body.apps?.length) this.fail('--ref applies to a single repository url; add the others separately'); body.repos[0].ref = v.ref; }
209
+ const page = await this.request('POST', route, body);
210
+ if (!this.self) {
211
+ if (!page.added?.length) this.stderr.write('already present, nothing appended\n');
212
+ else { for (const repo of page.added) this.stdout.write(repoLine(repo) + '\n'); this.stderr.write(`${page.added.length} appended, ${page.repos?.length || 0} total\n`); } return;
213
+ }
214
+ let repos = page.added || [];
215
+ if (!repos.length) {
216
+ this.stderr.write('Already available to this session; checking it out.\n');
217
+ const wanted = new Set((body.repos || []).map((repo) => repo.url.trim().replace(/\/+$/, '')));
218
+ repos = (page.repos || []).filter((repo) => wanted.has(repo.url.replace(/\/+$/, '')));
219
+ if (!repos.length && body.apps?.length) repos = page.repos || [];
220
+ }
221
+ let failed = 0;
222
+ for (const repo of repos) {
223
+ try { await this.checkout(repo); } catch (error) { failed++; if (error.message) this.stderr.write(`${this.name}: ${error.message}\n`); }
224
+ }
225
+ if (failed) throw new Failure(`${failed} of ${repos.length} repositories could not be checked out. They remain configured for this session, so \`multica repo checkout <url>\` can be retried.`);
226
+ }
227
+ async checkout(repo) {
228
+ if (!this.env.MULTICA_DAEMON_PORT) this.environment('MULTICA_DAEMON_PORT is not set, so there is no daemon to check the repository out through.\nThe repository is configured for this session; only the checkout did not happen.');
229
+ const bin = this.env.AGENTRT_CLI_DAEMON_BIN;
230
+ if (!bin || !path.isAbsolute(bin)) this.environment('AGENTRT_CLI_DAEMON_BIN must name the absolute path of the pinned mcd executable. This is a deployment problem.');
231
+ await new Promise((resolve, reject) => {
232
+ const child = spawn(bin, ['repo', 'checkout', repo.url, ...(repo.ref ? ['--ref', repo.ref] : [])], { env: this.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false });
233
+ child.stdout.pipe(this.stdout, { end: false }); child.stderr.pipe(this.stderr, { end: false });
234
+ child.once('error', (error) => reject(new Failure(`could not run ${bin}: ${error.message}`, 3)));
235
+ child.once('close', (code) => code === 0 ? resolve() : reject(new Failure('')));
236
+ });
237
+ }
238
+ async events(args) {
239
+ const form = 'events [--session <session-id>] [--after N] [--limit N] [--follow] [--raw]';
240
+ let v, rest;
241
+ try { ({ v, rest } = flags(args, ['session', 'after', 'limit'], ['raw', 'follow'])); numeric(v.after, 'after'); numeric(v.limit, 'limit'); }
242
+ catch { this.fail(`usage: ${this.name} ${form}`); }
243
+ const id = await this.readSession(v, rest, form);
244
+ if (v.follow) { const body = await this.request('GET', `/v1/sessions/${id}/events/stream?after_seq=${v.after || 0}`, undefined, { stream: true }); await scanSSE(body, (ev) => this.event(ev, v.raw)); return; }
245
+ const page = await this.request('GET', `/v1/sessions/${id}/events?after_seq=${v.after || 0}${v.limit && BigInt(v.limit) > 0n ? '&limit=' + v.limit : ''}`);
246
+ for (const ev of page.events || []) this.event(ev, v.raw);
247
+ this.stderr.write(`${page.events?.length || 0} events, next --after ${page.next_seq || 0}\n`);
248
+ }
249
+ event(ev, raw) {
250
+ const payload = ev.payload;
251
+ const summary = ev.source === 'agent' && payload && typeof payload === 'object' ? payload.content || payload.tool || payload.output : undefined;
252
+ this.stdout.write(`${String(ev.stream_seq).padStart(6)} ${(ev.source || '').padEnd(6)} ${(ev.type || '').padEnd(22)} ${raw ? encode(payload) : truncate(summary || encode(payload), 100)}\n`);
253
+ }
254
+ async msg(command, args) {
255
+ if (command === 'cancel') {
256
+ this.address();
257
+ let id;
258
+ if (args.length === 1) id = args[0];
259
+ else if (this.self && !args.length) { id = this.env.MULTICA_TASK_ID; if (!id) this.environment('MULTICA_TASK_ID is not set, so this process cannot tell which turn it is; this command only works inside a running task'); }
260
+ else this.fail(`usage: ${this.name} msg cancel ${this.self ? '[<turn-id>]' : '<invocation-id>'}`);
261
+ const inv = await this.request('POST', `/v1/invocations/${encodeURIComponent(id)}/cancel`);
262
+ this.stdout.write(`${inv.id} status=${inv.status} cancel_requested=${!!inv.cancel_requested}\n`);
263
+ if (inv.cancel_requested && !terminal(inv.status)) this.stderr.write('the cancel is recorded; the turn stops when the daemon acknowledges it\n'); return;
264
+ }
265
+ if (command !== 'send') return this.groupUsage('msg', command);
266
+ const { v, rest } = flags(args, ['idempotency-key'], ['follow', 'raw']);
267
+ if (rest.length < 2) this.fail(`usage: ${this.name} msg send <session-id> <text> [--follow]`);
268
+ const id = encodeURIComponent(rest[0]);
269
+ const cursor = v.follow ? (await this.request('GET', `/v1/sessions/${id}/events?limit=1`)).next_seq : 0;
270
+ const inv = await this.request('POST', `/v1/sessions/${id}/messages`, { message: rest.slice(1).join(' ') }, { headers: v['idempotency-key'] ? { 'Idempotency-Key': v['idempotency-key'] } : {} });
271
+ if (!v.follow) { this.stdout.write(inv.id + '\n'); this.stderr.write(`queued turn ${inv.seq}, status ${inv.status}\n`); return; }
272
+ await this.follow(id, inv.id, cursor || 0, v.raw);
273
+ }
274
+ report(inv) { if (inv.result) this.stdout.write(inv.result + '\n'); this.stderr.write(`turn ${inv.id}: ${inv.status}\n`); if (inv.error) this.stderr.write(`error: ${inv.error}\n`); if (inv.status !== 'succeeded') throw new Failure(''); }
275
+ async follow(session, invocation, cursor, raw) {
276
+ const controller = new AbortController(); let detached = false, result;
277
+ const detach = () => { detached = true; controller.abort(); };
278
+ process.once('SIGINT', detach); process.once('SIGTERM', detach);
279
+ const poll = (async () => {
280
+ while (!controller.signal.aborted) {
281
+ try { await delay(1000, undefined, { signal: controller.signal }); } catch { break; }
282
+ if (controller.signal.aborted) break;
283
+ try {
284
+ const inv = await this.request('GET', `/v1/invocations/${encodeURIComponent(invocation)}`, undefined, { signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30000)]) });
285
+ if (terminal(inv.status)) { result = inv; try { await delay(2000, undefined, { signal: controller.signal }); } catch {} controller.abort(); break; }
286
+ } catch { /* A failed poll is not a verdict on the turn. */ }
287
+ }
288
+ })();
289
+ try {
290
+ const body = await this.request('GET', `/v1/sessions/${session}/events/stream?after_seq=${cursor}`, undefined, { stream: true, signal: controller.signal });
291
+ await scanSSE(body, (ev) => this.event(ev, raw));
292
+ } catch (error) { if (!controller.signal.aborted) { controller.abort(); throw error; } }
293
+ finally { controller.abort(); await poll; process.removeListener('SIGINT', detach); process.removeListener('SIGTERM', detach); }
294
+ if (detached && !result) { this.stderr.write(`detached; the turn is still running (${this.name} msg cancel ${invocation} to stop it)\n`); return; }
295
+ result ||= await this.request('GET', `/v1/invocations/${encodeURIComponent(invocation)}`);
296
+ if (!terminal(result.status)) throw new Failure(`the event stream ended while the turn was still ${result.status}; re-attach with: ${this.name} events ${this.self ? '--session ' : ''}${session} --follow --after ${cursor}`);
297
+ this.report(result);
298
+ }
299
+ async ops(command, args) {
300
+ if (!['runtimes', 'orphans'].includes(command)) return this.groupUsage('ops', command);
301
+ const { v, rest } = flags(args, [], command === 'runtimes' ? ['json'] : ['fix']);
302
+ if (rest.length) this.fail(`usage: ${this.name} ops ${command}`);
303
+ const route = `/v1/ops/${command}`;
304
+ if (v.json) return this.getJSON(route);
305
+ const body = await this.request(v.fix ? 'POST' : 'GET', route);
306
+ if (command === 'runtimes') {
307
+ if (!body.runtimes?.length) { this.stderr.write('no runtimes have registered; session creates will fail with provider_offline\n'); return; }
308
+ table([['ID', 'PROVIDER', 'RECORDED', 'LIVE', 'STALE', 'DEVICE', 'LAST SEEN'], ...body.runtimes.map((rt) => [rt.id, rt.provider, rt.status, rt.live ? 'yes' : 'no', `${rt.stale_for}s`, truncate(rt.device_name, 20), shortTime(rt.last_seen_at)])], this.stdout);
309
+ const stale = body.runtimes.filter((rt) => !rt.live).length;
310
+ if (stale) this.stderr.write(`${stale} runtime(s) recorded but not heartbeating; trust the LIVE column\n`); return;
311
+ }
312
+ if (!body.workdirs?.length && !body.turns?.length) this.stderr.write('no orphans\n');
313
+ else {
314
+ if (body.workdirs?.length) { this.stdout.write('working directories:\n'); table([[' SESSION', 'KIND', 'STATE', 'WORKDIR', 'NOTE'], ...body.workdirs.map((w) => [` ${w.session_id}`, w.kind, w.workdir_state, truncate(w.work_dir, 40), w.note])], this.stdout); }
315
+ if (body.turns?.length) { this.stdout.write('turns:\n'); table([[' INVOCATION', 'KIND', 'STATUS', 'RUNTIME', 'LAST SEEN', 'NOTE'], ...body.turns.map((t) => [` ${t.invocation_id}`, t.kind, t.status, truncate(t.runtime_name, 20), shortTime(t.last_seen_at), t.note])], this.stdout); }
316
+ this.stderr.write(body.reconciled ? `corrected ${body.workdirs_corrected} workdir(s), failed ${body.turns_failed} turn(s)\n` : 'nothing was changed; re-run with --fix to apply\n');
317
+ }
318
+ if (body.unreadable) this.stderr.write(`warning: ${body.unreadable} working directories could not be read; this result does not cover them\n`);
319
+ }
320
+ groupUsage(group, command) { if (command) this.stderr.write(`${this.name} ${group}: unknown command ${JSON.stringify(command)}\n\n`); this.stderr.write(usage[this.self ? 'df-agentrt' : 'agentrt'][group]); throw new Failure('', 2); }
321
+ async run(args) {
322
+ try {
323
+ if (args[0] === '--cli-version') {
324
+ let version = 'workspace'; try { version = JSON.parse(await readFile(new URL('./release.json', import.meta.url), 'utf8')).version; } catch {}
325
+ const report = { name: this.name, version, entry: fileURLToPath(new URL(this.self ? './df-agentrt.mjs' : './agentrt.mjs', import.meta.url)) };
326
+ this.stdout.write((args.includes('--json') ? JSON.stringify(report) : `${this.name} ${version}`) + '\n'); return 0;
327
+ }
328
+ if (!args.length || ['--help', '-h', 'help'].includes(args[0])) { const code = !args.length && !this.self ? 2 : 0; (code ? this.stderr : this.stdout).write(usage[this.self ? 'df-agentrt' : 'agentrt']['']); return code; }
329
+ if (['--version', '-v', 'version'].includes(args[0])) {
330
+ let version = 'workspace'; try { version = JSON.parse(await readFile(new URL('./release.json', import.meta.url), 'utf8')).version; } catch { /* Source checkout. */ }
331
+ this.stdout.write(`${this.name} ${version} (DeepFlow Agent Runtime)\n`); return 0;
332
+ }
333
+ const [group, command, ...rest] = args;
334
+ if (group === 'events') await this.events(args.slice(1));
335
+ else if (group === 'invocation') await this.invocationCmd(command, rest);
336
+ else if (['session', 'repo', 'msg', 'ops'].includes(group)) {
337
+ if (!command) this.groupUsage(group);
338
+ else if (group === 'session') await this.sessionCmd(command, rest);
339
+ else if (group === 'repo') await this.repoCmd(command, rest);
340
+ else if (group === 'msg') await this.msg(command, rest);
341
+ else await this.ops(command, rest);
342
+ } else { this.stderr.write(`${this.name}: unknown command ${JSON.stringify(group)}\n\n`); this.stderr.write(usage[this.self ? 'df-agentrt' : 'agentrt']['']); return 2; }
343
+ return 0;
344
+ } catch (error) { if (error.message) this.stderr.write(`${this.name}: ${error.message}\n`); return error.code || 1; }
345
+ }
346
+ }
347
+
348
+ export async function scanSSE(body, emit) {
349
+ const decoder = new TextDecoder(); let pending = '';
350
+ const line = (raw) => { if (!raw.startsWith('data:')) return; try { emit(decode(raw.slice(5).trimStart())); } catch (error) { if (!(error instanceof SyntaxError)) throw error; } };
351
+ for await (const chunk of body) {
352
+ pending += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
353
+ let end;
354
+ while ((end = pending.indexOf('\n')) >= 0) { if (end > 8 * 1024 * 1024) throw new Failure('SSE frame exceeds 8 MiB'); line(pending.slice(0, end).replace(/\r$/, '')); pending = pending.slice(end + 1); }
355
+ if (Buffer.byteLength(pending) > 8 * 1024 * 1024) throw new Failure('SSE frame exceeds 8 MiB');
356
+ }
357
+ pending += decoder.decode(); if (pending) line(pending);
358
+ }
@@ -0,0 +1,16 @@
1
+ export const usage = {
2
+ "df-agentrt": {
3
+ "": "df-agentrt -- DeepFlow Agent Runtime, from inside a task\n\nTHIS SESSION\n df-agentrt session info Which session this is:\n provider, model, working\n directory, repositories,\n and whether the\n conversation history\n survived into this turn.\n df-agentrt session turns [--session ID] [--json] Every turn; defaults to\n this session.\n df-agentrt events [--session ID] [--after N] The message stream. Also\n --limit N, --raw and\n --follow.\n df-agentrt repo list The repositories this\n session may check out.\n df-agentrt repo search <name> Find an application and\n the repository its code\n lives in.\n df-agentrt repo add <url|code> ... Make another repository\n available and check it out\n now. Prints the absolute\n path of each checkout.\n df-agentrt msg cancel [invocation-id] Ask a turn to stop;\n defaults to the current\n turn.\n\nOTHER SESSIONS\n df-agentrt invocation result <id> [--json] Read a bounded final result.\n --output-file saves full text.\n df-agentrt session ls Sessions on this runtime.\n df-agentrt session get <id> [--json] One session in full.\n df-agentrt session create ... Start a new session. It\n runs an agent and costs\n money, and it does not end\n by itself.\n df-agentrt msg send <id> <text> Queue a turn on another\n session. Same caveat.\n\nRUNTIME\n df-agentrt ops runtimes [--json] Which execution engines\n are registered and alive.\n df-agentrt ops orphans Where the records and the\n world have drifted apart.\n\nRun `df-agentrt <group>` for the flags of one group.\n",
4
+ "session": "df-agentrt session <command>\n\n info This session. Includes whether the conversation history\n carried over into this turn -- if it did not, you have the\n same files but none of what was said before.\n turns [--session ID] [--json]\n Every turn; defaults to this session, oldest first.\n ls Sessions on this runtime.\n get <id> [--json] [--metadata-source S] [--metadata-query-key K]\n Another session in full.\n create --provider P [--model M] [--thinking-level L] [--instructions S]\n [--skill S ...] [--repo R ...] [--client-ref REF] [--mcp-config FILE]\n [--metadata JSON]\n [--idempotency-key KEY]\n Start a new session. It runs an agent and costs money, and\n nothing ends it for you.\n release <id> Stop a session accepting turns and ask for its working\n directory back.\n rm <id> Delete a session's record, message stream included.\n\nEverything about a session except its repository set is fixed at creation.\nFor repositories see `df-agentrt repo` -- those can be appended afterwards.\n",
5
+ "repo": "df-agentrt repo <command>\n\n list The repositories this session may check out.\n search <name> Find an application and the repository its code\n lives in. Use this when you know what a thing is\n called but not where its code is.\n add <url|app-code> ... Make another repository available to this session\n [--ref REF] and check it out now. Prints the absolute path of\n each checkout on stdout; nothing else to run\n afterwards. --ref takes one repository only.\n\nAn argument containing \"://\" or \"@\" is treated as a clone url and stored\nverbatim; anything else is treated as an application code and looked up for\nyou. A code can never contain either, so the two cannot be confused.\n\nAppend-only: there is no remove. Removing a repository you are working inside\nwould break this turn.\n",
6
+ "msg": "df-agentrt msg <command>\n\n send <session-id> <text> [--follow] [--idempotency-key KEY] [--raw]\n Queue a turn on another session. That session runs an agent and\n costs money; it is not this one. --follow waits for the turn to\n finish, which blocks you for as long as it runs.\n cancel [<turn-id>]\n Ask a turn to stop. With no argument, the turn you are executing\n right now -- you will be asked to wind down, not killed outright.\n",
7
+ "ops": "df-agentrt ops <command>\n\n runtimes [--json] Which daemons have registered, and which are still alive.\n orphans [--fix] Where the records and the world have drifted apart.\n Reports only unless --fix is given; --fix fails stranded\n turns on other sessions and cannot be undone.\n"
8
+ },
9
+ "agentrt": {
10
+ "": "agentrt -- DeepFlow Agent Runtime CLI\n\nUSAGE\n agentrt <group> <command> [flags]\n\nGROUPS\n session info | create | get | ls | turns | release | rm\n invocation result <id> [--json] [--max-bytes N] [--output-file PATH]\n repo ls | add | search\n msg send | cancel\n events events <session> [--after N]\n ops runtimes | orphans\n\nENVIRONMENT\n AGENTRT_ADDR Base URL of the runtime API (default http://127.0.0.1:8141)\n\nRun `agentrt <group>` for that group's commands.\n",
11
+ "session": "agentrt session <command>\n\n info <session-id> The same view as get. Named for the agent-side CLI,\n where it is the command for \"which session am I in\".\n create --provider P [--model M] [--thinking-level L] [--instructions S]\n [--skill S ...] [--repo R ...] [--client-ref REF] [--mcp-config FILE]\n [--idempotency-key KEY]\n get <session-id> [--json] [--metadata-source S] [--metadata-query-key K]\n\t ls [--status active|released] [--limit N] [--metadata-source S] [--metadata-query-key K]\n turns <session-id> [--json] (or --session <session-id>)\n Every turn of one session, oldest first -- the same\n timeline the web UI draws, from the same endpoint.\n release <session-id> Stop accepting turns; Phase 1 keeps the Worker workspace.\n Events and active workspace reads stay available afterwards.\n\t rm <session-id> Delete the record. Workspace archive is not a Phase 1 operation.\n\nEverything here except the repository set is fixed when the session is created.\nFor repositories, see `agentrt repo` -- they can be appended to afterwards.\n",
12
+ "repo": "agentrt repo <command>\n\n ls <session-id> Repositories this session may check out.\n add <session-id> <url|app-code> ... Append repositories. Takes effect on the\n next checkout, including mid-turn.\n [--ref REF] Branch, tag or commit. One repository only.\n search <name> Find an application and its repository in\n the registry.\n\nAn argument containing \"://\" or \"@\" is taken as a clone URL and stored\nverbatim; anything else is taken as an application code and resolved against\nthe registry. A code can never contain either, so the two cannot be confused.\n\nAppend-only: there is no remove. Removing a repository the agent is working\ninside would break the turn in flight.\n",
13
+ "msg": "agentrt msg <command>\n\n send <session-id> <text> [--follow] [--idempotency-key KEY] [--raw]\n Queue one turn. --follow attaches to the event stream and waits for\n the turn to finish, exiting non-zero if it failed.\n cancel <invocation-id>\n Ask for a running turn to stop. Returns as soon as the request is\n recorded, which is before the agent has actually wound down.\n",
14
+ "ops": "agentrt ops <command>\n\n runtimes [--json] Which daemons have registered, and which are still alive.\n orphans [--fix] Where the records and the world have drifted apart.\n Reports only unless --fix is given; --fix fails stranded\n turns on other sessions and cannot be undone.\n"
15
+ }
16
+ };
@@ -0,0 +1,188 @@
1
+ export const help = `df-task — DeepFlow Task fact log CLI
2
+
3
+ Usage:
4
+ df-task ls [--limit <n>] [--metadata-source <source>] [--metadata-query-key <key>]
5
+ df-task get <task-id> [--fields <paths> --json] [--metadata-source <source>] [--metadata-query-key <key>]
6
+ df-task create --title <text> [--description <text>] [--message <text>] [--metadata <json>]
7
+ df-task facts <task-id> [--after <seq>] [--types <a,b>] [--limit <n>] [--metadata-source <source>] [--metadata-query-key <key>]
8
+ df-task facts <task-id> --all --output-file <path> --json [--max-pages <n>] [--max-bytes <n>]
9
+ df-task append <task-id> --type <type> --file payload.json [--idempotency-key <k>]
10
+ df-task stream [--after <seq>] [--types <a,b>] [--limit <n>] [--metadata-source <source>] [--metadata-query-key <key>]
11
+ df-task title <task-id> --title <text>
12
+ df-task metadata <task-id> --metadata <json> [--metadata-source <source>] [--metadata-query-key <key>]
13
+
14
+ Scheduling shorthands (each one is a wrapper around append):
15
+ df-task dispatch <task-id> --agent <name> --instruction <text> [--run <run-id>]
16
+ df-task wait <task-id> --on <what> --reason <text> [--waiting-run <run-id>]
17
+ df-task handoff <task-id> --ask <text> [--assignee <who>]
18
+
19
+ Global options (before or after the command):
20
+ --base-url <url> Service URL (or DF_TASK_BASE_URL)
21
+ --json Machine-readable JSON output
22
+ help [command] Show general or command help (-h / --help also supported)
23
+
24
+ Environment:
25
+ DF_TASK_BASE_URL Service URL, default http://127.0.0.1:8090
26
+ DF_TASK_ACTOR_KIND Write identity: human | external | task_run | scheduler_run | driver
27
+ DF_TASK_ACTOR_ID Write identity id, e.g. the current run id
28
+
29
+ Facts are append-only. The API has no way to change or remove a fact, so neither does this CLI.
30
+ `;
31
+ export const commands = {
32
+ "ls": {
33
+ "usage": "ls [--limit <n>] [--metadata-source <source>] [--metadata-query-key <key>]",
34
+ "description": "List tasks, newest first.",
35
+ "flags": [
36
+ "--limit",
37
+ "--metadata-source",
38
+ "--metadata-query-key"
39
+ ],
40
+ "required": [],
41
+ "needsID": false,
42
+ "writes": false
43
+ },
44
+ "get": {
45
+ "usage": "get <task-id> [--fields <paths> --json] [--metadata-source <source>] [--metadata-query-key <key>]",
46
+ "description": "Read one task's identity. --fields selects known fields and object paths; absent paths are omitted.",
47
+ "flags": [
48
+ "--fields",
49
+ "--metadata-source",
50
+ "--metadata-query-key"
51
+ ],
52
+ "required": [],
53
+ "needsID": true,
54
+ "writes": false
55
+ },
56
+ "create": {
57
+ "usage": "create --title <text> [--description <text>] [--message <text>] [--metadata <json>]",
58
+ "description": "Create a task, optionally with its first user message in the same transaction.",
59
+ "flags": [
60
+ "--title",
61
+ "--description",
62
+ "--message",
63
+ "--metadata"
64
+ ],
65
+ "required": [
66
+ "--title"
67
+ ],
68
+ "needsID": false,
69
+ "writes": true
70
+ },
71
+ "facts": {
72
+ "usage": "facts <task-id> [--after <seq>] [--types <a,b>] [--limit <n>] [--fields <paths> --json | --all --output-file <path> --json [--max-pages <n>] [--max-bytes <n>]] [--metadata-source <source>] [--metadata-query-key <key>]",
73
+ "description": "Read facts in seq order. --fields selects fields per fact, retaining pagination. --all writes complete raw pages to a private file and prints a manifest; limits default to 10 pages / 8 MiB. Only an observed empty page marks complete. Partial reads exit 1.",
74
+ "flags": [
75
+ "--fields",
76
+ "--all",
77
+ "--output-file",
78
+ "--max-pages",
79
+ "--max-bytes",
80
+ "--after",
81
+ "--types",
82
+ "--limit",
83
+ "--metadata-source",
84
+ "--metadata-query-key"
85
+ ],
86
+ "required": [],
87
+ "needsID": true,
88
+ "writes": false
89
+ },
90
+ "append": {
91
+ "usage": "append <task-id> --type <type> --file payload.json [--idempotency-key <k>]",
92
+ "description": "Append one fact from a JSON payload file.",
93
+ "flags": [
94
+ "--type",
95
+ "--file",
96
+ "--idempotency-key"
97
+ ],
98
+ "required": [
99
+ "--type",
100
+ "--file"
101
+ ],
102
+ "needsID": true,
103
+ "writes": true
104
+ },
105
+ "stream": {
106
+ "usage": "stream [--after <seq>] [--types <a,b>] [--limit <n>] [--metadata-source <source>] [--metadata-query-key <key>]",
107
+ "description": "Read facts across all tasks from a cursor. One-shot pull, not a long connection.",
108
+ "flags": [
109
+ "--after",
110
+ "--types",
111
+ "--limit",
112
+ "--metadata-source",
113
+ "--metadata-query-key"
114
+ ],
115
+ "required": [],
116
+ "needsID": false,
117
+ "writes": false
118
+ },
119
+ "title": {
120
+ "usage": "title <task-id> --title <text>",
121
+ "description": "Change a task's title. Writes no fact.",
122
+ "flags": [
123
+ "--title"
124
+ ],
125
+ "required": [
126
+ "--title"
127
+ ],
128
+ "needsID": true,
129
+ "writes": true
130
+ },
131
+ "metadata": {
132
+ "usage": "metadata <task-id> --metadata <json> [--metadata-source <source>] [--metadata-query-key <key>]",
133
+ "description": "Replace a task's metadata as human. Writes no fact.",
134
+ "flags": [
135
+ "--metadata",
136
+ "--metadata-source",
137
+ "--metadata-query-key"
138
+ ],
139
+ "required": [
140
+ "--metadata"
141
+ ],
142
+ "needsID": true,
143
+ "writes": true
144
+ },
145
+ "dispatch": {
146
+ "usage": "dispatch <task-id> --agent <name> --instruction <text> [--run <run-id>]",
147
+ "description": "Append a dispatch intent. Without --run it asks for a new run; with --run it posts a message to that run.",
148
+ "flags": [
149
+ "--agent",
150
+ "--instruction",
151
+ "--run"
152
+ ],
153
+ "required": [
154
+ "--agent",
155
+ "--instruction"
156
+ ],
157
+ "needsID": true,
158
+ "writes": true
159
+ },
160
+ "wait": {
161
+ "usage": "wait <task-id> --on <what> --reason <text> [--waiting-run <run-id>]",
162
+ "description": "Append a waiting reason. It is context for humans and the next scheduling run, not a gate for the driver.",
163
+ "flags": [
164
+ "--on",
165
+ "--reason",
166
+ "--waiting-run"
167
+ ],
168
+ "required": [
169
+ "--on",
170
+ "--reason"
171
+ ],
172
+ "needsID": true,
173
+ "writes": true
174
+ },
175
+ "handoff": {
176
+ "usage": "handoff <task-id> --ask <text> [--assignee <who>]",
177
+ "description": "Append a handoff to a human. Handing the ball back and asking a human to do a step are the same fact.",
178
+ "flags": [
179
+ "--ask",
180
+ "--assignee"
181
+ ],
182
+ "required": [
183
+ "--ask"
184
+ ],
185
+ "needsID": true,
186
+ "writes": true
187
+ }
188
+ };