openclaw-scheduler 0.2.0 → 0.2.2

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.
package/AGENTS.md CHANGED
@@ -27,6 +27,27 @@ For manifest authoring, validation, and identity/authorization profiles, use
27
27
  - Shell jobs (`session_target: "shell"`) run without the gateway. Agent jobs
28
28
  (`session_target: "isolated"` or `"main"`) require a running gateway.
29
29
 
30
+ ## Checking Job Status — Always Poll, Never Infer
31
+
32
+ When reporting on whether a dispatched job is running, done, or stuck, **always call the status command directly** — never infer from check-in messages or notifications that appeared in your conversation.
33
+
34
+ Check-in messages are delivered asynchronously. By the time they appear, the job may already be finished, failed, or on a later step. Conversation messages are stale by definition.
35
+
36
+ ```bash
37
+ # For chilisaus-dispatched jobs:
38
+ node ~/.openclaw/chilisaus/index.mjs status --label <label>
39
+
40
+ # For scheduler jobs:
41
+ openclaw-scheduler runs list <job-id> --json
42
+ openclaw-scheduler runs running --json
43
+ ```
44
+
45
+ The `status` output gives authoritative `status` (`accepted` / `running` / `done` / `error`), `updatedAt` timestamp, and final `summary`. Use that.
46
+
47
+ **Rule: if you haven't polled status, you don't know the status.**
48
+
49
+ ---
50
+
30
51
  ## Error Handling
31
52
 
32
53
  CLI errors exit non-zero. In plain-text mode, the message goes to stderr. With
package/BEST-PRACTICES.md CHANGED
@@ -134,7 +134,7 @@ Use `isolated` when:
134
134
 
135
135
  | Rule | Bad | Good |
136
136
  |------|-----|------|
137
- | Be imperative and specific | "check kubernetes" | "Check k8s pods in requesthub-prod and requesthub-dev. List any non-Running pods." |
137
+ | Be imperative and specific | "check kubernetes" | "Check k8s pods in myapp-prod and myapp-staging. List any non-Running pods." |
138
138
  | Include a success signal | *(nothing)* | "If all pods Running, reply with exactly: HEARTBEAT_OK" |
139
139
  | Specify output format | *(nothing)* | "Format issues as: ⚠️ \<namespace\>/\<pod\>: \<status\>" |
140
140
  | State available resources | *(implicit)* | "Your memory files are in ~/.openclaw/workspace/memory/" |
@@ -147,7 +147,7 @@ Check everything and let me know if anything is wrong
147
147
 
148
148
  **Good prompt:**
149
149
  ```
150
- Check k8s pod health across requesthub-prod and requesthub-dev namespaces.
150
+ Check k8s pod health across myapp-prod and myapp-staging namespaces.
151
151
  List any non-Running pods. If all pods are Running, reply with exactly: HEARTBEAT_OK
152
152
  Format any issues as: ⚠️ <namespace>/<pod>: <status>
153
153
  ```
@@ -282,7 +282,7 @@ When the dispatcher fires an isolated job, the agent receives a message structur
282
282
  From: scheduler | result | Previous backup: 3 files committed, pushed to origin
283
283
  ---
284
284
 
285
- Check k8s pod health across requesthub-prod and requesthub-dev.
285
+ Check k8s pod health across myapp-prod and myapp-staging.
286
286
  If all pods are Running, reply with exactly: HEARTBEAT_OK
287
287
  Format any issues as: ⚠️ <namespace>/<pod>: <status>
288
288
  ```
@@ -360,6 +360,23 @@ The dispatcher picks this up on its next tick (within 15s) and creates and immed
360
360
 
361
361
  ---
362
362
 
363
+ ### Checking Job Status — Always Poll, Never Infer
364
+
365
+ When reporting on whether a dispatched job is running, done, or stuck, **always call `status` directly** — never infer from check-in messages that appeared in the conversation.
366
+
367
+ Check-in messages (from `agent-checkin.mjs` or similar) are delivered asynchronously via the scheduler inbox. By the time they appear in your conversation, the job may have already finished, failed, or moved on to a later step. Treating them as real-time signals produces stale or incorrect status reports.
368
+
369
+ ```bash
370
+ # Always do this before reporting job status:
371
+ node ~/.openclaw/chilisaus/index.mjs status --label <label>
372
+ ```
373
+
374
+ The `status` output gives you the authoritative `status` field (`accepted` / `running` / `done` / `error`), the last `updatedAt` timestamp, and the final `summary`. Use that — not the most recent check-in message.
375
+
376
+ **Rule: if you haven't polled `status`, you don't know the status.**
377
+
378
+ ---
379
+
363
380
  ### Communicating Between Jobs
364
381
 
365
382
  Jobs can pass data to each other using the inter-agent message queue. Messages injected into a job's context appear in the `--- Pending Messages ---` block at the top of the prompt.
package/CHANGELOG.md CHANGED
@@ -2,21 +2,58 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.2.2] -- 2026-04-15
6
+
7
+ ### Fixed
8
+ - fix(dispatch): make completion delivery deterministic
9
+ - fix(dispatch): suppress junk completion fallbacks
10
+ - fix(package): include provider registry in npm tarball
11
+ - fix(scheduler): canonicalize isolated session auth overrides
12
+ - fix(dispatch): delete watchdog jobs on disarm instead of disable to prevent accumulation
13
+
14
+ ### Added
15
+ - feat(watcher): add stop_reason-based early delivery (Path 2a)
16
+ - feat(dispatch): auto-inject ORIGIN_CHAT_ID from deliverTo into prompt
17
+ - fix(dispatch): prefer group sessions over DM in auto-detected origin
18
+
19
+ ### Changed
20
+ - ci: upgrade checkout and setup-node actions to v5
21
+ - docs: align packaged runtime path with host layout
22
+ - docs: document local npm pack install and upgrade flow
23
+ - test: remove dead locals in watcher coverage
24
+
25
+ ## [0.2.1] -- 2026-04-01
6
26
 
7
27
  ### Fixed
8
28
  - fix(watcher): exit cleanly when session status=done (PR #1)
9
29
  - fix(watchdog): prevent auto-resolving active sessions with heartbeat + hard ceiling (PR #2)
10
30
  - fix(gateway): reset idle timer while fetch is in flight (PR #3)
11
31
  - fix(watcher): prevent premature kill of active subagent sessions with JSONL activity signal (PR #7)
32
+ - fix(db): add SQLite busy_timeout (5s) to prevent SQLITE_BUSY on CLI + dispatcher contention
33
+ - fix(approvals): prevent double-dispatch race on auto-approved jobs
34
+ - fix(watcher): cap deadline extension at min(timeout, 4h) to prevent zombie watchers
35
+ - fix(runs): preserve empty string summary/error_message (use ?? instead of ||)
36
+ - fix(runs): guard getTimedOutRuns against NULL run_timeout_ms on legacy rows
37
+ - fix(gateway): use byte length for Telegram message chunking (4096-byte limit)
38
+ - fix(jobs): validate schedule_tz as real IANA timezone via Intl.DateTimeFormat
39
+ - fix(dispatcher): wrap delete_after_run cleanup in transaction
40
+ - fix(dispatch): remove 4000-char truncation in formatMessageForDelivery
41
+ - fix(dispatch): add retry exception path delivery announcement
42
+ - fix(dispatch): fix dispatch CLI subcommand routing in bin wrapper
12
43
 
13
44
  ### Added
14
45
  - feat: v0.2 runtime with identity/trust/authorization/evidence/credential handoff (PR #4)
15
46
  - feat: x-openclaw-env-inject header for agent task credentials (PR #5)
47
+ - feat: [IMAGE:path] marker protocol for shell job image attachments
48
+ - feat: auto-delete watcher and watchdog jobs after completion (delete_after_run)
49
+ - feat: enforce delivery_to as required field on job INSERT
50
+ - feat: multi-platform CI (Linux, macOS, Windows)
16
51
  - docs: trust architecture, multi-agent gateway routing, agent adoption files
52
+ - docs: AGENTS.md, CONTEXT.md, JOB-QUICK-REF.md for agent adoption
17
53
 
18
54
  ### Changed
19
55
  - chore: replace non-ASCII characters with ASCII equivalents (PR #6)
56
+ - chore: bump output_excerpt_limit and output_summary_limit defaults to 64KB
20
57
 
21
58
  ## [0.2.0] -- 2026-03-11
22
59
 
package/INSTALL.md CHANGED
@@ -39,6 +39,21 @@ npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- help
39
39
 
40
40
  Runtime state for npm installs defaults to `~/.openclaw/scheduler/`, not the package directory under `node_modules/`.
41
41
 
42
+ To test the published package layout from local source before publishing, build a tarball and install it into a separate runtime prefix:
43
+
44
+ ```bash
45
+ git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
46
+ cd /tmp/openclaw-scheduler
47
+ npm ci
48
+ npm run verify:local
49
+ npm pack
50
+
51
+ mkdir -p ~/.openclaw/packages/openclaw-scheduler
52
+ npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
53
+ ```
54
+
55
+ In that setup, run the service from `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js` and keep mutable state in `~/.openclaw/scheduler` via `SCHEDULER_HOME` and `SCHEDULER_DB`.
56
+
42
57
  ---
43
58
 
44
59
  ## Step 2: Install Dependencies
@@ -223,6 +238,14 @@ npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- setup --service-mo
223
238
  npm exec --prefix ~/.openclaw/scheduler openclaw-scheduler -- setup --service-mode daemon
224
239
  ```
225
240
 
241
+ If you installed from a locally packed tarball:
242
+
243
+ ```bash
244
+ npm exec --prefix ~/.openclaw/packages/openclaw-scheduler openclaw-scheduler -- setup --service-mode agent
245
+ # or:
246
+ npm exec --prefix ~/.openclaw/packages/openclaw-scheduler openclaw-scheduler -- setup --service-mode daemon
247
+ ```
248
+
226
249
  What each mode does:
227
250
 
228
251
  - `agent` writes `~/Library/LaunchAgents/ai.openclaw.scheduler.plist` and bootstraps `gui/$UID/ai.openclaw.scheduler`
package/README.md CHANGED
@@ -206,6 +206,23 @@ npm run verify:smoke # lightweight smoke gate used by GitHub Act
206
206
 
207
207
  GitHub Actions runs the smoke gate plus the in-memory test suite on Linux, macOS, and Windows with Node 20. The full release gate still runs locally via `npm run verify:local` and is enforced again by `prepublishOnly`.
208
208
 
209
+ ### Option C: local npm pack (simulate the published package from source)
210
+
211
+ Use this when you want to test the exact package layout end users get from npm without publishing to npmjs.org yet.
212
+
213
+ ```bash
214
+ git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
215
+ cd /tmp/openclaw-scheduler
216
+ npm ci
217
+ npm run verify:local
218
+ npm pack
219
+
220
+ mkdir -p ~/.openclaw/packages/openclaw-scheduler
221
+ npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
222
+ ```
223
+
224
+ Point your service at `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js`, and keep mutable state in `~/.openclaw/scheduler` via `SCHEDULER_HOME` and `SCHEDULER_DB`.
225
+
209
226
  The package also exports a small safe programmatic API surface for tooling:
210
227
 
211
228
  ```js
@@ -1140,18 +1157,13 @@ Use this when you want scripts to enqueue only actionable signals, then a single
1140
1157
  # 1) Enqueue a signal
1141
1158
  openclaw-scheduler msg send monitor-agent main "Found 3 critical errors in prod logs"
1142
1159
 
1143
- # 2) Add a consumer shell job (every 5 minutes)
1144
- openclaw-scheduler jobs add '{
1145
- "name": "Inbox Consumer",
1146
- "schedule_cron": "*/5 * * * *",
1147
- "session_target": "shell",
1148
- "payload_message": "npm exec --prefix ~/.openclaw/scheduler openclaw-inbox-consumer -- --to YOUR_CHAT_ID",
1149
- "delivery_mode": "announce",
1150
- "delivery_channel": "telegram",
1151
- "delivery_to": "YOUR_CHAT_ID",
1152
- "run_timeout_ms": 60000,
1153
- "origin": "system"
1154
- }'
1160
+ # 2) Run the inbox consumer daemon (event-driven, watches SQLite WAL)
1161
+ # Delivers within ~250ms of a message landing in the DB.
1162
+ # No polling cron needed — the daemon watches for WAL changes.
1163
+ node scripts/inbox-consumer.mjs --watch --to YOUR_CHAT_ID --channel telegram
1164
+
1165
+ # Or as a one-shot drain (useful for testing):
1166
+ node scripts/inbox-consumer.mjs --to YOUR_CHAT_ID --channel telegram
1155
1167
  ```
1156
1168
 
1157
1169
  ---
package/UPGRADING.md CHANGED
@@ -105,6 +105,24 @@ npm install --prefix ~/.openclaw/scheduler openclaw-scheduler@latest
105
105
  npm install --prefix $env:USERPROFILE\.openclaw\scheduler openclaw-scheduler@latest
106
106
  ```
107
107
 
108
+ ### Local source tarball upgrade (`npm pack`)
109
+
110
+ Use this when you want to upgrade a host to a locally built package before publishing to npmjs.org.
111
+
112
+ ```bash
113
+ git clone https://github.com/amittell/openclaw-scheduler /tmp/openclaw-scheduler
114
+ cd /tmp/openclaw-scheduler
115
+ git pull
116
+ npm ci
117
+ npm run verify:local
118
+ npm pack
119
+
120
+ mkdir -p ~/.openclaw/packages/openclaw-scheduler
121
+ npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ./openclaw-scheduler-*.tgz
122
+ ```
123
+
124
+ Keep `SCHEDULER_HOME=~/.openclaw/scheduler` and `SCHEDULER_DB=~/.openclaw/scheduler/scheduler.db`, and point the service at `~/.openclaw/packages/openclaw-scheduler/node_modules/openclaw-scheduler/dispatcher.js`.
125
+
108
126
  ---
109
127
 
110
128
  ## Step 2: Install Dependencies
@@ -277,6 +295,19 @@ sleep 3
277
295
  ssh $HOST "tail -5 /tmp/openclaw-scheduler.log && cd ~/.openclaw/scheduler && node cli.js status"
278
296
  ```
279
297
 
298
+ #### macOS host over SSH using a local tarball
299
+
300
+ ```bash
301
+ HOST=youruser@your-mac-host.lan
302
+ TARBALL=./openclaw-scheduler-*.tgz
303
+
304
+ scp $TARBALL $HOST:~/.openclaw/
305
+ ssh $HOST "mkdir -p ~/.openclaw/packages/openclaw-scheduler && npm install --prefix ~/.openclaw/packages/openclaw-scheduler --omit=dev --no-package-lock ~/.openclaw/$(basename $TARBALL)"
306
+ ssh $HOST "launchctl kickstart -k gui/\$(id -u)/ai.openclaw.scheduler"
307
+ sleep 3
308
+ ssh $HOST "tail -5 /tmp/openclaw-scheduler.log && launchctl print gui/\$(id -u)/ai.openclaw.scheduler | sed -n '1,20p'"
309
+ ```
310
+
280
311
  #### Linux / Windows WSL2 host over SSH
281
312
 
282
313
  ```bash
@@ -32,6 +32,7 @@ import { randomUUID } from 'crypto';
32
32
  import { execFileSync } from 'child_process';
33
33
  import { homedir } from 'os';
34
34
  import Database from 'better-sqlite3';
35
+ import { buildTerminalCompletionPayload } from './completion.mjs';
35
36
  import { onStarted, onFinished, onStuck } from './hooks.mjs';
36
37
 
37
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -334,14 +335,28 @@ function readSessionsStore(agent = 'main') {
334
335
  *
335
336
  * @returns {string|null} - e.g. "telegram:-100200000000", or null if not found
336
337
  */
338
+ /**
339
+ * Infer the chat type ("group" | "direct" | "") from a session object and its key.
340
+ * Checks session.chatType first, then falls back to key pattern matching.
341
+ * Key patterns: agent:main:<channel>:group:<id> → group
342
+ * agent:main:<channel>:direct:<id> → direct
343
+ */
344
+ function inferChatType(key, session) {
345
+ if (session.chatType) return session.chatType;
346
+ if (key.includes(":group:")) return "group";
347
+ if (key.includes(":direct:")) return "direct";
348
+ return "";
349
+ }
350
+
337
351
  function getActiveOriginFromSessions() {
338
352
  const store = readSessionsStore("main");
339
353
  if (!store) return null;
340
354
 
341
- let best = null;
342
- let bestTime = 0;
343
355
  const TEN_MIN_MS = 10 * 60 * 1000;
344
356
 
357
+ /** @type {Array<{origin: string, updatedAt: number, chatType: string}>} */
358
+ const candidates = [];
359
+
345
360
  for (const [key, session] of Object.entries(store)) {
346
361
  // Only consider main sessions, not subagents
347
362
  // Pattern: agent:main:<channel>:<type>:<id> but NOT agent:main:subagent:*
@@ -357,19 +372,37 @@ function getActiveOriginFromSessions() {
357
372
  // Must be recently active
358
373
  if (Date.now() - updatedAt > TEN_MIN_MS) continue;
359
374
 
360
- if (updatedAt > bestTime) {
361
- // Prefer deliveryContext.to if available
362
- const deliveryTo = session.deliveryContext?.to || null;
363
- if (deliveryTo) {
364
- bestTime = updatedAt;
365
- // deliveryContext.to format: "telegram:-100200000000"
366
- // Convert to origin format: "telegram:-100200000000"
367
- best = deliveryTo;
368
- }
369
- }
375
+ // Prefer deliveryContext.to if available
376
+ const deliveryTo = session.deliveryContext?.to || null;
377
+ if (!deliveryTo) continue;
378
+
379
+ candidates.push({
380
+ origin: deliveryTo,
381
+ updatedAt,
382
+ chatType: inferChatType(key, session),
383
+ });
370
384
  }
371
385
 
372
- return best;
386
+ if (candidates.length === 0) return null;
387
+
388
+ // Tiebreaker: prefer group sessions over direct/DM sessions.
389
+ // When both a DM and a group session are recently active, the DM session
390
+ // often has a more recent updatedAt (agent just replied there), but the
391
+ // triggering context was the group chat. Within the same chat type, prefer
392
+ // the most recently updated session.
393
+ const typeScore = (chatType) => {
394
+ if (chatType === "group") return 2;
395
+ if (chatType === "direct") return 0;
396
+ return 1; // unknown / other
397
+ };
398
+
399
+ candidates.sort((a, b) => {
400
+ const scoreDiff = typeScore(b.chatType) - typeScore(a.chatType);
401
+ if (scoreDiff !== 0) return scoreDiff;
402
+ return b.updatedAt - a.updatedAt;
403
+ });
404
+
405
+ return candidates[0].origin;
373
406
  }
374
407
 
375
408
  /**
@@ -507,12 +540,12 @@ function disarmWatchdog(label) {
507
540
  if (!entry?.watchdogJobId) return;
508
541
  try {
509
542
  const schedulerCli = join(__dirname, '..', 'cli.js');
510
- execFileSync(process.execPath, [schedulerCli, 'jobs', 'disable', entry.watchdogJobId], {
543
+ execFileSync(process.execPath, [schedulerCli, 'jobs', 'delete', entry.watchdogJobId], {
511
544
  encoding: 'utf-8',
512
545
  timeout: 5000,
513
546
  stdio: ['pipe', 'pipe', 'pipe'],
514
547
  });
515
- process.stderr.write(`[${BRAND}] watchdog disarmed for ${label}\n`);
548
+ process.stderr.write(`[${BRAND}] watchdog deleted for ${label}\n`);
516
549
  } catch (err) {
517
550
  process.stderr.write(`[${BRAND}] watchdog disarm failed for ${label}: ${err.message}\n`);
518
551
  }
@@ -599,6 +632,14 @@ async function cmdEnqueue(flags) {
599
632
  const deliverMode = flags['delivery-mode'] || 'announce';
600
633
  const mode = flags.mode || 'fresh';
601
634
 
635
+ // -- Auto-inject ORIGIN_CHAT_ID into prompt message ---------
636
+ // Ensures the spawned agent always knows where to send message tool calls,
637
+ // matching the delivery target. Skip if already present (caller is explicit)
638
+ // or if there's no delivery target.
639
+ if (deliverTo && !message.includes('ORIGIN_CHAT_ID:')) {
640
+ message = `ORIGIN_CHAT_ID: ${deliverTo}\n\n${message}`;
641
+ }
642
+
602
643
  // -- Verify command flag -----------------------------------
603
644
  const verifyCmd = flags['verify-cmd'] || null;
604
645
 
@@ -849,7 +890,8 @@ async function cmdEnqueue(flags) {
849
890
  const watcherPath = join(__dirname, 'watcher.mjs');
850
891
  // Watcher timeout = session timeout + 120s buffer for startup/polling
851
892
  const watcherTimeoutS = timeoutS + 120;
852
- const watcherCmd = `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' '${sq(process.execPath)}' '${sq(watcherPath)}' --label '${sq(label)}' --timeout ${watcherTimeoutS} --poll-interval 20`;
893
+ const idleThresholdS = flags['idle-threshold'] || '300';
894
+ const watcherCmd = `DISPATCH_LABELS_PATH='${sq(LABELS_PATH)}' '${sq(process.execPath)}' '${sq(watcherPath)}' --label '${sq(label)}' --timeout ${watcherTimeoutS} --poll-interval 20 --idle-threshold ${idleThresholdS}`;
853
895
 
854
896
  const nowUtc = new Date().toISOString().replace('T', ' ').slice(0, 19);
855
897
  const jobSpec = JSON.stringify({
@@ -1116,6 +1158,7 @@ function cmdStatus(flags) {
1116
1158
  spawnedAt: current.spawnedAt,
1117
1159
  updatedAt: current.updatedAt,
1118
1160
  summary: current.summary || null,
1161
+ completion: current.completion || null,
1119
1162
  error: current.error || null,
1120
1163
  liveness,
1121
1164
  ...(syncAction ? { syncAction } : {}),
@@ -1405,6 +1448,7 @@ function cmdResult(flags) {
1405
1448
  status: entry.status,
1406
1449
  spawnedAt: entry.spawnedAt,
1407
1450
  summary: entry.summary || (lastReply ? lastReply.slice(0, 500) : null),
1451
+ completion: entry.completion || null,
1408
1452
  lastReply: lastReply || null,
1409
1453
  error: entry.error || null,
1410
1454
  });
@@ -1474,15 +1518,15 @@ async function cmdDone(flags) {
1474
1518
  }
1475
1519
  }
1476
1520
 
1477
- // Bug 1 fix: truncate summary to 300 chars (delivery path silently truncates at 500)
1478
- const MAX_SUMMARY = 300;
1479
- let summary = rawSummary;
1480
- if (rawSummary.length > MAX_SUMMARY) {
1481
- process.stderr.write(
1482
- `[${BRAND}] warn: --summary truncated from ${rawSummary.length} chars to ${MAX_SUMMARY} chars\n`,
1483
- );
1484
- summary = rawSummary.slice(0, MAX_SUMMARY);
1485
- }
1521
+ // Summary passes through as-is for raw diagnostics, but we also persist a
1522
+ // first-class completion payload with deterministic delivery text so the
1523
+ // watcher/post-office path never depends solely on transcript recovery.
1524
+ const completion = buildTerminalCompletionPayload({
1525
+ summary: rawSummary,
1526
+ checklist,
1527
+ sha,
1528
+ });
1529
+ const summary = completion.summary || rawSummary;
1486
1530
 
1487
1531
  const existing = getLabel(label);
1488
1532
 
@@ -1598,7 +1642,7 @@ async function cmdDone(flags) {
1598
1642
  // Label was never registered (e.g. direct subagent spawn, not via enqueue).
1599
1643
  // This is not an error -- the work completed, the label just wasn't tracked.
1600
1644
  process.stderr.write(`[${BRAND}] warn: no session found for label "${label}" -- registering as done\n`);
1601
- setLabel(label, { status: 'done', summary, ...(sha ? { sha } : {}) });
1645
+ setLabel(label, { status: 'done', summary, completion, ...(sha ? { sha } : {}) });
1602
1646
 
1603
1647
  // No watcher is polling for this label, so actively notify via the gateway
1604
1648
  // post office using delivery config from config.json as fallback target.
@@ -1622,13 +1666,14 @@ async function cmdDone(flags) {
1622
1666
  process.stderr.write(`[${BRAND}] warn: no deliverTo in config -- completion not delivered for "${label}"\n`);
1623
1667
  }
1624
1668
 
1625
- out({ ok: true, label, status: 'done', summary, message: 'Label not previously registered; marked done.' });
1669
+ out({ ok: true, label, status: 'done', summary, completion, message: 'Label not previously registered; marked done.' });
1626
1670
  return;
1627
1671
  }
1628
1672
 
1629
1673
  setLabel(label, {
1630
1674
  status: 'done',
1631
1675
  summary,
1676
+ completion,
1632
1677
  ...(sha ? { sha } : {}),
1633
1678
  });
1634
1679
 
@@ -1647,7 +1692,7 @@ async function cmdDone(flags) {
1647
1692
  session_key: existing.sessionKey || null,
1648
1693
  }).catch(() => {});
1649
1694
 
1650
- out({ ok: true, label, status: 'done', summary, message: 'Label marked done via agent signal.' });
1695
+ out({ ok: true, label, status: 'done', summary, completion, message: 'Label marked done via agent signal.' });
1651
1696
  }
1652
1697
 
1653
1698
  /**