openclaw-scheduler 0.2.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.
Files changed (70) hide show
  1. package/AGENTS.md +302 -0
  2. package/BEST-PRACTICES.md +506 -0
  3. package/CHANGELOG.md +82 -0
  4. package/CODE_OF_CONDUCT.md +22 -0
  5. package/CONTEXT.md +26 -0
  6. package/CONTRIBUTING.md +73 -0
  7. package/IMPLEMENTATION_SPEC.md +170 -0
  8. package/INSTALL-ADDITIONAL-HOST.md +333 -0
  9. package/INSTALL-LINUX.md +419 -0
  10. package/INSTALL-WINDOWS.md +305 -0
  11. package/INSTALL.md +364 -0
  12. package/JOB-QUICK-REF.md +222 -0
  13. package/LICENSE +21 -0
  14. package/QUICK-START.md +256 -0
  15. package/README.md +2170 -0
  16. package/SECURITY.md +34 -0
  17. package/UNINSTALL.md +129 -0
  18. package/UPGRADING.md +436 -0
  19. package/agents.js +67 -0
  20. package/approval.js +107 -0
  21. package/backup.js +390 -0
  22. package/bin/openclaw-scheduler.js +138 -0
  23. package/cli.js +1083 -0
  24. package/db.js +122 -0
  25. package/dispatch/529-recovery.mjs +204 -0
  26. package/dispatch/README.md +372 -0
  27. package/dispatch/config.example.json +24 -0
  28. package/dispatch/deliver-watcher.sh +57 -0
  29. package/dispatch/hooks.mjs +171 -0
  30. package/dispatch/index.mjs +1836 -0
  31. package/dispatch/watcher.mjs +1396 -0
  32. package/dispatch-queue.js +112 -0
  33. package/dispatcher-approvals.js +96 -0
  34. package/dispatcher-delivery.js +43 -0
  35. package/dispatcher-maintenance.js +242 -0
  36. package/dispatcher-shell.js +29 -0
  37. package/dispatcher-strategies.js +1280 -0
  38. package/dispatcher-utils.js +81 -0
  39. package/dispatcher.js +855 -0
  40. package/docs/adr-schedule-ownership.md +73 -0
  41. package/docs/gateway-contract.md +904 -0
  42. package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
  43. package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
  44. package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
  45. package/docs/trust-architecture.md +266 -0
  46. package/gateway.js +473 -0
  47. package/idempotency.js +119 -0
  48. package/index.d.ts +864 -0
  49. package/index.js +17 -0
  50. package/jobs.js +1224 -0
  51. package/messages.js +357 -0
  52. package/migrate-consolidate.js +694 -0
  53. package/migrate.js +125 -0
  54. package/package.json +130 -0
  55. package/paths.js +79 -0
  56. package/prompt-context.js +94 -0
  57. package/retrieval.js +176 -0
  58. package/runs.js +270 -0
  59. package/scheduler-schema.js +101 -0
  60. package/schema.sql +480 -0
  61. package/scripts/dispatch-cli-utils.mjs +65 -0
  62. package/scripts/inbox-consumer.mjs +288 -0
  63. package/scripts/stuck-detector.sh +18 -0
  64. package/scripts/stuck-run-detector.mjs +333 -0
  65. package/scripts/telegram-webhook-check.mjs +238 -0
  66. package/setup.mjs +724 -0
  67. package/shell-result.js +214 -0
  68. package/task-tracker.js +300 -0
  69. package/team-adapter.js +335 -0
  70. package/v02-runtime.js +599 -0
package/cli.js ADDED
@@ -0,0 +1,1083 @@
1
+ #!/usr/bin/env node
2
+ // Scheduler CLI -- manage jobs, runs, messages, agents
3
+ import { readFileSync } from 'fs';
4
+ import { initDb, getDb } from './db.js';
5
+ import { createJob, getJob, listJobs, updateJob, deleteJob, cancelJob, runJobNow, validateJobSpec, parseInDuration, AT_JOB_CRON_SENTINEL } from './jobs.js';
6
+ import { getRun, getRunsForJob, getRunningRuns, getStaleRuns, finishRun } from './runs.js';
7
+ import {
8
+ sendMessage, getInbox, getOutbox, getThread, markRead, markAllRead, getUnreadCount, pruneMessages,
9
+ ackMessage, listMessageReceipts, getTeamMessages,
10
+ } from './messages.js';
11
+ import { upsertAgent, getAgent, listAgents } from './agents.js';
12
+ import { SCHEDULER_SCHEMAS } from './scheduler-schema.js';
13
+
14
+ const cliArgs = process.argv.slice(2);
15
+ const jsonFlagIndex = cliArgs.indexOf('--json');
16
+ const jsonMode = jsonFlagIndex >= 0;
17
+ if (jsonFlagIndex >= 0) cliArgs.splice(jsonFlagIndex, 1);
18
+ const [command, sub, ...args] = cliArgs;
19
+
20
+ function usage() {
21
+ console.log(`
22
+ Usage: openclaw-scheduler <command> [subcommand] [options]
23
+
24
+ Global:
25
+ --json Emit machine-readable JSON
26
+
27
+ Jobs:
28
+ jobs list [--type watchdog] List all jobs (optionally filter by type)
29
+ jobs tree Show jobs as parent/child tree
30
+ jobs get <id> Get job details
31
+ jobs add <json> [--watchdog] [--at <datetime>] [--in <duration>] [--profile <id>]
32
+ Add a job (--watchdog sets defaults for watchdog type)
33
+ run_timeout_ms is REQUIRED (no default -- prevents indefinite runs)
34
+ --at: one-shot schedule, e.g. '2026-03-10T16:47:00-04:00'
35
+ --in: one-shot sugar, e.g. '15m', '2h', '30s', '1d'
36
+ --profile: auth profile override (null, 'inherit', or 'provider:label')
37
+ jobs validate <json> Validate a job spec without writing it
38
+ jobs enable <id> Enable a job
39
+ jobs disable <id> Disable a job
40
+ jobs delete <id> Delete a job
41
+ jobs cancel <id> [--no-cascade] Cancel a job (+ children by default)
42
+ jobs update <id> <json> [--profile <id>]
43
+ Update job fields
44
+ --profile: auth profile override (null, 'inherit', or 'provider:label')
45
+ jobs run <id> Trigger immediate run (sets next_run_at to now)
46
+ jobs approve <id> Approve a pending job
47
+ jobs reject <id> [reason] Reject with optional reason
48
+
49
+ Runs:
50
+ runs list <job-id> [limit] List runs for a job
51
+ runs get <run-id> Get a run by id
52
+ runs output <run-id> [stdout|stderr] Print offloaded or stored shell output
53
+ runs running Currently running runs
54
+ runs stale [threshold-s] Stale runs
55
+
56
+ Queue:
57
+ queue list [agent] [limit] Show pending + delivered messages (default: main)
58
+ queue clear [agent] Mark all messages read
59
+ queue prune Prune old messages now
60
+
61
+ Messages:
62
+ messages send --from <label> [--to <agent>] [--kind <kind>] --body "<text>"
63
+ Send a checkpoint/status message (kind defaults to 'status', to defaults to 'main')
64
+ msg send <from> <to> <body> Send a message (positional form)
65
+ msg inbox <agent-id> [limit] Get inbox (unread)
66
+ msg team-inbox <team-id> [limit] [member-id] [task-id] Get team mailbox
67
+ msg outbox <agent-id> [limit] Get outbox
68
+ msg thread <message-id> Get message thread
69
+ msg ack <message-id> [actor] [note] Mark message as acknowledged/read
70
+ msg receipts <message-id> [limit] Show delivery/ack receipt events
71
+ msg read <message-id> Mark message as read
72
+ msg readall <agent-id> Mark all as read
73
+ msg unread <agent-id> Unread count
74
+
75
+ Team Adapter:
76
+ team map [limit] Project unmapped team messages into mailbox/task events
77
+ team tasks <team-id> [limit] List projected team tasks
78
+ team events <team-id> [limit] [task-id] List team mailbox events
79
+ team gate <team-id> <task-id> <members-json> [timeout-s] Open task completion gate
80
+ team check-gates [limit] Evaluate/advance team task gates
81
+ team ack <message-id> [actor] [note] Team-aware ACK (creates team mailbox event)
82
+
83
+ Agents:
84
+ agents list List registered agents
85
+ agents get <id> Get agent details
86
+ agents register <id> [name] Register/update agent
87
+
88
+ Tasks:
89
+ tasks list List active task groups
90
+ tasks status <id> Detailed status of a task group
91
+ tasks create <json> Create a tracked task group
92
+ tasks history [limit] Recent completed task groups
93
+ tasks heartbeat <id> <label> running|completed|failed [msg] Sub-agent reports status
94
+ tasks register-session <id> <label> <key> Orchestrator links session key to agent
95
+
96
+ Approvals:
97
+ approvals list List pending approvals
98
+ approvals pending Alias for list
99
+
100
+ Idempotency:
101
+ idem status <job-id> Show recent idempotency keys for a job
102
+ idem check <key> Check if a key is claimed
103
+ idem release <key> Manually release a claimed key
104
+ idem prune Force prune expired entries
105
+
106
+ Aliases:
107
+ alias list List all delivery aliases
108
+ alias add <name> <ch> <tgt> [desc] Add a delivery alias
109
+ alias remove <name> Remove a delivery alias
110
+
111
+ Status:
112
+ status Overall scheduler status
113
+
114
+ Schema:
115
+ schema [jobs|runs|messages|approvals|dispatches|all]
116
+
117
+ Capabilities:
118
+ capabilities Report v0.2 feature support
119
+ `);
120
+ }
121
+
122
+ await initDb();
123
+
124
+ function fmt(obj) { return JSON.stringify(obj, null, 2); }
125
+
126
+ function emit(data, human = null) {
127
+ if (jsonMode) {
128
+ console.log(fmt(data));
129
+ return;
130
+ }
131
+ if (typeof human === 'function') {
132
+ human();
133
+ return;
134
+ }
135
+ if (typeof human === 'string') {
136
+ console.log(human);
137
+ return;
138
+ }
139
+ console.log(typeof data === 'string' ? data : fmt(data));
140
+ }
141
+
142
+ function fail(message, code = 1) {
143
+ if (jsonMode) {
144
+ console.log(fmt({ ok: false, error: message }));
145
+ } else {
146
+ console.error(message);
147
+ }
148
+ process.exit(code);
149
+ }
150
+
151
+ switch (command) {
152
+ // -- Jobs ------------------------------------------------
153
+ case 'jobs':
154
+ switch (sub) {
155
+ case 'list': {
156
+ let jobs = listJobs();
157
+ // Filter by --type if provided (e.g. --type watchdog)
158
+ const typeFilterIdx = args.indexOf('--type');
159
+ if (typeFilterIdx >= 0 && args[typeFilterIdx + 1]) {
160
+ const typeFilter = args[typeFilterIdx + 1];
161
+ jobs = jobs.filter(j => (j.job_type || 'standard') === typeFilter);
162
+ }
163
+ const rows = jobs.map(j => ({
164
+ id: j.id.slice(0, 8) + '..',
165
+ name: j.name,
166
+ type: j.job_type || 'standard',
167
+ kind: j.schedule_kind || 'cron',
168
+ enabled: !!j.enabled,
169
+ schedule: j.schedule_kind === 'at' ? `at:${(j.schedule_at || '').slice(0, 16)}` : j.schedule_cron,
170
+ agent: j.agent_id || 'main',
171
+ target: j.session_target,
172
+ guarantee: j.delivery_guarantee || 'at-most-once',
173
+ parent: j.parent_id ? j.parent_id.slice(0, 8) + '..' : '-',
174
+ trigger: j.trigger_on || '-',
175
+ ...(j.job_type === 'watchdog' ? { watchdog: j.watchdog_target_label || '-' } : {}),
176
+ nextRun: j.next_run_at,
177
+ lastStatus: j.last_status,
178
+ errors: j.consecutive_errors,
179
+ }));
180
+ emit(jsonMode ? jobs : rows, () => console.table(rows));
181
+ break;
182
+ }
183
+ case 'tree': {
184
+ const jobs = listJobs();
185
+ const roots = jobs.filter(j => !j.parent_id);
186
+ const childMap = {};
187
+ for (const j of jobs) {
188
+ if (j.parent_id) {
189
+ if (!childMap[j.parent_id]) childMap[j.parent_id] = [];
190
+ childMap[j.parent_id].push(j);
191
+ }
192
+ }
193
+ function treeNode(job) {
194
+ return {
195
+ id: job.id,
196
+ name: job.name,
197
+ enabled: !!job.enabled,
198
+ agent_id: job.agent_id || 'main',
199
+ trigger_on: job.trigger_on || null,
200
+ trigger_delay_s: job.trigger_delay_s || 0,
201
+ children: (childMap[job.id] || []).map(treeNode),
202
+ };
203
+ }
204
+ function printTree(job, indent = '') {
205
+ const status = job.enabled ? '[on]' : '[ ]';
206
+ const trigger = job.trigger_on ? ` [on:${job.trigger_on}]` : '';
207
+ const delay = job.trigger_delay_s ? ` (+${job.trigger_delay_s}s)` : '';
208
+ console.log(`${indent}${status} ${job.name} (${job.agent_id || 'main'})${trigger}${delay}`);
209
+ const children = childMap[job.id] || [];
210
+ for (const child of children) {
211
+ printTree(child, indent + ' |- ');
212
+ }
213
+ }
214
+ emit(roots.map(treeNode), () => {
215
+ for (const root of roots) printTree(root);
216
+ });
217
+ break;
218
+ }
219
+ case 'get': emit(getJob(args[0])); break;
220
+ case 'add': {
221
+ const dryRun = args.includes('--dry-run');
222
+ const isWatchdog = args.includes('--watchdog');
223
+ const profileIdx = args.indexOf('--profile');
224
+ const profileValue = profileIdx >= 0 ? args[profileIdx + 1] : undefined;
225
+ const atIdx = args.indexOf('--at');
226
+ const atValue = atIdx >= 0 ? args[atIdx + 1] : undefined;
227
+ const inIdx = args.indexOf('--in');
228
+ const inValue = inIdx >= 0 ? args[inIdx + 1] : undefined;
229
+ const skipArgs = new Set(['--dry-run', '--watchdog']);
230
+ if (profileIdx >= 0) { skipArgs.add(args[profileIdx]); skipArgs.add(args[profileIdx + 1]); }
231
+ if (atIdx >= 0) { skipArgs.add(args[atIdx]); skipArgs.add(args[atIdx + 1]); }
232
+ if (inIdx >= 0) { skipArgs.add(args[inIdx]); skipArgs.add(args[inIdx + 1]); }
233
+ const payload = args.find(a => !skipArgs.has(a));
234
+ if (!payload) fail('Usage: jobs add <json> [--dry-run] [--watchdog] [--at <datetime>] [--in <duration>] [--profile <id>]');
235
+ let spec;
236
+ try { spec = JSON.parse(payload); } catch { fail('Invalid JSON. Usage: jobs add \'{"name":"..."}\''); }
237
+ if (profileValue !== undefined) spec.auth_profile = profileValue;
238
+
239
+ // One-shot scheduling via --at or --in
240
+ if (atValue || inValue) {
241
+ let scheduleAt;
242
+ try {
243
+ if (inValue) {
244
+ scheduleAt = parseInDuration(inValue);
245
+ } else {
246
+ const d = new Date(atValue);
247
+ if (isNaN(d.getTime())) throw new Error(`Invalid datetime: "${atValue}"`);
248
+ scheduleAt = d.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, '');
249
+ }
250
+ } catch (err) {
251
+ fail(`--at/--in error: ${err.message}`);
252
+ }
253
+ spec.schedule_kind = 'at';
254
+ spec.schedule_at = scheduleAt;
255
+ // Use sentinel cron to satisfy NOT NULL on existing DBs without nullable schedule_cron
256
+ if (!spec.schedule_cron) spec.schedule_cron = AT_JOB_CRON_SENTINEL;
257
+ spec.next_run_at = scheduleAt;
258
+ // Default delete_after_run for at-jobs (user can override in JSON)
259
+ if (spec.delete_after_run === undefined) spec.delete_after_run = 1;
260
+ }
261
+
262
+ // If --watchdog flag is set, apply watchdog defaults
263
+ if (isWatchdog) {
264
+ spec.job_type = 'watchdog';
265
+ // Watchdog jobs default to shell target with shellCommand kind
266
+ if (!spec.session_target) spec.session_target = 'shell';
267
+ if (!spec.payload_kind) spec.payload_kind = 'shellCommand';
268
+ if (!spec.payload_message) spec.payload_message = spec.watchdog_check_cmd || 'true';
269
+ if (!spec.delivery_mode) spec.delivery_mode = 'none';
270
+ if (spec.watchdog_self_destruct === undefined) spec.watchdog_self_destruct = 1;
271
+ }
272
+
273
+ const normalized = validateJobSpec(spec, null, 'create');
274
+ if (dryRun) {
275
+ emit({ ok: true, dry_run: true, valid: true, normalized });
276
+ break;
277
+ }
278
+ const job = createJob(spec);
279
+ emit({ ok: true, job }, `Created: ${fmt(job)}`);
280
+ break;
281
+ }
282
+ case 'validate': {
283
+ if (!args[0]) fail('Usage: jobs validate <json>');
284
+ let spec;
285
+ try { spec = JSON.parse(args[0]); } catch { fail('Invalid JSON. Usage: jobs validate \'{"name":"..."}\''); }
286
+ const normalized = validateJobSpec(spec, null, 'create');
287
+ emit({ ok: true, valid: true, normalized });
288
+ break;
289
+ }
290
+ case 'enable': updateJob(args[0], { enabled: 1 }); emit({ ok: true, job_id: args[0], enabled: true }, 'Enabled'); break;
291
+ case 'disable': updateJob(args[0], { enabled: 0 }); emit({ ok: true, job_id: args[0], enabled: false }, 'Disabled'); break;
292
+ case 'delete': deleteJob(args[0]); emit({ ok: true, job_id: args[0], deleted: true }, 'Deleted'); break;
293
+ case 'cancel': {
294
+ const noCascade = args.includes('--no-cascade');
295
+ const id = args.find(a => !a.startsWith('--'));
296
+ if (!id) fail('Usage: jobs cancel <id> [--no-cascade]');
297
+ const cancelled = cancelJob(id, { cascade: !noCascade });
298
+ emit({ ok: true, cancelled }, `Cancelled ${cancelled.length} job(s): ${cancelled.map(c => c.slice(0, 8) + '..').join(', ')}`);
299
+ break;
300
+ }
301
+ case 'update': {
302
+ const dryRun = args.includes('--dry-run');
303
+ const updateProfileIdx = args.indexOf('--profile');
304
+ const updateProfileValue = updateProfileIdx >= 0 ? args[updateProfileIdx + 1] : undefined;
305
+ const updateFilterArgs = new Set(['--dry-run']);
306
+ if (updateProfileIdx >= 0) { updateFilterArgs.add(args[updateProfileIdx]); updateFilterArgs.add(args[updateProfileIdx + 1]); }
307
+ const updateArgs = args.filter(a => !updateFilterArgs.has(a));
308
+ const current = getJob(updateArgs[0]);
309
+ if (!current) fail(`Job not found: ${updateArgs[0]}`);
310
+ let patch;
311
+ try { patch = JSON.parse(updateArgs[1]); } catch { fail('Invalid JSON. Usage: jobs update <id> \'{"key":"value"}\''); }
312
+ if (updateProfileValue !== undefined) patch.auth_profile = updateProfileValue;
313
+ const normalized = validateJobSpec(patch, current, 'update');
314
+ if (dryRun) {
315
+ emit({ ok: true, dry_run: true, valid: true, normalized });
316
+ break;
317
+ }
318
+ const job = updateJob(updateArgs[0], patch);
319
+ emit({ ok: true, job }, `Updated: ${fmt(job)}`);
320
+ break;
321
+ }
322
+ case 'run': {
323
+ const job = runJobNow(args[0]);
324
+ if (!job) fail(`Job not found: ${args[0]}`);
325
+ emit(
326
+ { ok: true, job_id: job.id, name: job.name, dispatch_id: job.dispatch_id, dispatch_kind: job.dispatch_kind },
327
+ `Scheduled for immediate run: ${job.name} (dispatch: ${job.dispatch_id})`
328
+ );
329
+ break;
330
+ }
331
+ case 'approve': {
332
+ if (!args[0]) fail('Usage: jobs approve <job-id>');
333
+ const { getPendingApproval, resolveApproval } = await import('./approval.js');
334
+ const approval = getPendingApproval(args[0]);
335
+ if (!approval) fail(`No pending approval for job: ${args[0]}`);
336
+ resolveApproval(approval.id, 'approved', 'operator');
337
+ emit({ ok: true, approval_id: approval.id, job_id: approval.job_id, status: 'approved' }, `Approved: ${approval.job_id}`);
338
+ break;
339
+ }
340
+ case 'reject': {
341
+ if (!args[0]) fail('Usage: jobs reject <job-id> [reason]');
342
+ const { getPendingApproval, resolveApproval } = await import('./approval.js');
343
+ const approval = getPendingApproval(args[0]);
344
+ if (!approval) fail(`No pending approval for job: ${args[0]}`);
345
+ const reason = args.slice(1).join(' ') || null;
346
+ resolveApproval(approval.id, 'rejected', 'operator', reason);
347
+ if (approval.run_id) {
348
+ finishRun(approval.run_id, 'cancelled', { error_message: reason || 'Rejected by operator' });
349
+ }
350
+ emit(
351
+ { ok: true, approval_id: approval.id, job_id: approval.job_id, status: 'rejected', reason },
352
+ `Rejected: ${approval.job_id}${reason ? ' -- ' + reason : ''}`
353
+ );
354
+ break;
355
+ }
356
+ default: usage();
357
+ }
358
+ break;
359
+
360
+ // -- Runs ------------------------------------------------
361
+ case 'runs':
362
+ switch (sub) {
363
+ case 'list': {
364
+ if (!args[0]) fail('Usage: runs list <job-id> [limit]');
365
+ const runs = getRunsForJob(args[0], parseInt(args[1] || '20', 10));
366
+ const rows = runs.map(r => ({
367
+ id: r.id.slice(0, 8),
368
+ status: r.status,
369
+ started: r.started_at,
370
+ finished: r.finished_at,
371
+ durationMs: r.duration_ms,
372
+ heartbeat: r.last_heartbeat,
373
+ }));
374
+ emit(jsonMode ? runs : rows, () => console.table(rows));
375
+ break;
376
+ }
377
+ case 'get': {
378
+ const run = getRun(args[0]);
379
+ if (!run) fail(`Run not found: ${args[0]}`);
380
+ emit(run);
381
+ break;
382
+ }
383
+ case 'output': {
384
+ if (!args[0]) fail('Usage: runs output <run-id> [stdout|stderr]');
385
+ const run = getRun(args[0]);
386
+ if (!run) fail(`Run not found: ${args[0]}`);
387
+ const kind = (args[1] || 'stdout').toLowerCase();
388
+ if (kind !== 'stdout' && kind !== 'stderr') fail('Usage: runs output <run-id> [stdout|stderr]');
389
+ const pathField = kind === 'stderr' ? 'shell_stderr_path' : 'shell_stdout_path';
390
+ const textField = kind === 'stderr' ? 'shell_stderr' : 'shell_stdout';
391
+ const filePath = run[pathField];
392
+ let payload;
393
+ try {
394
+ payload = filePath ? readFileSync(filePath, 'utf8') : (run[textField] || '');
395
+ } catch (err) {
396
+ fail(`Cannot read output file ${filePath}: ${err.message}`);
397
+ }
398
+ if (jsonMode) {
399
+ emit({ ok: true, run_id: run.id, kind, file_path: filePath || null, content: payload });
400
+ } else {
401
+ process.stdout.write(payload);
402
+ if (!payload.endsWith('\n')) process.stdout.write('\n');
403
+ }
404
+ break;
405
+ }
406
+ case 'running': {
407
+ const runs = getRunningRuns();
408
+ if (runs.length === 0) { emit([] , 'No running runs'); break; }
409
+ const rows = runs.map(r => ({
410
+ id: r.id.slice(0, 8),
411
+ job: r.job_name,
412
+ started: r.started_at,
413
+ heartbeat: r.last_heartbeat,
414
+ sessionKey: r.session_key,
415
+ }));
416
+ emit(jsonMode ? runs : rows, () => console.table(rows));
417
+ break;
418
+ }
419
+ case 'stale': {
420
+ const stale = getStaleRuns(parseInt(args[0] || '90', 10));
421
+ if (stale.length === 0) { emit([], 'No stale runs'); break; }
422
+ const rows = stale.map(r => ({
423
+ id: r.id.slice(0, 8),
424
+ job: r.job_name,
425
+ heartbeat: r.last_heartbeat,
426
+ }));
427
+ emit(jsonMode ? stale : rows, () => console.table(rows));
428
+ break;
429
+ }
430
+ default: usage();
431
+ }
432
+ break;
433
+
434
+ // -- Messages --------------------------------------------
435
+ case 'msg':
436
+ switch (sub) {
437
+ case 'send': {
438
+ const [from, to, ...bodyParts] = args;
439
+ if (!from || !to || !bodyParts.length) fail('Usage: msg send <from> <to> <body>');
440
+ const msg = sendMessage({ from_agent: from, to_agent: to, body: bodyParts.join(' ') });
441
+ emit({ ok: true, message: msg }, `Sent: ${fmt(msg)}`);
442
+ break;
443
+ }
444
+ case 'inbox': {
445
+ const msgs = getInbox(args[0], { limit: parseInt(args[1] || '20', 10), includeDelivered: true });
446
+ if (msgs.length === 0) { emit([], 'Inbox empty'); break; }
447
+ const rows = msgs.map(m => ({
448
+ id: m.id.slice(0, 8),
449
+ from: m.from_agent,
450
+ kind: m.kind,
451
+ subject: m.subject?.slice(0, 40),
452
+ status: m.status,
453
+ priority: m.priority,
454
+ created: m.created_at,
455
+ }));
456
+ emit(jsonMode ? msgs : rows, () => console.table(rows));
457
+ break;
458
+ }
459
+ case 'team-inbox': {
460
+ const teamId = args[0];
461
+ if (!teamId) fail('Usage: msg team-inbox <team-id> [limit] [member-id] [task-id]');
462
+ const limit = parseInt(args[1] || '20', 10);
463
+ const memberId = args[2] || null;
464
+ const taskId = args[3] || null;
465
+ const msgs = getTeamMessages(teamId, { limit, memberId, taskId, includeRead: true });
466
+ if (msgs.length === 0) { emit([], 'Team inbox empty'); break; }
467
+ const rows = msgs.map(m => ({
468
+ id: m.id.slice(0, 8),
469
+ from: m.from_agent,
470
+ to: m.to_agent,
471
+ member: m.member_id || '-',
472
+ task: m.task_id || '-',
473
+ kind: m.kind,
474
+ status: m.status,
475
+ ackRequired: !!m.ack_required,
476
+ ackAt: m.ack_at || '-',
477
+ attempts: m.delivery_attempts || 0,
478
+ lastError: m.last_error || '-',
479
+ created: m.created_at,
480
+ }));
481
+ emit(jsonMode ? msgs : rows, () => console.table(rows));
482
+ break;
483
+ }
484
+ case 'outbox': {
485
+ const msgs = getOutbox(args[0], parseInt(args[1] || '20', 10));
486
+ if (msgs.length === 0) { emit([], 'Outbox empty'); break; }
487
+ const rows = msgs.map(m => ({
488
+ id: m.id.slice(0, 8),
489
+ to: m.to_agent,
490
+ kind: m.kind,
491
+ subject: m.subject?.slice(0, 40),
492
+ status: m.status,
493
+ created: m.created_at,
494
+ }));
495
+ emit(jsonMode ? msgs : rows, () => console.table(rows));
496
+ break;
497
+ }
498
+ case 'thread': {
499
+ const msgs = getThread(args[0]);
500
+ emit(msgs, () => {
501
+ for (const m of msgs) {
502
+ console.log(`[${m.from_agent} -> ${m.to_agent}] (${m.status}) ${m.created_at}`);
503
+ console.log(` ${(m.body || '').slice(0, 200)}`);
504
+ console.log();
505
+ }
506
+ });
507
+ break;
508
+ }
509
+ case 'ack': {
510
+ if (!args[0]) fail('Usage: msg ack <message-id> [actor] [note]');
511
+ const actor = args[1] || 'operator';
512
+ const detail = args.slice(2).join(' ') || null;
513
+ const msg = ackMessage(args[0], actor, detail);
514
+ if (!msg) fail('Message not found: ' + args[0]);
515
+ emit(
516
+ { ok: true, id: msg.id, status: msg.status, ack_at: msg.ack_at, read_at: msg.read_at },
517
+ `Acknowledged: ${fmt({ id: msg.id, status: msg.status, ack_at: msg.ack_at, read_at: msg.read_at })}`
518
+ );
519
+ break;
520
+ }
521
+ case 'receipts': {
522
+ if (!args[0]) fail('Usage: msg receipts <message-id> [limit]');
523
+ const receipts = listMessageReceipts(args[0], parseInt(args[1] || '20', 10));
524
+ if (receipts.length === 0) { emit([], 'No receipts for message'); break; }
525
+ const rows = receipts.map(r => ({
526
+ id: r.id.slice(0, 8),
527
+ type: r.event_type,
528
+ attempt: r.attempt ?? '-',
529
+ actor: r.actor || '-',
530
+ detail: (r.detail || '').slice(0, 80),
531
+ at: r.created_at,
532
+ }));
533
+ emit(jsonMode ? receipts : rows, () => console.table(rows));
534
+ break;
535
+ }
536
+ case 'read': { if (!args[0]) fail('Usage: msg read <message-id>'); markRead(args[0]); emit({ ok: true, message_id: args[0], read: true }, 'Marked read'); break; }
537
+ case 'readall': { if (!args[0]) fail('Usage: msg readall <agent-id>'); const r = markAllRead(args[0]); emit({ ok: true, agent: args[0], changes: r.changes }, `Marked ${r.changes} read`); break; }
538
+ case 'unread': { if (!args[0]) fail('Usage: msg unread <agent-id>'); const count = getUnreadCount(args[0]); emit({ agent: args[0], unread: count }, `Unread: ${count}`); break; }
539
+ default: usage();
540
+ }
541
+ break;
542
+
543
+ // -- Messages (named-flag interface) --------------------------
544
+ case 'messages':
545
+ switch (sub) {
546
+ case 'send': {
547
+ // Parse named flags from args: --from, --to, --kind, --body
548
+ const mFlags = {};
549
+ for (let i = 0; i < args.length; i++) {
550
+ if (args[i].startsWith('--')) {
551
+ const key = args[i].slice(2);
552
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
553
+ mFlags[key] = args[i + 1];
554
+ i++;
555
+ } else {
556
+ mFlags[key] = true;
557
+ }
558
+ }
559
+ }
560
+ const mFrom = mFlags.from;
561
+ const mTo = mFlags.to || 'main';
562
+ const mKind = mFlags.kind || 'status';
563
+ const mBody = mFlags.body;
564
+ if (!mFrom || !mBody) {
565
+ fail('Usage: messages send --from <label> [--to <agent>] [--kind <kind>] --body "<text>"');
566
+ }
567
+ const msg = sendMessage({ from_agent: mFrom, to_agent: mTo, kind: mKind, body: mBody });
568
+ emit({ ok: true, message: msg }, `Sent: ${fmt(msg)}`);
569
+ break;
570
+ }
571
+ default: usage();
572
+ }
573
+ break;
574
+
575
+ // -- Queue ------------------------------------------------
576
+ case 'queue':
577
+ switch (sub) {
578
+ case 'list':
579
+ case undefined: {
580
+ const agent = args[0] || 'main';
581
+ const limit = parseInt(args[1] || '50', 10);
582
+ const msgs = getInbox(agent, { limit, includeDelivered: true });
583
+ const delivered = msgs.filter(m => m.status === 'delivered');
584
+ const unread = getUnreadCount(agent);
585
+ if (msgs.length === 0) { emit({ agent, pending: unread, delivered: 0, messages: [] }, 'Queue empty'); break; }
586
+ const rows = msgs.map(m => ({
587
+ id: m.id.slice(0, 8),
588
+ from: m.from_agent,
589
+ kind: m.kind,
590
+ subject: m.subject?.slice(0, 45),
591
+ status: m.status,
592
+ priority: m.priority,
593
+ created: m.created_at,
594
+ }));
595
+ emit(jsonMode ? { agent, pending: unread, delivered: delivered.length, messages: msgs } : rows, () => {
596
+ console.log(`\nQueue for agent: ${agent} | ${unread} pending | ${delivered.length} delivered (showing last ${limit})\n`);
597
+ console.table(rows);
598
+ });
599
+ break;
600
+ }
601
+ case 'clear': {
602
+ const r = markAllRead(args[0] || 'main');
603
+ emit({ ok: true, agent: args[0] || 'main', changes: r.changes }, `Cleared ${r.changes} messages`);
604
+ break;
605
+ }
606
+ case 'prune': {
607
+ pruneMessages();
608
+ emit({ ok: true, pruned: true }, 'Pruned old messages (delivered >3d, system/result >3d, read/expired/failed >30d)');
609
+ break;
610
+ }
611
+ default: usage();
612
+ }
613
+ break;
614
+
615
+ // -- Agents ----------------------------------------------
616
+ case 'agents':
617
+ switch (sub) {
618
+ case 'list': {
619
+ const agents = listAgents();
620
+ const rows = agents.map(a => ({
621
+ id: a.id,
622
+ name: a.name,
623
+ status: a.status,
624
+ lastSeen: a.last_seen_at,
625
+ sessionKey: a.session_key,
626
+ }));
627
+ emit(jsonMode ? agents : rows, () => console.table(rows));
628
+ break;
629
+ }
630
+ case 'get': emit(getAgent(args[0])); break;
631
+ case 'register': {
632
+ if (!args[0]) fail('Usage: agents register <agent-id> [name]');
633
+ const a = upsertAgent(args[0], { name: args[1] || args[0] });
634
+ emit({ ok: true, agent: a }, `Registered: ${fmt(a)}`);
635
+ break;
636
+ }
637
+ default: usage();
638
+ }
639
+ break;
640
+
641
+ // -- Tasks ------------------------------------------------
642
+ case 'tasks':
643
+ switch (sub) {
644
+ case 'list': {
645
+ const { listActiveTaskGroups, getTaskGroupStatus } = await import('./task-tracker.js');
646
+ const groups = listActiveTaskGroups();
647
+ if (groups.length === 0) { emit([], 'No active task groups'); break; }
648
+ const rows = groups.map(g => {
649
+ const status = getTaskGroupStatus(g.id);
650
+ if (!status) return null;
651
+ let agents;
652
+ try { agents = JSON.parse(g.expected_agents); } catch (e) {
653
+ process.stderr.write('Warning: failed to parse expected_agents JSON for group ' + g.id + ': ' + e.message + '\n');
654
+ agents = [];
655
+ }
656
+ return {
657
+ id: g.id.slice(0, 8) + '..',
658
+ name: g.name,
659
+ agents: `${status.agents.filter(a => a.status === 'completed').length}/${agents.length}`,
660
+ status: g.status,
661
+ elapsed: `${status.elapsed}s`,
662
+ timeout: `${g.timeout_s}s`,
663
+ };
664
+ }).filter(r => r !== null);
665
+ emit(jsonMode ? groups : rows, () => console.table(rows));
666
+ break;
667
+ }
668
+ case 'status': {
669
+ const { getTaskGroupStatus } = await import('./task-tracker.js');
670
+ const status = getTaskGroupStatus(args[0]);
671
+ if (!status) fail('Task group not found: ' + args[0]);
672
+ emit(status, () => {
673
+ console.log(`\nTask Group: ${status.name}`);
674
+ console.log(`Status: ${status.status}`);
675
+ console.log(`Elapsed: ${status.elapsed}s / ${status.remaining_timeout + status.elapsed}s timeout`);
676
+ console.log(`\nAgents:`);
677
+ for (const a of status.agents) {
678
+ const icon = a.status === 'completed' ? '[ok]' : a.status === 'failed' ? '[FAIL]' : a.status === 'dead' ? '[DEAD]' : a.status === 'running' ? '[..]' : '[ ]';
679
+ const dur = a.duration != null ? ` (${a.duration}s)` : '';
680
+ const detail = a.exit_message || a.error || '';
681
+ console.log(` ${icon} ${a.label}: ${a.status}${dur}${detail ? ' -- ' + detail : ''}`);
682
+ }
683
+ if (status.summary) {
684
+ console.log(`\nSummary:\n${status.summary}`);
685
+ }
686
+ });
687
+ break;
688
+ }
689
+ case 'create': {
690
+ const { createTaskGroup } = await import('./task-tracker.js');
691
+ let opts;
692
+ try { opts = JSON.parse(args[0]); } catch { fail('Invalid JSON. Usage: tasks create \'{"name":"..."}\''); }
693
+ const group = createTaskGroup(opts);
694
+ emit({ ok: true, group }, `Created: ${fmt(group)}`);
695
+ break;
696
+ }
697
+ case 'history': {
698
+ const limit = parseInt(args[0] || '10', 10);
699
+ const groups = getDb().prepare(
700
+ "SELECT * FROM task_tracker WHERE status != 'active' ORDER BY completed_at DESC LIMIT ?"
701
+ ).all(limit);
702
+ if (groups.length === 0) { emit([], 'No completed task groups'); break; }
703
+ const rows = groups.map(g => ({
704
+ id: g.id.slice(0, 8) + '..',
705
+ name: g.name,
706
+ status: g.status,
707
+ completed: g.completed_at,
708
+ created_by: g.created_by,
709
+ }));
710
+ emit(jsonMode ? groups : rows, () => console.table(rows));
711
+ break;
712
+ }
713
+
714
+ // -- tasks heartbeat ----------------------------------
715
+ // Called BY sub-agents during execution to report status.
716
+ // Usage: tasks heartbeat <trackerId> <agentLabel> running|completed|failed [message]
717
+ case 'heartbeat': {
718
+ const { agentStarted, agentCompleted, agentFailed } = await import('./task-tracker.js');
719
+ const [trackerId, agentLabel, status, ...msgParts] = args;
720
+ const exitMsg = msgParts.join(' ') || undefined;
721
+
722
+ if (!trackerId || !agentLabel || !status) {
723
+ fail('Usage: tasks heartbeat <trackerId> <agentLabel> running|completed|failed [message]');
724
+ }
725
+
726
+ if (status === 'running') {
727
+ agentStarted(trackerId, agentLabel);
728
+ emit({ ok: true, tracker_id: trackerId, agent: agentLabel, status: 'running' }, `Heartbeat recorded: ${agentLabel} -> running`);
729
+ } else if (status === 'completed') {
730
+ agentCompleted(trackerId, agentLabel, exitMsg);
731
+ emit({ ok: true, tracker_id: trackerId, agent: agentLabel, status: 'completed', message: exitMsg }, `Heartbeat recorded: ${agentLabel} -> completed${exitMsg ? ` (${exitMsg})` : ''}`);
732
+ } else if (status === 'failed') {
733
+ agentFailed(trackerId, agentLabel, exitMsg || 'failed');
734
+ emit({ ok: true, tracker_id: trackerId, agent: agentLabel, status: 'failed', message: exitMsg }, `Heartbeat recorded: ${agentLabel} -> failed${exitMsg ? ` (${exitMsg})` : ''}`);
735
+ } else {
736
+ fail(`Unknown status: "${status}". Valid values: running | completed | failed`);
737
+ }
738
+ break;
739
+ }
740
+
741
+ // -- tasks register-session ----------------------------
742
+ // Called BY the orchestrator after spawning a sub-agent.
743
+ // Links the sub-agent's OC session key to the tracker agent so
744
+ // the dispatcher can auto-detect heartbeats without CLI calls.
745
+ // Usage: tasks register-session <trackerId> <agentLabel> <sessionKey>
746
+ case 'register-session': {
747
+ const { registerAgentSession } = await import('./task-tracker.js');
748
+ const [trackerId, agentLabel, sessionKey] = args;
749
+ if (!trackerId || !agentLabel || !sessionKey) {
750
+ fail('Usage: tasks register-session <trackerId> <agentLabel> <sessionKey>');
751
+ }
752
+ registerAgentSession(trackerId, agentLabel, sessionKey);
753
+ emit({ ok: true, tracker_id: trackerId, agent: agentLabel, session_key: sessionKey }, `Session registered: ${agentLabel} -> ${sessionKey}`);
754
+ break;
755
+ }
756
+
757
+ default: usage();
758
+ }
759
+ break;
760
+
761
+ // -- Approvals ----------------------------------------------
762
+ case 'approvals':
763
+ switch (sub) {
764
+ case 'list':
765
+ case 'pending': {
766
+ const { listPendingApprovals } = await import('./approval.js');
767
+ const approvals = listPendingApprovals();
768
+ if (approvals.length === 0) { emit([], 'No pending approvals'); break; }
769
+ const rows = approvals.map(a => ({
770
+ id: a.id.slice(0, 8),
771
+ job: a.job_id.slice(0, 8),
772
+ job_name: a.job_name || '-',
773
+ run: a.run_id?.slice(0, 8) || '-',
774
+ status: a.status,
775
+ requested: a.requested_at,
776
+ }));
777
+ emit(jsonMode ? approvals : rows, () => console.table(rows));
778
+ break;
779
+ }
780
+ default: usage();
781
+ }
782
+ break;
783
+
784
+ // -- Idempotency ----------------------------------------
785
+ case 'idem': {
786
+ const { listIdempotencyForJob, getIdempotencyEntry, releaseIdempotencyKey, forcePruneIdempotency } = await import('./idempotency.js');
787
+ switch (sub) {
788
+ case 'status': {
789
+ if (!args[0]) fail('Usage: idem status <job-id>');
790
+ const entries = listIdempotencyForJob(args[0]);
791
+ if (entries.length === 0) { emit([], 'No idempotency entries for this job'); break; }
792
+ const rows = entries.map(e => ({
793
+ key: e.key.slice(0, 12) + '..',
794
+ run: (e.run_id?.slice(0, 8) || '-') + '..',
795
+ status: e.status,
796
+ claimed: e.claimed_at,
797
+ released: e.released_at || '-',
798
+ expires: e.expires_at,
799
+ hash: e.result_hash || '-',
800
+ }));
801
+ emit(jsonMode ? entries : rows, () => console.table(rows));
802
+ break;
803
+ }
804
+ case 'check': {
805
+ if (!args[0]) fail('Usage: idem check <key>');
806
+ const entry = getIdempotencyEntry(args[0]);
807
+ if (!entry) { emit({ found: false, key: args[0] }, 'Key not found in ledger'); break; }
808
+ emit(entry);
809
+ break;
810
+ }
811
+ case 'release': {
812
+ if (!args[0]) fail('Usage: idem release <key>');
813
+ const before = getIdempotencyEntry(args[0]);
814
+ if (!before) fail('Key not found in ledger');
815
+ if (before.status === 'released') { emit({ ok: true, key: args[0], already_released: true }, 'Key already released'); break; }
816
+ releaseIdempotencyKey(args[0]);
817
+ emit({ ok: true, key: args[0], released: true }, `Released idempotency key: ${args[0].slice(0, 12)}..`);
818
+ break;
819
+ }
820
+ case 'prune': {
821
+ const result = forcePruneIdempotency();
822
+ emit({ ok: true, pruned: result }, `Pruned ${result} expired idempotency entries`);
823
+ break;
824
+ }
825
+ default: usage();
826
+ }
827
+ break;
828
+ }
829
+
830
+ // -- Team Adapter ---------------------------------------
831
+ case 'team': {
832
+ const {
833
+ mapTeamMessages, listTeamTasks, listTeamMailboxEvents,
834
+ createTeamTaskGate, checkTeamTaskGates, ackTeamMessage,
835
+ } = await import('./team-adapter.js');
836
+
837
+ switch (sub) {
838
+ case 'map': {
839
+ const mapped = mapTeamMessages(parseInt(args[0] || '200', 10));
840
+ emit({ ok: true, mapped }, `Mapped ${mapped} team message(s)`);
841
+ break;
842
+ }
843
+ case 'tasks': {
844
+ if (!args[0]) fail('Usage: team tasks <team-id> [limit]');
845
+ const tasks = listTeamTasks(args[0], parseInt(args[1] || '50', 10));
846
+ if (tasks.length === 0) { emit([], 'No team tasks'); break; }
847
+ const rows = tasks.map(t => ({
848
+ team: t.team_id,
849
+ task: t.id,
850
+ member: t.member_id || '-',
851
+ status: t.status,
852
+ gateTracker: t.gate_tracker_id ? t.gate_tracker_id.slice(0, 8) + '..' : '-',
853
+ gateStatus: t.gate_status || '-',
854
+ updated: t.updated_at,
855
+ completed: t.completed_at || '-',
856
+ }));
857
+ emit(jsonMode ? tasks : rows, () => console.table(rows));
858
+ break;
859
+ }
860
+ case 'events': {
861
+ if (!args[0]) fail('Usage: team events <team-id> [limit] [task-id]');
862
+ const teamId = args[0];
863
+ const limit = parseInt(args[1] || '50', 10);
864
+ const taskId = args[2] || null;
865
+ const events = listTeamMailboxEvents(teamId, { limit, taskId });
866
+ if (events.length === 0) { emit([], 'No team events'); break; }
867
+ const rows = events.map(e => ({
868
+ id: e.id.slice(0, 8),
869
+ team: e.team_id,
870
+ member: e.member_id || '-',
871
+ task: e.task_id || '-',
872
+ message: e.message_id ? e.message_id.slice(0, 8) : '-',
873
+ type: e.event_type,
874
+ at: e.created_at,
875
+ }));
876
+ emit(jsonMode ? events : rows, () => console.table(rows));
877
+ break;
878
+ }
879
+ case 'gate': {
880
+ if (!args[0] || !args[1] || !args[2]) {
881
+ fail('Usage: team gate <team-id> <task-id> <members-json> [timeout-s]\nExample: team gate core-team deploy-001 "[\\"writer\\",\\"reviewer\\"]" 900');
882
+ }
883
+ const teamId = args[0];
884
+ const taskId = args[1];
885
+ let members;
886
+ try { members = JSON.parse(args[2]); } catch { fail('Invalid JSON for members list. Example: \'["writer","reviewer"]\''); }
887
+ const timeoutS = parseInt(args[3] || '600', 10);
888
+ const gate = createTeamTaskGate({ teamId, taskId, expectedMembers: members, timeoutS });
889
+ emit({ ok: true, gate }, `Gate opened: ${fmt(gate)}`);
890
+ break;
891
+ }
892
+ case 'check-gates': {
893
+ const result = checkTeamTaskGates(parseInt(args[0] || '100', 10));
894
+ emit(result);
895
+ break;
896
+ }
897
+ case 'ack': {
898
+ if (!args[0]) fail('Usage: team ack <message-id> [actor] [note]');
899
+ const actor = args[1] || 'team-member';
900
+ const detail = args.slice(2).join(' ') || null;
901
+ const msg = ackTeamMessage(args[0], actor, detail);
902
+ if (!msg) fail('Team message not found: ' + args[0]);
903
+ emit(
904
+ { ok: true, id: msg.id, status: msg.status, ack_at: msg.ack_at },
905
+ `Team ACK: ${fmt({ id: msg.id, status: msg.status, ack_at: msg.ack_at })}`
906
+ );
907
+ break;
908
+ }
909
+ default: usage();
910
+ }
911
+ break;
912
+ }
913
+
914
+ // -- Aliases ---------------------------------------------
915
+ case 'alias': {
916
+ const db = getDb();
917
+ switch (sub) {
918
+ case 'list': {
919
+ const aliases = db.prepare('SELECT alias, channel, target, description, created_at FROM delivery_aliases ORDER BY alias').all();
920
+ if (aliases.length === 0) { emit([], 'No aliases defined'); break; }
921
+ const rows = aliases.map(a => ({
922
+ alias: a.alias,
923
+ channel: a.channel,
924
+ target: a.target,
925
+ description: a.description || '',
926
+ }));
927
+ emit(jsonMode ? aliases : rows, () => console.table(rows));
928
+ break;
929
+ }
930
+ case 'add': {
931
+ const [name, channel, target, ...descParts] = args;
932
+ if (!name || !channel || !target) {
933
+ fail('Usage: alias add <name> <channel> <target> [description]');
934
+ }
935
+ const description = descParts.length > 0 ? descParts.join(' ') : null;
936
+ db.prepare('INSERT OR REPLACE INTO delivery_aliases (alias, channel, target, description) VALUES (?, ?, ?, ?)')
937
+ .run(name, channel, target, description);
938
+ emit({ ok: true, alias: name, channel, target, description }, `Added alias: ${name} -> ${channel}/${target}`);
939
+ break;
940
+ }
941
+ case 'remove': {
942
+ if (!args[0]) fail('Usage: alias remove <name>');
943
+ const result = db.prepare('DELETE FROM delivery_aliases WHERE alias = ?').run(args[0]);
944
+ if (result.changes > 0) emit({ ok: true, alias: args[0], removed: true }, `Removed alias: ${args[0]}`);
945
+ else fail(`Alias not found: ${args[0]}`);
946
+ break;
947
+ }
948
+ default: usage();
949
+ }
950
+ break;
951
+ }
952
+
953
+ // -- Status ----------------------------------------------
954
+ case 'status': {
955
+ const db = getDb();
956
+ const jobs = listJobs();
957
+ const runningRuns = getRunningRuns();
958
+ const stale = getStaleRuns();
959
+ const agents = listAgents();
960
+ const budget = db.prepare(`
961
+ SELECT
962
+ (SELECT COUNT(*) FROM job_dispatch_queue WHERE status = 'pending') AS pending_dispatches,
963
+ (SELECT COUNT(*) FROM job_dispatch_queue WHERE status = 'awaiting_approval') AS approval_blocked_dispatches,
964
+ (SELECT COALESCE(SUM(queued_count), 0) FROM jobs) AS overlap_queued_dispatches,
965
+ (SELECT COUNT(*) FROM approvals WHERE status = 'pending') AS pending_approvals,
966
+ (SELECT COALESCE(SUM(shell_stdout_bytes + shell_stderr_bytes), 0) FROM runs) AS shell_output_bytes,
967
+ (SELECT COUNT(*) FROM runs WHERE shell_stdout_path IS NOT NULL OR shell_stderr_path IS NOT NULL) AS offloaded_shell_runs
968
+ `).get();
969
+ const hotJobs = db.prepare(`
970
+ SELECT
971
+ j.id, j.name, j.queued_count, j.max_queued_dispatches, j.max_pending_approvals,
972
+ (SELECT COUNT(*) FROM approvals a WHERE a.job_id = j.id AND a.status = 'pending') AS pending_approval_count,
973
+ (SELECT COUNT(*) FROM job_dispatch_queue q WHERE q.job_id = j.id AND q.status IN ('pending', 'claimed', 'awaiting_approval')) AS dispatch_backlog
974
+ FROM jobs j
975
+ WHERE
976
+ j.queued_count >= j.max_queued_dispatches
977
+ OR (SELECT COUNT(*) FROM approvals a WHERE a.job_id = j.id AND a.status = 'pending') >= j.max_pending_approvals
978
+ ORDER BY j.name
979
+ LIMIT 10
980
+ `).all();
981
+ const nextJob = jobs
982
+ .filter(j => j.enabled && j.next_run_at)
983
+ .sort((a, b) => a.next_run_at.localeCompare(b.next_run_at))[0] || null;
984
+ const payload = {
985
+ jobs_total: jobs.length,
986
+ jobs_enabled: jobs.filter(j => j.enabled).length,
987
+ running_runs: runningRuns.length,
988
+ stale_runs: stale.length,
989
+ budgets: budget,
990
+ budget_hotspots: hotJobs,
991
+ agents: agents.map(a => ({
992
+ id: a.id,
993
+ status: a.status,
994
+ unread: getUnreadCount(a.id),
995
+ })),
996
+ next_job: nextJob ? { id: nextJob.id, name: nextJob.name, next_run_at: nextJob.next_run_at } : null,
997
+ };
998
+ emit(payload, () => {
999
+ console.log('=== OpenClaw Scheduler Status ===');
1000
+ console.log(`Jobs: ${jobs.length} total, ${jobs.filter(j => j.enabled).length} enabled`);
1001
+ console.log(`Running: ${runningRuns.length}`);
1002
+ console.log(`Stale: ${stale.length}`);
1003
+ console.log(`Dispatch: ${budget.pending_dispatches} pending, ${budget.approval_blocked_dispatches} approval-blocked, ${budget.overlap_queued_dispatches} overlap-queued`);
1004
+ console.log(`Approvals:${budget.pending_approvals} pending`);
1005
+ console.log(`Output: ${budget.shell_output_bytes} bytes stored/offloaded across runs (${budget.offloaded_shell_runs} offloaded runs)`);
1006
+ console.log(`Agents: ${agents.length}`);
1007
+ for (const a of agents) {
1008
+ const unread = getUnreadCount(a.id);
1009
+ console.log(` ${a.id}: ${a.status}${unread ? ` (${unread} unread)` : ''}`);
1010
+ }
1011
+ if (hotJobs.length > 0) {
1012
+ console.log('\nBudget hotspots:');
1013
+ console.table(hotJobs.map(job => ({
1014
+ name: job.name,
1015
+ dispatchBacklog: job.dispatch_backlog,
1016
+ queuedCount: job.queued_count,
1017
+ maxQueued: job.max_queued_dispatches,
1018
+ pendingApprovals: job.pending_approval_count,
1019
+ maxApprovals: job.max_pending_approvals,
1020
+ })));
1021
+ }
1022
+ if (nextJob) console.log(`\nNext: ${nextJob.name} at ${nextJob.next_run_at}`);
1023
+ });
1024
+ break;
1025
+ }
1026
+
1027
+ case 'schema': {
1028
+ const key = (sub || 'all').toLowerCase();
1029
+ if (key === 'all') {
1030
+ emit(SCHEDULER_SCHEMAS);
1031
+ break;
1032
+ }
1033
+ const singularMap = {
1034
+ job: 'jobs',
1035
+ run: 'runs',
1036
+ message: 'messages',
1037
+ approval: 'approvals',
1038
+ dispatch: 'dispatches',
1039
+ };
1040
+ const resolved = singularMap[key] || key;
1041
+ if (!SCHEDULER_SCHEMAS[resolved]) fail(`Unknown schema target: ${sub}`);
1042
+ emit(SCHEDULER_SCHEMAS[resolved]);
1043
+ break;
1044
+ }
1045
+
1046
+ // -- Capabilities ----------------------------------------
1047
+ case 'capabilities': {
1048
+ const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
1049
+ const schemaVersion = getDb().prepare('SELECT MAX(version) AS v FROM schema_migrations').get()?.v ?? null;
1050
+ const capabilities = {
1051
+ scheduler_version: pkg.version,
1052
+ schema_version: schemaVersion,
1053
+ handoff_version: '2',
1054
+ features: {
1055
+ approvals: 'runtime',
1056
+ model_policy: 'model+thinking',
1057
+ execution_intent: 'runtime',
1058
+ output_hints: 'runtime',
1059
+ timeout_support: 'runtime',
1060
+ context_retrieval: 'runtime',
1061
+ runtime_execution: true,
1062
+ identity_declaration: true,
1063
+ runtime_identity_resolution: true,
1064
+ trust_evaluation: true,
1065
+ authorization_proof_verification: true,
1066
+ authorization_hook: true,
1067
+ evidence_generation: true,
1068
+ delegation_validation: false,
1069
+ credential_handoff: true,
1070
+ audit_export: true,
1071
+ },
1072
+ };
1073
+ emit(capabilities);
1074
+ break;
1075
+ }
1076
+
1077
+ default:
1078
+ if (command) {
1079
+ fail(`Unknown command: ${command}. Run without arguments for usage.`);
1080
+ }
1081
+ usage();
1082
+ process.exit(0);
1083
+ }