@canonmsg/agent-sdk 13.0.2 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -227,6 +227,8 @@ Every room is encrypted, so the runner needs the agent's MLS device, which it ke
227
227
  | `agentTurnCap` | 6 | Encrypted rooms: how many agent @mentions in a row wake the agent in a room before a person writes there again |
228
228
  | `mentionAliases` | none | Encrypted rooms: other names that @mention the agent, such as its role |
229
229
  | `sessionApprovalForDestructive` | off | Encrypted rooms: whether the owner may allow an action the runtime marks destructive for the rest of the session (CLI `--allow-session-for-destructive`) |
230
+ | `wake` | left as registered | The agent's signed wake callback, registered at each start, or `null` to remove it; see [Waking and sleeping](#waking-and-sleeping) |
231
+ | `idleTimeoutMs`, `onIdle` | off | `onIdle(activity)` once the runner has had nothing to do for this long; see [Waking and sleeping](#waking-and-sleeping) |
230
232
 
231
233
  `baseUrl`, `streamUrl`, `endpoint` and `fetch` are test seams; production never sets them. `codeDriver`'s second argument overrides its capabilities, such as `outboundFiles` and `maxConcurrentTurns` (default 8).
232
234
 
@@ -328,6 +330,14 @@ canon-agent errands --profile engineer --cancel <errand id>
328
330
  - **Replies.** Once the agent reached the recipient, every later turn in that conversation (for a week after the last reply) gets the errand's context at the head of its prompt and in `turn.input.errands`, so the agent knows why it is there. Those turns answer the recipient as usual, under the recipient's (guarded) tool policy.
329
331
  - **Status.** `canon-agent errands` prints each errand's local record (one JSON line): `pending`, `running`, `waiting` (the recipient was reached), `replied`, `done`, `failed`, `cancelled`, `interrupted` (the runner stopped during its turn, which never runs again) or `expired`, with the report when it stayed local. `--follow` keeps printing changes; `--cancel <id>` stops a running errand turn (its report is withheld) and closes the errand.
330
332
 
333
+ ## Waking and sleeping
334
+
335
+ An agent need not run all the time. Canon can wake it, and the runner says when it has nothing to do, so its host can stop or suspend it in between ([agent-wake.md](https://github.com/HeyBobChan/canon/blob/main/docs/agent-wake.md)).
336
+
337
+ - **Wake.** `wake: { callbackUrl, secret, conversationIds }` (CLI `--wake-config <file>`) registers, at each start, a public HTTPS callback for up to 32 rooms. When a person types in one, or a message arrives there, Canon POSTs it a signed [Standard Webhooks](https://www.standardwebhooks.com/) request whose body is only `{"type":"wake","reason":"typing"|"event"}`: no room, sender or content. The host starts or resumes the agent and answers 2xx once it is ready; the agent then reads its input as usual. The secret is `whsec_` and the padded base64 of 32 random bytes (`echo "whsec_$(openssl rand -base64 32)"`). Canon first sends the URL a signed `{"type":"verification","challenge":"…"}`, which must be answered with `{"challenge":"…"}` and wake nothing, and the start fails if it is not. `wake: null` (`--wake-off`) removes the callback; leaving it out leaves it as it is.
338
+ - **Idle.** `runner.activity()` reports what the runner has in hand, as counts: running `turns`, `inputs` waiting for one, `errands` waiting, and `outbound` operations Canon has not answered yet (a reply waiting out an outage counts). It is `idle` when all are 0. With `idleTimeoutMs` (at least 1 s), `onIdle(activity)` is called once the runner has been idle that long, and again only after new activity. The CLI's `--idle-timeout 10m` stops gracefully and exits 0 then.
339
+ - **What a host must add.** Typing reaches the host's callback, not the runner, so a host keeps the agent awake while a person types (Canon sends at most one typing hint per room every 10 s while they do). A wake that arrives just as the runner goes idle can leave its input in the inbox: a host that has received a wake since the runner last reported idle starts it again. Errands written while the agent is stopped run at its next start.
340
+
331
341
  ## Registration
332
342
 
333
343
  `ensureAgentProfile` registers an agent and keeps its key in `$CANON_HOME/agents.json` (default `~/.canon`), as a profile the CLI can use with `--profile`. It reuses an environment-bound profile, resumes the exact saved approval request, persists credentials before acknowledging delivery, and retries an interrupted acknowledgement on the next call. `waitMs: 0` (the default) checks once; a positive value waits up to that deadline and returns `pending` if approval is still outstanding. See the [complete runnable quickstart](https://canonmail.com/agents/build#run-a-complete-agent).
@@ -31,7 +31,7 @@ import {
31
31
  resolveCanonRuntimeConnection,
32
32
  splitTextWithinBytes,
33
33
  verifyCanonRuntimeConnection
34
- } from "./chunk-TYZT346O.js";
34
+ } from "./chunk-ZPBOGOAD.js";
35
35
  import {
36
36
  NodeMlsDevCustodyError,
37
37
  createNodeMlsDevCustody,
@@ -262,9 +262,9 @@ function stdioRpc(child, handlers, { name, envelope }) {
262
262
  }, PIPE_GRACE_MS).unref();
263
263
  });
264
264
  return {
265
- call: /* @__PURE__ */ __name((method, params) => new Promise((resolve3, reject) => {
265
+ call: /* @__PURE__ */ __name((method, params) => new Promise((resolve4, reject) => {
266
266
  next += 1;
267
- pending.set(next, { resolve: /* @__PURE__ */ __name((value) => resolve3(isRecord(value) ? value : {}), "resolve"), reject });
267
+ pending.set(next, { resolve: /* @__PURE__ */ __name((value) => resolve4(isRecord(value) ? value : {}), "resolve"), reject });
268
268
  write({ id: next, method, params });
269
269
  }), "call"),
270
270
  notify: /* @__PURE__ */ __name((method, params) => write({ method, ...params ? { params } : {} }), "notify"),
@@ -341,11 +341,11 @@ async function checkAcp({ command, args = [], cwd, timeoutMs = 3e4 }) {
341
341
  const link = stdioRpc(child, { notify: /* @__PURE__ */ __name(() => void 0, "notify"), request: /* @__PURE__ */ __name((method) => refuseMethod(method), "request") }, { name: basename(command), envelope: { jsonrpc: "2.0" } });
342
342
  let timer;
343
343
  try {
344
- return await new Promise((resolve3, reject) => {
344
+ return await new Promise((resolve4, reject) => {
345
345
  timer = setTimeout(() => reject(new Error(`${command} did not answer the Agent Client Protocol's initialize within ${timeoutMs / 1e3} s`)), timeoutMs);
346
346
  child.once("error", (error) => reject(new Error(`Could not run ${command}: ${error.message}`)));
347
347
  child.once("close", () => reject(new Error(`${command} exited before answering the Agent Client Protocol's initialize`)));
348
- initialize(link, command).then(({ capabilities, info }) => resolve3({
348
+ initialize(link, command).then(({ capabilities, info }) => resolve4({
349
349
  name: text(info.title) || text(info.name) || basename(command),
350
350
  version: text(info.version),
351
351
  sessionMemory: restoreMethod(capabilities) ? "native" : "none"
@@ -401,8 +401,8 @@ function acpDriver(options) {
401
401
  if (guest && options.guestMode === void 0 && !options.unguardedGuests) return { reply: OWNER_ONLY };
402
402
  const child = startAgent(command, args, cwd);
403
403
  let exit = /* @__PURE__ */ __name(() => void 0, "exit");
404
- const exited = new Promise((resolve3) => {
405
- exit = resolve3;
404
+ const exited = new Promise((resolve4) => {
405
+ exit = resolve4;
406
406
  });
407
407
  let killing;
408
408
  let stopping = false;
@@ -413,7 +413,7 @@ function acpDriver(options) {
413
413
  }, "close"), exited };
414
414
  running.set(turn.session, entry);
415
415
  const server = `${CANON}_${randomBytes(4).toString("hex")}`;
416
- return new Promise((resolve3, reject) => {
416
+ return new Promise((resolve4, reject) => {
417
417
  const messages = [];
418
418
  const calls = /* @__PURE__ */ new Map();
419
419
  let sessionId = "";
@@ -439,7 +439,7 @@ function acpDriver(options) {
439
439
  finish();
440
440
  if (turn.signal.aborted) reject(turn.signal.reason);
441
441
  else if (outcome instanceof Error) reject(outcome);
442
- else resolve3(outcome);
442
+ else resolve4(outcome);
443
443
  }, "settle");
444
444
  const stopGuest = /* @__PURE__ */ __name((what) => {
445
445
  link.notify("session/cancel", { sessionId });
@@ -532,10 +532,10 @@ ${text(item.newText)}`);
532
532
  child.once("exit", () => {
533
533
  if (stopping || guest || turn.signal.aborted) {
534
534
  signalAgent(child, "SIGTERM");
535
- ended = new Promise((resolve4) => {
535
+ ended = new Promise((resolve5) => {
536
536
  setTimeout(() => {
537
537
  signalAgent(child, "SIGKILL");
538
- resolve4();
538
+ resolve5();
539
539
  }, KILL_AFTER_MS);
540
540
  });
541
541
  }
@@ -789,8 +789,8 @@ function claudeDriver(options) {
789
789
  const resume = turn.state.get(SESSION2);
790
790
  const native = query({ prompt: turn.prompt, options: queryOptions(turn, resume) });
791
791
  let exit = /* @__PURE__ */ __name(() => void 0, "exit");
792
- const entry = { close: /* @__PURE__ */ __name(() => native.close(), "close"), exited: new Promise((resolve3) => {
793
- exit = resolve3;
792
+ const entry = { close: /* @__PURE__ */ __name(() => native.close(), "close"), exited: new Promise((resolve4) => {
793
+ exit = resolve4;
794
794
  }) };
795
795
  running.set(turn.session, entry);
796
796
  let closing;
@@ -803,7 +803,7 @@ function claudeDriver(options) {
803
803
  }, "interrupt");
804
804
  turn.signal.addEventListener("abort", interrupt, { once: true });
805
805
  const reader = liveReader(turn);
806
- return new Promise((resolve3, reject) => void (async () => {
806
+ return new Promise((resolve4, reject) => void (async () => {
807
807
  let session = resume;
808
808
  let started = false;
809
809
  let finished = false;
@@ -820,7 +820,7 @@ function claudeDriver(options) {
820
820
  closeLater();
821
821
  if (message2.subtype === "success" && !message2.is_error) {
822
822
  const reply = message2.result.trim() ? message2.result : reader.text();
823
- resolve3({ reply: reply.trim() ? reply : null });
823
+ resolve4({ reply: reply.trim() ? reply : null });
824
824
  } else {
825
825
  const detail = message2.subtype === "success" ? message2.result : message2.errors.join("; ");
826
826
  reject(failed(new Error(`Claude Code ended the turn with ${message2.subtype}${detail ? `: ${oneLine2(detail, 300)}` : ""}`)));
@@ -1112,14 +1112,14 @@ function codexDriver(options) {
1112
1112
  const cli = installedCli(codexPath);
1113
1113
  const child = spawn3(cli.file, [...cli.args, "app-server", "--listen", "stdio://"], { cwd, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
1114
1114
  let exit = /* @__PURE__ */ __name(() => void 0, "exit");
1115
- const exited = new Promise((resolve3) => {
1116
- exit = resolve3;
1115
+ const exited = new Promise((resolve4) => {
1116
+ exit = resolve4;
1117
1117
  });
1118
1118
  const entry = { close: /* @__PURE__ */ __name(() => {
1119
1119
  stopCli(child);
1120
1120
  }, "close"), exited };
1121
1121
  running.set(turn.session, entry);
1122
- return new Promise((resolve3, reject) => {
1122
+ return new Promise((resolve4, reject) => {
1123
1123
  const messages = /* @__PURE__ */ new Map();
1124
1124
  const items = /* @__PURE__ */ new Map();
1125
1125
  const completed = /* @__PURE__ */ new Map();
@@ -1145,7 +1145,7 @@ function codexDriver(options) {
1145
1145
  finish();
1146
1146
  if (turn.signal.aborted) reject(turn.signal.reason);
1147
1147
  else if (outcome instanceof Error) reject(outcome);
1148
- else resolve3(outcome);
1148
+ else resolve4(outcome);
1149
1149
  }, "settle");
1150
1150
  const conclude = /* @__PURE__ */ __name(() => {
1151
1151
  const ended = completed.get(nativeTurn);
@@ -1343,8 +1343,8 @@ function connectorDriver() {
1343
1343
  entry.confirmation = confirmation;
1344
1344
  const deadline = options.deadline ?? Date.now() + CONNECTOR_RELAY_ANSWER_WITHIN_MS;
1345
1345
  let timer;
1346
- const late = new Promise((resolve3) => {
1347
- timer = setTimeout(resolve3, Math.max(0, deadline - Date.now()), "late");
1346
+ const late = new Promise((resolve4) => {
1347
+ timer = setTimeout(resolve4, Math.max(0, deadline - Date.now()), "late");
1348
1348
  });
1349
1349
  const confirmed = await Promise.race([confirmation, late]).finally(() => clearTimeout(timer));
1350
1350
  const state = entry.state;
@@ -1383,7 +1383,7 @@ function connectorDriver() {
1383
1383
  ])).digest("hex").slice(0, 40)}`;
1384
1384
  const input = fitInput(inputId, turn);
1385
1385
  if (!input) return Promise.reject(new Error(TOO_LARGE));
1386
- return new Promise((resolve3, reject) => {
1386
+ return new Promise((resolve4, reject) => {
1387
1387
  const entry = {
1388
1388
  ref: { inputId, conversationId: turn.input.conversationId, messageId: messages.at(-1).id, createdAt: messages.at(-1).createdAt },
1389
1389
  session: turn.session,
@@ -1393,9 +1393,9 @@ function connectorDriver() {
1393
1393
  finish: /* @__PURE__ */ __name((result) => {
1394
1394
  turn.signal.removeEventListener("abort", onAbort);
1395
1395
  if (result instanceof Error) reject(result);
1396
- else if (result.outcome === "reply") resolve3({ reply: result.text });
1396
+ else if (result.outcome === "reply") resolve4({ reply: result.text });
1397
1397
  else if (result.outcome === "failed") reject(new Error(FAILED));
1398
- else resolve3({ reply: null });
1398
+ else resolve4({ reply: null });
1399
1399
  }, "finish")
1400
1400
  };
1401
1401
  const onAbort = /* @__PURE__ */ __name(() => withdraw(entry, turn.signal.reason), "onAbort");
@@ -1531,7 +1531,7 @@ var answered = /* @__PURE__ */ __name((kind, record) => record.status !== "sendi
1531
1531
  async function awaitErrand(directory, command, ms) {
1532
1532
  const journal = errandJournal(directory);
1533
1533
  let record;
1534
- for (const deadline = Date.now() + ms; Date.now() < deadline; await new Promise((resolve3) => setTimeout(resolve3, 200))) {
1534
+ for (const deadline = Date.now() + ms; Date.now() < deadline; await new Promise((resolve4) => setTimeout(resolve4, 200))) {
1535
1535
  record = journal.get(command.id);
1536
1536
  if (record && answered(command.kind, record)) break;
1537
1537
  }
@@ -2161,9 +2161,9 @@ function managedMlsDevice(options) {
2161
2161
  }
2162
2162
  __name(device, "device");
2163
2163
  const custody2 = {
2164
- prepareProtected: /* @__PURE__ */ __name((context, ...rest) => device(context, false).then((open4) => open4.prepareProtected(context, ...rest)), "prepareProtected"),
2165
- prepareProtectedKeyPackage: /* @__PURE__ */ __name((context, ...rest) => device(context, false).then((open4) => open4.prepareProtectedKeyPackage(context, ...rest)), "prepareProtectedKeyPackage"),
2166
- prepareProtectedGroup: /* @__PURE__ */ __name((identity, ...rest) => device(identity, false).then((open4) => open4.prepareProtectedGroup(identity, ...rest)), "prepareProtectedGroup")
2164
+ prepareProtected: /* @__PURE__ */ __name((context, ...rest) => device(context, false).then((open5) => open5.prepareProtected(context, ...rest)), "prepareProtected"),
2165
+ prepareProtectedKeyPackage: /* @__PURE__ */ __name((context, ...rest) => device(context, false).then((open5) => open5.prepareProtectedKeyPackage(context, ...rest)), "prepareProtectedKeyPackage"),
2166
+ prepareProtectedGroup: /* @__PURE__ */ __name((identity, ...rest) => device(identity, false).then((open5) => open5.prepareProtectedGroup(identity, ...rest)), "prepareProtectedGroup")
2167
2167
  };
2168
2168
  return {
2169
2169
  configuration,
@@ -2181,7 +2181,7 @@ function managedMlsDevice(options) {
2181
2181
  return false;
2182
2182
  },
2183
2183
  async close() {
2184
- await Promise.allSettled([...devices.values()].map((open4) => open4.then((custody3) => custody3.close())));
2184
+ await Promise.allSettled([...devices.values()].map((open5) => open5.then((custody3) => custody3.close())));
2185
2185
  }
2186
2186
  };
2187
2187
  }
@@ -2685,15 +2685,15 @@ import { createHash as createHash7 } from "node:crypto";
2685
2685
  import { readdirSync as readdirSync3, readFileSync as readFileSync5, rmSync } from "node:fs";
2686
2686
  import { join as join6 } from "node:path";
2687
2687
  var sha2562 = /* @__PURE__ */ __name((value) => createHash7("sha256").update(value).digest("hex"), "sha256");
2688
- var pause = /* @__PURE__ */ __name((ms) => new Promise((resolve3) => {
2689
- setTimeout(resolve3, ms).unref?.();
2688
+ var pause = /* @__PURE__ */ __name((ms) => new Promise((resolve4) => {
2689
+ setTimeout(resolve4, ms).unref?.();
2690
2690
  }), "pause");
2691
- var pauseUnless = /* @__PURE__ */ __name((ms, signal) => new Promise((resolve3) => {
2692
- if (signal.aborted) return resolve3();
2691
+ var pauseUnless = /* @__PURE__ */ __name((ms, signal) => new Promise((resolve4) => {
2692
+ if (signal.aborted) return resolve4();
2693
2693
  const done = /* @__PURE__ */ __name(() => {
2694
2694
  clearTimeout(timer);
2695
2695
  signal.removeEventListener("abort", done);
2696
- resolve3();
2696
+ resolve4();
2697
2697
  }, "done");
2698
2698
  const timer = setTimeout(done, ms);
2699
2699
  timer.unref?.();
@@ -2986,14 +2986,14 @@ function createMlsAsks(options, approvalScope2) {
2986
2986
  }
2987
2987
  }
2988
2988
  if (signal.aborted) throw ended();
2989
- await new Promise((resolve3) => {
2989
+ await new Promise((resolve4) => {
2990
2990
  const timer = setTimeout(done, Math.max(0, Math.min(pollMs, expiresAtMs - Date.now())));
2991
2991
  timer.unref?.();
2992
2992
  function done() {
2993
2993
  clearTimeout(timer);
2994
2994
  signal.removeEventListener("abort", done);
2995
2995
  card.wake = () => void 0;
2996
- resolve3();
2996
+ resolve4();
2997
2997
  }
2998
2998
  __name(done, "done");
2999
2999
  card.wake = done;
@@ -3004,12 +3004,12 @@ function createMlsAsks(options, approvalScope2) {
3004
3004
  __name(wait, "wait");
3005
3005
  function bounded(card, work) {
3006
3006
  const { expiresAtMs, signal } = card.input;
3007
- return new Promise((resolve3, reject) => {
3008
- const stop = /* @__PURE__ */ __name(() => resolve3(void 0), "stop");
3007
+ return new Promise((resolve4, reject) => {
3008
+ const stop = /* @__PURE__ */ __name(() => resolve4(void 0), "stop");
3009
3009
  const timer = setTimeout(stop, Math.max(0, expiresAtMs - Date.now()));
3010
3010
  timer.unref?.();
3011
3011
  signal.addEventListener("abort", stop, { once: true });
3012
- work.then(resolve3, reject).finally(() => {
3012
+ work.then(resolve4, reject).finally(() => {
3013
3013
  clearTimeout(timer);
3014
3014
  signal.removeEventListener("abort", stop);
3015
3015
  });
@@ -3058,11 +3058,11 @@ function createMlsAsks(options, approvalScope2) {
3058
3058
  throw error;
3059
3059
  });
3060
3060
  })();
3061
- return new Promise((resolve3, reject) => {
3061
+ return new Promise((resolve4, reject) => {
3062
3062
  const stop = /* @__PURE__ */ __name(() => reject(turnEnded()), "stop");
3063
3063
  if (signal.aborted) stop();
3064
3064
  else signal.addEventListener("abort", stop, { once: true });
3065
- work.then(resolve3, reject).finally(() => signal.removeEventListener("abort", stop));
3065
+ work.then(resolve4, reject).finally(() => signal.removeEventListener("abort", stop));
3066
3066
  });
3067
3067
  },
3068
3068
  wake,
@@ -3158,9 +3158,9 @@ async function startToolServer() {
3158
3158
  if (!response.headersSent) response.writeHead(500).end();
3159
3159
  });
3160
3160
  });
3161
- await new Promise((resolve3, reject) => {
3161
+ await new Promise((resolve4, reject) => {
3162
3162
  http.once("error", reject);
3163
- http.listen(0, "127.0.0.1", resolve3);
3163
+ http.listen(0, "127.0.0.1", resolve4);
3164
3164
  });
3165
3165
  const { port } = http.address();
3166
3166
  return {
@@ -3171,10 +3171,10 @@ async function startToolServer() {
3171
3171
  turns.delete(token);
3172
3172
  }, "release") };
3173
3173
  },
3174
- close: /* @__PURE__ */ __name(() => new Promise((resolve3) => {
3174
+ close: /* @__PURE__ */ __name(() => new Promise((resolve4) => {
3175
3175
  turns.clear();
3176
3176
  http.closeAllConnections();
3177
- http.close(() => resolve3());
3177
+ http.close(() => resolve4());
3178
3178
  }), "close")
3179
3179
  };
3180
3180
  }
@@ -3183,6 +3183,9 @@ __name(startToolServer, "startToolServer");
3183
3183
  // src/runner/runner.ts
3184
3184
  var STOPS = ["stop", "drop", "new_session", "membership", "audience"];
3185
3185
  var MAX_INPUTS_PER_TURN = 20;
3186
+ var OUTBOUND_UNANSWERED = ["queued", "sending", "deferred", "pending"];
3187
+ var INBOUND_UNSETTLED = ["observed", "deferred", "offered"];
3188
+ var ACTIVITY_COUNT_LIMIT = 1e3;
3186
3189
  var CLAIM_RETRY_MS = 250;
3187
3190
  var CLAIM_PATIENCE_MS = 2e4;
3188
3191
  var HISTORY_WINDOW = 20;
@@ -3248,14 +3251,14 @@ var toolText = /* @__PURE__ */ __name((text3, isError = false) => ({ content: [{
3248
3251
  var turnEnded2 = /* @__PURE__ */ __name(() => new DOMException("This turn has ended", "AbortError"), "turnEnded");
3249
3252
  function within(promise, ms) {
3250
3253
  let timer;
3251
- const expiry = new Promise((resolve3) => {
3252
- timer = setTimeout(resolve3, ms, void 0);
3254
+ const expiry = new Promise((resolve4) => {
3255
+ timer = setTimeout(resolve4, ms, void 0);
3253
3256
  });
3254
3257
  return Promise.race([promise, expiry]).finally(() => clearTimeout(timer));
3255
3258
  }
3256
3259
  __name(within, "within");
3257
- var pause2 = /* @__PURE__ */ __name((ms) => new Promise((resolve3) => {
3258
- setTimeout(resolve3, ms).unref?.();
3260
+ var pause2 = /* @__PURE__ */ __name((ms) => new Promise((resolve4) => {
3261
+ setTimeout(resolve4, ms).unref?.();
3259
3262
  }), "pause");
3260
3263
  function agentRun(turns) {
3261
3264
  const history = turns.map(({ senderId, senderType }) => senderType === "ai_agent" ? { senderId, senderType, metadata: { turnSemantics: "turn_complete" } } : { senderId, senderType: "human" });
@@ -3432,6 +3435,11 @@ async function createRunner(options) {
3432
3435
  const maxTurns = driver.capabilities.maxConcurrentTurns ?? 8;
3433
3436
  const agentTurnCap = options.agentTurnCap ?? 6;
3434
3437
  if (!Number.isSafeInteger(agentTurnCap) || agentTurnCap < 0 || agentTurnCap > 1e3) throw new Error("agentTurnCap must be a whole number from 0 to 1000");
3438
+ const { idleTimeoutMs, onIdle } = options;
3439
+ if (idleTimeoutMs === void 0 !== (onIdle === void 0)) throw new Error("idleTimeoutMs and onIdle go together");
3440
+ if (idleTimeoutMs !== void 0 && (!Number.isSafeInteger(idleTimeoutMs) || idleTimeoutMs < 1e3)) {
3441
+ throw new Error("idleTimeoutMs must be a whole number of milliseconds, at least 1000");
3442
+ }
3435
3443
  const names = /* @__PURE__ */ __name((list, what) => {
3436
3444
  if (list !== void 0 && (!Array.isArray(list) || list.some((name) => typeof name !== "string" || !name.trim() || name.length > 200))) {
3437
3445
  throw new Error(`${what} must be a list of non-empty names`);
@@ -3484,13 +3492,90 @@ async function createRunner(options) {
3484
3492
  const halt = new AbortController();
3485
3493
  const untilHalt = /* @__PURE__ */ __name((work) => {
3486
3494
  let stop = /* @__PURE__ */ __name(() => void 0, "stop");
3487
- const halted = new Promise((resolve3) => {
3488
- stop = /* @__PURE__ */ __name(() => resolve3("pending"), "stop");
3495
+ const halted = new Promise((resolve4) => {
3496
+ stop = /* @__PURE__ */ __name(() => resolve4("pending"), "stop");
3489
3497
  if (halt.signal.aborted) stop();
3490
3498
  else halt.signal.addEventListener("abort", stop, { once: true });
3491
3499
  });
3492
3500
  return Promise.race([work, halted]).finally(() => halt.signal.removeEventListener("abort", stop));
3493
3501
  }, "untilHalt");
3502
+ let lastActiveAt = Date.now();
3503
+ let idleCalled = false;
3504
+ let offering = 0;
3505
+ let calls = 0;
3506
+ const touch = /* @__PURE__ */ __name(() => {
3507
+ lastActiveAt = Date.now();
3508
+ idleCalled = false;
3509
+ }, "touch");
3510
+ async function activeCall(work) {
3511
+ calls += 1;
3512
+ touch();
3513
+ try {
3514
+ return await work();
3515
+ } finally {
3516
+ calls -= 1;
3517
+ touch();
3518
+ }
3519
+ }
3520
+ __name(activeCall, "activeCall");
3521
+ function ownActivity() {
3522
+ let turns = running.size;
3523
+ let queued2 = offering;
3524
+ for (const current of rooms.values()) {
3525
+ queued2 += current.queue.length;
3526
+ if (current.recovering) turns += 1;
3527
+ if (current.resetting) turns += 1;
3528
+ }
3529
+ let errandsWaiting = 0;
3530
+ for (const record of errandsById.values()) if (record.status === "pending") errandsWaiting += 1;
3531
+ return { turns, queued: queued2, errands: errandsWaiting, sending: posting.size + calls };
3532
+ }
3533
+ __name(ownActivity, "ownActivity");
3534
+ async function activity() {
3535
+ if (stopping) throw new Error("The runner is stopping");
3536
+ if (!inbound) throw new Error("Start the runner first");
3537
+ const [operations, records] = await Promise.all([
3538
+ endpoint.listOperations({ statuses: [...OUTBOUND_UNANSWERED], limit: ACTIVITY_COUNT_LIMIT }),
3539
+ endpoint.listInbound({ states: [...INBOUND_UNSETTLED], limit: ACTIVITY_COUNT_LIMIT })
3540
+ ]);
3541
+ const own = ownActivity();
3542
+ const unsettled = records.filter(({ event, deleted, externalClaim }) => event.kind === "message.created" && !deleted && !externalClaim).length;
3543
+ const inputs = Math.max(own.queued, unsettled);
3544
+ const outbound = own.sending + operations.length;
3545
+ const idle = own.turns === 0 && inputs === 0 && own.errands === 0 && outbound === 0;
3546
+ if (!idle) touch();
3547
+ return { idle, lastActiveAt, turns: own.turns, inputs, errands: own.errands, outbound };
3548
+ }
3549
+ __name(activity, "activity");
3550
+ function watchIdle() {
3551
+ if (idleTimeoutMs === void 0) return;
3552
+ let checking = false;
3553
+ const check = /* @__PURE__ */ __name(async () => {
3554
+ if (checking || stopping || idleCalled) return;
3555
+ const own = ownActivity();
3556
+ if (own.turns || own.queued || own.errands || own.sending) return touch();
3557
+ checking = true;
3558
+ try {
3559
+ const now = await activity();
3560
+ if (now.idle && !stopping && !idleCalled && Date.now() - lastActiveAt >= idleTimeoutMs) {
3561
+ idleCalled = true;
3562
+ try {
3563
+ onIdle(now);
3564
+ } catch (error) {
3565
+ warn("onIdle")(error);
3566
+ }
3567
+ }
3568
+ } catch (error) {
3569
+ if (!stopping) warn("idle check")(error);
3570
+ } finally {
3571
+ checking = false;
3572
+ }
3573
+ }, "check");
3574
+ const timer = setInterval(() => void check(), Math.min(3e4, Math.max(1e3, Math.floor(idleTimeoutMs / 4))));
3575
+ timer.unref?.();
3576
+ detach.push(() => clearInterval(timer));
3577
+ }
3578
+ __name(watchIdle, "watchIdle");
3494
3579
  const room = /* @__PURE__ */ __name((id) => {
3495
3580
  let found = rooms.get(id);
3496
3581
  if (!found) rooms.set(id, found = { id, queue: [], errands: [], controls: Promise.resolve(), lane: Promise.resolve() });
@@ -3569,9 +3654,21 @@ ${error instanceof Error ? error.message : String(error)}`;
3569
3654
  }
3570
3655
  __name(progress, "progress");
3571
3656
  async function offer(event) {
3657
+ offering += 1;
3658
+ touch();
3659
+ try {
3660
+ await take(event);
3661
+ } finally {
3662
+ offering -= 1;
3663
+ touch();
3664
+ }
3665
+ }
3666
+ __name(offer, "offer");
3667
+ async function take(event) {
3572
3668
  const handle = await endpoint.acquireInbound(event.id);
3573
3669
  if (!handle) return;
3574
3670
  const roomId = event.data.conversationId ?? "";
3671
+ const clears = rooms.get(roomId)?.clears;
3575
3672
  const input = await admitInput(event, handle).catch(async (error) => {
3576
3673
  deferredNote(roomId)(error);
3577
3674
  await handle.release("not-admitted-yet");
@@ -3580,11 +3677,12 @@ ${error instanceof Error ? error.message : String(error)}`;
3580
3677
  if (!input) return;
3581
3678
  if (stopping) return void await handle.release("runner-stopping");
3582
3679
  const target = room(input.payload.conversationId);
3680
+ if (target.clears !== clears) return void await dispose([input], "cancel", "stop-and-clear");
3583
3681
  target.queue.push(input);
3584
3682
  requeue(target);
3585
3683
  pump();
3586
3684
  }
3587
- __name(offer, "offer");
3685
+ __name(take, "take");
3588
3686
  async function admitInput(event, handle) {
3589
3687
  const payload = event.data;
3590
3688
  const { conversationId } = payload;
@@ -3735,11 +3833,13 @@ ${decision.reasonCode}`;
3735
3833
  turn.live.end("ended");
3736
3834
  if (candidate.turn === turn) candidate.turn = void 0;
3737
3835
  running.delete(done);
3836
+ touch();
3738
3837
  rooms.delete(candidate.id);
3739
3838
  rooms.set(candidate.id, candidate);
3740
3839
  pump();
3741
3840
  });
3742
3841
  running.add(done);
3842
+ touch();
3743
3843
  }
3744
3844
  while (localErrands.length && !stopping && running.size < maxTurns) startErrand(void 0, localErrands.shift());
3745
3845
  }
@@ -4170,9 +4270,9 @@ ${JSON.stringify(result, null, 2)}`);
4170
4270
  async function outcomeOf(turn, work) {
4171
4271
  const signal = turn.controller.signal;
4172
4272
  const settled = work.then((result) => ({ result }), (error) => ({ error }));
4173
- const aborted = new Promise((resolve3) => {
4174
- if (signal.aborted) resolve3(void 0);
4175
- else signal.addEventListener("abort", () => resolve3(void 0), { once: true });
4273
+ const aborted = new Promise((resolve4) => {
4274
+ if (signal.aborted) resolve4(void 0);
4275
+ else signal.addEventListener("abort", () => resolve4(void 0), { once: true });
4176
4276
  });
4177
4277
  const first = await Promise.race([settled, aborted]);
4178
4278
  if (first && !signal.aborted) return first;
@@ -4414,8 +4514,8 @@ ${JSON.stringify(result, null, 2)}`);
4414
4514
  ...turn.facts ? { roomFacts: turn.facts } : {}
4415
4515
  });
4416
4516
  const line = /* @__PURE__ */ __name((message2, mark = "") => promptLine(message2, message2.senderId === agentId ? "You" : message2.senderName ?? message2.senderId, turn.media.get(message2.id), mark), "line");
4417
- const open4 = /* @__PURE__ */ __name(() => turn.open && !turn.controller.signal.aborted, "open");
4418
- const live = /* @__PURE__ */ __name(() => open4() && !turn.silenced, "live");
4517
+ const open5 = /* @__PURE__ */ __name(() => turn.open && !turn.controller.signal.aborted, "open");
4518
+ const live = /* @__PURE__ */ __name(() => open5() && !turn.silenced, "live");
4419
4519
  return {
4420
4520
  session: turn.session,
4421
4521
  // §12.6: an errand turn's prompt is its errand; the owner's message is only its input's record.
@@ -4437,18 +4537,18 @@ ${JSON.stringify(result, null, 2)}`);
4437
4537
  if (live()) progress(turn);
4438
4538
  }, "onActivity"),
4439
4539
  ask: /* @__PURE__ */ __name((request) => live() ? ask(roomId, turn, input.sender, request) : Promise.reject(turnEnded2()), "ask"),
4440
- state: driverState(turn.session, open4),
4540
+ state: driverState(turn.session, open5),
4441
4541
  tools: tools2,
4442
- confirmPickup: /* @__PURE__ */ __name(() => confirmPickup(roomId, turn, open4), "confirmPickup")
4542
+ confirmPickup: /* @__PURE__ */ __name(() => confirmPickup(roomId, turn, open5), "confirmPickup")
4443
4543
  };
4444
4544
  }
4445
4545
  __name(buildTurn, "buildTurn");
4446
- async function confirmPickup(roomId, turn, open4) {
4447
- if (!open4()) throw turnEnded2();
4546
+ async function confirmPickup(roomId, turn, open5) {
4547
+ if (!open5()) throw turnEnded2();
4448
4548
  turn.pickingUp = true;
4449
4549
  try {
4450
4550
  const current = await client.getConversation(roomId);
4451
- if (!open4()) throw turnEnded2();
4551
+ if (!open5()) throw turnEnded2();
4452
4552
  if (!current?.memberIds?.includes(agentId)) {
4453
4553
  loseMembership(room(roomId));
4454
4554
  throw new Error("The agent is no longer a member of this conversation");
@@ -4460,7 +4560,7 @@ ${JSON.stringify(result, null, 2)}`);
4460
4560
  }
4461
4561
  throw new Error("Who can read this encrypted conversation changed");
4462
4562
  }
4463
- if (!open4()) throw turnEnded2();
4563
+ if (!open5()) throw turnEnded2();
4464
4564
  if (turn.pickUp) await turn.pickUp();
4465
4565
  progress(turn);
4466
4566
  } finally {
@@ -4552,9 +4652,13 @@ ${JSON.stringify(result, null, 2)}`);
4552
4652
  async function control(current) {
4553
4653
  const consumed = await client.consumeRuntimeSignal(current.id);
4554
4654
  if (consumed.status !== "signal") return;
4655
+ touch();
4555
4656
  const signal = consumed.signal;
4556
4657
  const turn = current.turn;
4557
- if (signal === "stop_and_drop" || signal === "new_session") void dispose(current.queue.splice(0), "cancel", "stop-and-clear");
4658
+ if (signal === "stop_and_drop" || signal === "new_session") {
4659
+ current.clears = (current.clears ?? 0) + 1;
4660
+ void dispose(current.queue.splice(0), "cancel", "stop-and-clear");
4661
+ }
4558
4662
  if (turn) {
4559
4663
  void dispose(turn.inputs, "cancel", "stopped-before-submit");
4560
4664
  abort(turn, signal === "interrupt" ? "stop" : signal === "stop_and_drop" ? "drop" : "new_session");
@@ -4568,6 +4672,7 @@ ${JSON.stringify(result, null, 2)}`);
4568
4672
  const reset = Promise.all([...sessions].map((session) => resetSession(session).catch(warn("session reset")))).then(() => void 0);
4569
4673
  await (current.resetting = reset.finally(() => {
4570
4674
  current.resetting = void 0;
4675
+ touch();
4571
4676
  pump();
4572
4677
  }));
4573
4678
  }
@@ -4611,6 +4716,7 @@ ${JSON.stringify(result, null, 2)}`);
4611
4716
  })().catch(warn("restart recovery"));
4612
4717
  target.recovering = work.finally(() => {
4613
4718
  target.recovering = void 0;
4719
+ touch();
4614
4720
  pump();
4615
4721
  });
4616
4722
  }
@@ -4738,7 +4844,11 @@ ${JSON.stringify(result, null, 2)}`);
4738
4844
  if (sent !== "pending") finishErrandOutput(record, sent);
4739
4845
  }, warn(`errand ${id}`));
4740
4846
  posting.add(delivery);
4741
- void delivery.finally(() => posting.delete(delivery));
4847
+ touch();
4848
+ void delivery.finally(() => {
4849
+ posting.delete(delivery);
4850
+ touch();
4851
+ });
4742
4852
  return true;
4743
4853
  }
4744
4854
  __name(takeErrand, "takeErrand");
@@ -4784,6 +4894,7 @@ ${JSON.stringify(result, null, 2)}`);
4784
4894
  __name(saveErrand, "saveErrand");
4785
4895
  function enqueueErrand(record) {
4786
4896
  (record.origin ? room(record.origin).errands : localErrands).push(record);
4897
+ touch();
4787
4898
  pump();
4788
4899
  }
4789
4900
  __name(enqueueErrand, "enqueueErrand");
@@ -4821,10 +4932,10 @@ ${JSON.stringify(result, null, 2)}`);
4821
4932
  }
4822
4933
  __name(contextPrompt, "contextPrompt");
4823
4934
  function openErrands(roomId) {
4824
- const open4 = [...errandsById.values()].filter((record) => record.conversationId === roomId && openErrand(record));
4825
- const stale = open4.filter((record) => Date.now() - Date.parse(record.repliedAt ?? record.reachedAt ?? record.updatedAt) > ERRAND_OPEN_MS);
4935
+ const open5 = [...errandsById.values()].filter((record) => record.conversationId === roomId && openErrand(record));
4936
+ const stale = open5.filter((record) => Date.now() - Date.parse(record.repliedAt ?? record.reachedAt ?? record.updatedAt) > ERRAND_OPEN_MS);
4826
4937
  for (const record of stale) saveErrand({ ...record, status: "expired", reason: "no reply within a week" });
4827
- return open4.filter((record) => !stale.includes(record));
4938
+ return open5.filter((record) => !stale.includes(record));
4828
4939
  }
4829
4940
  __name(openErrands, "openErrands");
4830
4941
  function replied(turn) {
@@ -4859,6 +4970,7 @@ ${JSON.stringify(result, null, 2)}`);
4859
4970
  if (holder.turn === turn) holder.turn = void 0;
4860
4971
  errandTurns.delete(record.id);
4861
4972
  running.delete(done);
4973
+ touch();
4862
4974
  if (origin) {
4863
4975
  rooms.delete(origin.id);
4864
4976
  rooms.set(origin.id, origin);
@@ -4866,6 +4978,7 @@ ${JSON.stringify(result, null, 2)}`);
4866
4978
  pump();
4867
4979
  });
4868
4980
  running.add(done);
4981
+ touch();
4869
4982
  }
4870
4983
  __name(startErrand, "startErrand");
4871
4984
  async function executeErrand(current, turn, record) {
@@ -5247,7 +5360,10 @@ ${JSON.stringify(result, null, 2)}`);
5247
5360
  }
5248
5361
  warn("recovery")(error);
5249
5362
  }, "onError") }));
5363
+ touch();
5364
+ watchIdle();
5250
5365
  },
5366
+ activity,
5251
5367
  stop() {
5252
5368
  stopping ??= (async () => {
5253
5369
  for (const off of detach.splice(0).reverse()) off();
@@ -5275,13 +5391,14 @@ ${JSON.stringify(result, null, 2)}`);
5275
5391
  },
5276
5392
  async communicate(input) {
5277
5393
  if (!client) throw new Error("Start the runner before sending");
5278
- return client.communicate(parseCommunicateToolInput(input));
5394
+ return activeCall(() => client.communicate(parseCommunicateToolInput(input)));
5279
5395
  },
5280
5396
  async relayRead(conversationId, page = {}) {
5397
+ touch();
5281
5398
  const limit = page.limit ?? 20;
5282
5399
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new ConnectorRelayError(400, "CONNECTOR_RELAY_INVALID", "limit must be 1 to 100");
5283
5400
  const deadline = page.deadline ?? Date.now() + CONNECTOR_RELAY_ANSWER_WITHIN_MS;
5284
- return beforeDeadline((async () => {
5401
+ return beforeDeadline(activeCall(async () => {
5285
5402
  const { ownerId } = await relayRoom(conversationId);
5286
5403
  const result = await readAgain(() => endpoint.query("list_messages", { conversationId, limit, ...page.before ? { before: page.before } : {} }), deadline);
5287
5404
  const accepted = result.messages.filter((message2) => !message2.deleted && typeof message2.text === "string" && message2.status === "sent" && Number.isSafeInteger(message2.mlsFeedOrdinal) && message2.mlsFeedOrdinal > 0);
@@ -5293,9 +5410,10 @@ ${JSON.stringify(result, null, 2)}`);
5293
5410
  nextBefore = String(accepted.at(-1).mlsFeedOrdinal);
5294
5411
  }
5295
5412
  return { messages, nextBefore };
5296
- })(), deadline);
5413
+ }), deadline);
5297
5414
  },
5298
5415
  async relaySend(conversationId, messageId, text3, options2 = {}) {
5416
+ touch();
5299
5417
  if (typeof messageId !== "string" || !ENDPOINT_ID_PATTERN.test(messageId) || messageId.length > 120 || typeof text3 !== "string" || !text3.trim() || Buffer.byteLength(text3) > MLS_DEV_TEXT_MAX_BYTES) {
5300
5418
  throw new ConnectorRelayError(400, "CONNECTOR_RELAY_INVALID", "A send needs a messageId and 1 byte to 32 KiB of text");
5301
5419
  }
@@ -5303,13 +5421,14 @@ ${JSON.stringify(result, null, 2)}`);
5303
5421
  const id = `relay-send-${sha2563(JSON.stringify([conversationId, messageId])).slice(0, 32)}`;
5304
5422
  let entry = relaySends.get(id);
5305
5423
  if (!entry) relaySends.set(id, entry = prepareRelaySend(conversationId, id, text3, deadline));
5306
- const prepared = await beforeDeadline(entry.ready, deadline);
5424
+ const prepared = await beforeDeadline(activeCall(() => entry.ready), deadline);
5307
5425
  if (prepared.text !== text3) throw new ConnectorRelayError(409, "CONNECTOR_RELAY_MESSAGE_CONFLICT", "This messageId was already sent with other text");
5308
- const sent = await within(entry.sent, Math.max(0, deadline - Date.now()));
5426
+ const sent = await within(activeCall(() => entry.sent), Math.max(0, deadline - Date.now()));
5309
5427
  if (sent === "refused") throw new ConnectorRelayError(409, "CONNECTOR_RELAY_SEND_REFUSED", "Canon did not accept the message; the conversation's members may have changed");
5310
5428
  return { conversationId, messageId, historyMessageId: id, status: sent === "delivered" ? "sent" : "pending" };
5311
5429
  },
5312
5430
  async relayStart(targetUserId, messageId, text3, selection, startOptions = {}) {
5431
+ touch();
5313
5432
  if (typeof targetUserId !== "string" || !targetUserId || targetUserId.length > 200 || targetUserId.includes("/") || typeof messageId !== "string" || !ENDPOINT_ID_PATTERN.test(messageId) || messageId.length > 120 || typeof text3 !== "string" || !text3.trim() || Buffer.byteLength(text3) > MLS_DEV_TEXT_MAX_BYTES) {
5314
5433
  throw new ConnectorRelayError(400, "CONNECTOR_RELAY_INVALID", "A start needs a person or agent, a messageId and 1 byte to 32 KiB of text");
5315
5434
  }
@@ -5318,12 +5437,12 @@ ${JSON.stringify(result, null, 2)}`);
5318
5437
  const deadline = startOptions.deadline ?? Date.now() + CONNECTOR_RELAY_ANSWER_WITHIN_MS;
5319
5438
  const identity = { messageId, historyMessageId: `relay-start-${sha2563(messageId).slice(0, 32)}` };
5320
5439
  const answer = await within(
5321
- endpoint.startWithheldOpener({
5440
+ activeCall(() => endpoint.startWithheldOpener({
5322
5441
  targetUserId,
5323
5442
  text: text3,
5324
5443
  messageId: identity.historyMessageId,
5325
5444
  ...selection ? { selection } : {}
5326
- }).then((result2) => ({ result: result2 }), (error) => ({ error })),
5445
+ })).then((result2) => ({ result: result2 }), (error) => ({ error })),
5327
5446
  Math.max(0, deadline - Date.now())
5328
5447
  );
5329
5448
  if (!answer || "error" in answer && answer.error?.retryable === true) {
@@ -5373,6 +5492,81 @@ function relayMessage(message2, ownerId) {
5373
5492
  }
5374
5493
  __name(relayMessage, "relayMessage");
5375
5494
 
5495
+ // src/runner/wake.ts
5496
+ import { constants as constants5 } from "node:fs";
5497
+ import { open as open4, realpath as realpath5 } from "node:fs/promises";
5498
+ import { isAbsolute as isAbsolute4, resolve as resolve3 } from "node:path";
5499
+ var MAX_WAKE_CONFIGURATION_BYTES = 16 * 1024;
5500
+ var MAX_WAKE_ROOMS = 32;
5501
+ var ROOM_ID = /^[A-Za-z0-9_-]{1,200}$/;
5502
+ function isWakeSecret(value) {
5503
+ if (typeof value !== "string" || !/^whsec_[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
5504
+ const key = Buffer.from(value.slice(6), "base64");
5505
+ return key.length >= 32 && key.length <= 64 && key.toString("base64") === value.slice(6);
5506
+ }
5507
+ __name(isWakeSecret, "isWakeSecret");
5508
+ function parseWakeConfiguration(value) {
5509
+ const invalid2 = /* @__PURE__ */ __name((what) => {
5510
+ throw new Error(`Invalid wake configuration: ${what}`);
5511
+ }, "invalid");
5512
+ if (!value || typeof value !== "object" || Array.isArray(value)) return invalid2("expected a JSON object");
5513
+ const v = value;
5514
+ const keys = ["version", "callbackUrl", "secret", "conversationIds"];
5515
+ if (Object.keys(v).sort().join() !== [...keys].sort().join()) invalid2(`expected ${keys.join(", ")}`);
5516
+ if (v.version !== 1) invalid2("version");
5517
+ let url;
5518
+ try {
5519
+ url = typeof v.callbackUrl === "string" && v.callbackUrl.length <= 2048 ? new URL(v.callbackUrl) : void 0;
5520
+ } catch {
5521
+ }
5522
+ if (!url || url.protocol !== "https:" || url.username || url.password || url.hash) invalid2("callbackUrl: a public https URL, with no credentials or fragment");
5523
+ if (!isWakeSecret(v.secret)) invalid2("secret: whsec_ and the padded base64 of 32 to 64 random bytes");
5524
+ const rooms = v.conversationIds;
5525
+ if (!Array.isArray(rooms) || rooms.length < 1 || rooms.length > MAX_WAKE_ROOMS || rooms.some((id) => typeof id !== "string" || !ROOM_ID.test(id))) {
5526
+ invalid2(`conversationIds: 1 to ${MAX_WAKE_ROOMS} conversation ids`);
5527
+ }
5528
+ return { callbackUrl: url.toString(), secret: v.secret, conversationIds: [...new Set(rooms)].sort() };
5529
+ }
5530
+ __name(parseWakeConfiguration, "parseWakeConfiguration");
5531
+ async function loadWakeConfiguration(path) {
5532
+ const fail2 = /* @__PURE__ */ __name(() => {
5533
+ throw new Error(`The wake configuration must be a private file (0600, owned by this user, not a link): ${path}`);
5534
+ }, "fail");
5535
+ if (typeof path !== "string" || !isAbsolute4(path) || path.includes("\0") || resolve3(path) !== path || await realpath5(path).catch(() => "") !== path) fail2();
5536
+ const windows = process.platform === "win32";
5537
+ if (windows) assertPrivateWindowsFile(path);
5538
+ const file = await open4(path, constants5.O_RDONLY | (constants5.O_NOFOLLOW ?? 0) | (constants5.O_NONBLOCK ?? 0)).catch(() => fail2());
5539
+ let text3;
5540
+ try {
5541
+ const info = await file.stat();
5542
+ if (!info.isFile() || info.nlink !== 1 || info.size < 1 || info.size > MAX_WAKE_CONFIGURATION_BYTES) fail2();
5543
+ if (!windows && (info.uid !== process.getuid?.() || (info.mode & 63) !== 0)) fail2();
5544
+ const bytes = await readBoundedRegularFileHandle(file, MAX_WAKE_CONFIGURATION_BYTES).catch(() => fail2());
5545
+ try {
5546
+ text3 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
5547
+ } catch {
5548
+ return fail2();
5549
+ }
5550
+ } finally {
5551
+ await file.close();
5552
+ }
5553
+ let value;
5554
+ try {
5555
+ value = JSON.parse(text3);
5556
+ } catch {
5557
+ throw new Error("Invalid wake configuration: not JSON");
5558
+ }
5559
+ return parseWakeConfiguration(value);
5560
+ }
5561
+ __name(loadWakeConfiguration, "loadWakeConfiguration");
5562
+ function parseIdleTimeout(value) {
5563
+ const match = /^(\d{1,7})([smh]?)$/.exec(value.trim());
5564
+ const ms = match ? Number(match[1]) * { "": 1e3, s: 1e3, m: 6e4, h: 36e5 }[match[2]] : NaN;
5565
+ if (!Number.isSafeInteger(ms) || ms < 1e3) throw new Error("--idle-timeout takes a duration of at least 1 second: 90, 90s, 10m or 1h.");
5566
+ return ms;
5567
+ }
5568
+ __name(parseIdleTimeout, "parseIdleTimeout");
5569
+
5376
5570
  export {
5377
5571
  DriverSessionError,
5378
5572
  ConnectorRelayError,
@@ -5404,5 +5598,8 @@ export {
5404
5598
  writeRelayCredential,
5405
5599
  relayRunnerHome,
5406
5600
  runtimeDescriptor,
5407
- createRunner
5601
+ createRunner,
5602
+ parseWakeConfiguration,
5603
+ loadWakeConfiguration,
5604
+ parseIdleTimeout
5408
5605
  };
@@ -5473,6 +5473,7 @@ function createMlsDevGroups(options, context, keyPackages, bridge) {
5473
5473
  return { changed: false, operation: void 0 };
5474
5474
  }
5475
5475
  if (feed) await checkFeedEntry(tx, current, conversationId, feed);
5476
+ if (!current.envelope?.head && current.envelope?.pending?.control.kind === "create" && current.envelope.pending.operationId !== accepted.operationId) fail6("MLS_DEV_GROUP_PENDING_CONTROL", true);
5476
5477
  const rejoining = !!current.envelope && accepted.control.kind === "add" && accepted.control.keyPackageClaim?.recipientDeviceId === current.device.registration.device.deviceId && accepted.head.sequence > (current.envelope.head?.sequence ?? 0);
5477
5478
  if (current.envelope?.removed && !rejoining) fail6("MLS_DEV_GROUP_REMOVED");
5478
5479
  if (current.envelope?.head && !rejoining && (!same6(accepted.control.expectedHead, current.envelope.head) || accepted.head.sequence !== current.envelope.head.sequence + 1)) fail6("MLS_DEV_GROUP_CONTROL_GAP", true);
package/dist/cli.js CHANGED
@@ -17,13 +17,15 @@ import {
17
17
  loadInstructions,
18
18
  loadMlsDevConfiguration,
19
19
  loadRelayConfiguration,
20
+ loadWakeConfiguration,
21
+ parseIdleTimeout,
20
22
  queueErrand,
21
23
  readRelayCredential,
22
24
  relayRunnerHome,
23
25
  runMlsDevSetup,
24
26
  startRelayChannel,
25
27
  writeRelayCredential
26
- } from "./chunks/chunk-DNHJGAEL.js";
28
+ } from "./chunks/chunk-IEKVS7AN.js";
27
29
  import {
28
30
  assertPrivateHome,
29
31
  runnerLockName
@@ -38,7 +40,7 @@ import {
38
40
  resolveCanonRuntimeConnection,
39
41
  runCli,
40
42
  verifyCanonRuntimeConnection
41
- } from "./chunks/chunk-TYZT346O.js";
43
+ } from "./chunks/chunk-ZPBOGOAD.js";
42
44
  import "./chunks/chunk-YJUPKU47.js";
43
45
  import "./chunks/chunk-YHDDOADK.js";
44
46
  import {
@@ -56,10 +58,10 @@ var RUNNER_VERSION = readCliPackageVersion(import.meta.url);
56
58
  var RUNNER_CLIENT = isCanonClientVersion(RUNNER_VERSION) ? { kind: "runner", version: RUNNER_VERSION } : void 0;
57
59
  function once(handler) {
58
60
  let called = false;
59
- return (error) => {
61
+ return (...args) => {
60
62
  if (!called) {
61
63
  called = true;
62
- handler(error);
64
+ handler(...args);
63
65
  }
64
66
  };
65
67
  }
@@ -97,6 +99,14 @@ command also takes --mls-dev-config <path>, an operator's own custody instead. W
97
99
  of these, the agent refuses every room, and where Canon encrypts every new conversation
98
100
  it does not start.
99
101
 
102
+ Waking and sleeping: code, claude, codex and acp also take [--wake-config <path> |
103
+ --wake-off] [--idle-timeout <duration>]. With --wake-config the agent registers, at
104
+ each start, a public HTTPS callback for up to 32 rooms; Canon POSTs it a signed,
105
+ content-free hint when a person types or a message arrives there, so the host can
106
+ start (or resume) the agent. With --idle-timeout the agent stops and exits 0 once it
107
+ has had nothing to do for that long: no turn, no input, no errand, nothing Canon has
108
+ yet to accept. Nothing is lost while it is stopped; it answers once it starts again.
109
+
100
110
  Team rooms (an owner's agents working together): claude, codex and acp also take
101
111
  [--instructions <file>] [--trust-agent <agentId>]...; every run command takes
102
112
  [--agent-turn-cap <n>] [--mention-alias <name>]....
@@ -174,6 +184,17 @@ Flags:
174
184
  $CANON_HOME/relay. The relay serves only encrypted rooms,
175
185
  which are then not end-to-end encrypted to that agent. It sets
176
186
  up the relay's device with that credential.
187
+ --wake-config <path> Register the agent's wake callback at start: a private JSON file
188
+ (0600, owned by you, not a link) {"version": 1, "callbackUrl":
189
+ "https://...", "secret": "whsec_<base64 of 32 random bytes>",
190
+ "conversationIds": [up to 32 room ids]}. Canon checks the URL
191
+ with a signed challenge first; the agent does not start if
192
+ that fails. Without the flag a registration is left as it is.
193
+ --wake-off Remove the agent's wake callback at start.
194
+ --idle-timeout <d> Stop and exit 0 after this long with nothing to do: whole
195
+ seconds, or 90s, 10m, 1h. Typing in a room does not reach the
196
+ agent: a host that also receives typing hints decides itself
197
+ whether to keep the agent awake for them.
177
198
  --mls-dev-config <p> An operator's own MLS configuration, instead of the device the
178
199
  runner keeps itself: an absolute path to a private JSON file
179
200
  with the environment's issuer pins and the custody helper (no
@@ -263,6 +284,16 @@ async function main() {
263
284
  if (values["guest-mode"] !== void 0 && values["unguarded-guests"]) throw new UsageError("--guest-mode and --unguarded-guests are alternatives.");
264
285
  const team = teamOptions(values);
265
286
  if (values.driver === "connector" && values["allow-session-for-destructive"]) throw new UsageError("--allow-session-for-destructive goes with a driver that asks.");
287
+ if (values.driver === "connector" && (values["wake-config"] !== void 0 || values["wake-off"] || values["idle-timeout"] !== void 0)) {
288
+ throw new UsageError("--wake-config, --wake-off and --idle-timeout go with an agent's own key, not --driver connector.");
289
+ }
290
+ if (values["wake-config"] !== void 0 && values["wake-off"]) throw new UsageError("--wake-config and --wake-off are alternatives.");
291
+ let idleTimeoutMs;
292
+ try {
293
+ idleTimeoutMs = values["idle-timeout"] !== void 0 ? parseIdleTimeout(values["idle-timeout"]) : void 0;
294
+ } catch (error) {
295
+ throw new UsageError(error.message);
296
+ }
266
297
  if (values.driver === "connector") return runRelay(values, team);
267
298
  const permissionMode = values["permission-mode"];
268
299
  if (permissionMode !== void 0 && !PERMISSION_MODES.includes(permissionMode)) {
@@ -278,6 +309,7 @@ async function main() {
278
309
  const trustedAgents = values["trust-agent"] ?? [];
279
310
  if (trustedAgents.some((id) => !/^[\w.:-]{1,200}$/.test(id))) throw new UsageError("--trust-agent takes an agent id.");
280
311
  const instructions = values.instructions !== void 0 ? await loadInstructions(resolve(values.instructions)) : void 0;
312
+ const wake = values["wake-off"] ? null : values["wake-config"] !== void 0 ? await loadWakeConfiguration(resolve(values["wake-config"])) : void 0;
281
313
  const { environmentId, home, profile, apiKey } = await settings(values);
282
314
  const mls = await mlsDevice(values, environmentId, home);
283
315
  const baseUrl = process.env.CANON_BASE_URL || profile?.baseUrl;
@@ -335,6 +367,15 @@ async function main() {
335
367
  ...trustedAgents.length ? { trustedAgents } : {},
336
368
  ...values["allow-session-for-destructive"] ? { sessionApprovalForDestructive: true } : {},
337
369
  ...RUNNER_CLIENT ? { client: RUNNER_CLIENT } : {},
370
+ ...wake !== void 0 ? { wake } : {},
371
+ // Idle: a clean stop and exit 0, so a supervisor that restarts only on failure leaves the agent stopped until it is woken.
372
+ ...idleTimeoutMs !== void 0 ? { idleTimeoutMs, onIdle: once(() => {
373
+ console.error(`canon-agent: nothing to do for ${values["idle-timeout"]}; stopping.`);
374
+ runner.stop().then(() => process.exit(0), (error) => {
375
+ console.error(`canon-agent: ${error instanceof Error ? error.message : String(error)}`);
376
+ process.exit(1);
377
+ });
378
+ }) } : {},
338
379
  onFatal: once((error) => {
339
380
  console.error(`canon-agent: ${error.message}`);
340
381
  void runner.stop().catch(() => void 0).finally(() => process.exit(1));
@@ -347,7 +388,7 @@ async function main() {
347
388
  await runner.stop().catch(() => void 0);
348
389
  throw error;
349
390
  }
350
- console.error(`canon-agent: running the ${values.driver} driver in ${environmentId}. Ctrl-C stops it.`);
391
+ console.error(`canon-agent: running the ${values.driver} driver in ${environmentId}. Ctrl-C stops it${idleTimeoutMs !== void 0 ? `, and it stops itself after ${values["idle-timeout"]} with nothing to do` : ""}.`);
351
392
  }
352
393
  __name(main, "main");
353
394
  async function runRelay(values, team) {
@@ -478,7 +519,10 @@ function parse() {
478
519
  why: { type: "string" },
479
520
  origin: { type: "string" },
480
521
  follow: { type: "boolean" },
481
- cancel: { type: "string" }
522
+ cancel: { type: "string" },
523
+ "wake-config": { type: "string" },
524
+ "wake-off": { type: "boolean" },
525
+ "idle-timeout": { type: "string" }
482
526
  }
483
527
  });
484
528
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { createRunner, runtimeDescriptor } from './runner/runner.js';
2
- export type { Runner, RunnerOptions } from './runner/runner.js';
2
+ export type { Runner, RunnerActivity, RunnerOptions } from './runner/runner.js';
3
+ export { loadWakeConfiguration, parseWakeConfiguration } from './runner/wake.js';
3
4
  export { ConnectorRelayError, DriverSessionError } from './runner/types.js';
4
5
  export { loadMlsDevConfiguration, MLS_SETUP_ACTIONS, mlsDevRoomPackageId, runMlsDevSetup } from './runner/mls.js';
5
6
  export type { MlsSetupAction, RunnerMlsOptions } from './runner/mls.js';
package/dist/index.js CHANGED
@@ -18,13 +18,15 @@ import {
18
18
  loadClaudeQuery,
19
19
  loadMlsDevConfiguration,
20
20
  loadRelayConfiguration,
21
+ loadWakeConfiguration,
21
22
  managedMlsDevice,
22
23
  mlsDevRoomPackageId,
24
+ parseWakeConfiguration,
23
25
  queueErrand,
24
26
  runMlsDevSetup,
25
27
  runtimeDescriptor,
26
28
  startRelayChannel
27
- } from "./chunks/chunk-DNHJGAEL.js";
29
+ } from "./chunks/chunk-IEKVS7AN.js";
28
30
  import "./chunks/chunk-DTAWEQCM.js";
29
31
  import {
30
32
  CanonApiError,
@@ -50,7 +52,7 @@ import {
50
52
  toAnthropicImageBlock,
51
53
  toAnthropicImageBlocksWithinBudget,
52
54
  uploadMediaFile
53
- } from "./chunks/chunk-TYZT346O.js";
55
+ } from "./chunks/chunk-ZPBOGOAD.js";
54
56
  import "./chunks/chunk-YJUPKU47.js";
55
57
  import "./chunks/chunk-YHDDOADK.js";
56
58
  import "./chunks/chunk-5WPAB5MB.js";
@@ -88,11 +90,13 @@ export {
88
90
  loadClaudeQuery,
89
91
  loadMlsDevConfiguration,
90
92
  loadRelayConfiguration,
93
+ loadWakeConfiguration,
91
94
  managedMlsDevice,
92
95
  materializeAttachment,
93
96
  materializeMessageMedia,
94
97
  materializeReplyContextMedia,
95
98
  mlsDevRoomPackageId,
99
+ parseWakeConfiguration,
96
100
  queueErrand,
97
101
  resolveAttachmentMimeType,
98
102
  resumeRegistration,
@@ -1,8 +1,9 @@
1
1
  import type { Endpoint } from './engine.js';
2
2
  /** Transient writes are coalesced here; they never enter the durable outbox. */
3
3
  export declare function createEndpointTransientPublisher(endpoint: Pick<Endpoint, 'publish'>, clock?: () => number): {
4
- pulseTyping(conversationId: string): void;
5
- stopTyping: (conversationId: string) => void;
4
+ /** A keystroke in the composer wakes the room's agents: once per burst of typing (a 3 s pause ends one), and every 10 s
5
+ * of it. The hint carries no content; typing itself never leaves the device. */
6
+ prewarmAgents(conversationId: string): void;
6
7
  setPresence(state: "online" | "offline" | "hidden"): void;
7
8
  close(): void;
8
9
  };
package/dist/media.js CHANGED
@@ -18,7 +18,7 @@ import {
18
18
  toAnthropicImageBlock,
19
19
  toAnthropicImageBlocksWithinBudget,
20
20
  uploadMediaFile
21
- } from "./chunks/chunk-TYZT346O.js";
21
+ } from "./chunks/chunk-ZPBOGOAD.js";
22
22
  import "./chunks/chunk-YJUPKU47.js";
23
23
  import "./chunks/chunk-YHDDOADK.js";
24
24
  import "./chunks/chunk-5WPAB5MB.js";
@@ -68,9 +68,35 @@ export interface RunnerOptions {
68
68
  sessionApprovalForDestructive?: boolean;
69
69
  /** How long running turns may finish on shutdown before they are aborted (default 10 s). */
70
70
  shutdownGraceMs?: number;
71
+ /**
72
+ * Idle (agent-wake.md): `onIdle` is called once the runner has been idle (`activity().idle`) for this long, in ms, and
73
+ * again only after new activity and another idle period. The runner does nothing else about it: the host decides, as the
74
+ * CLI's --idle-timeout stops and exits, or a hosting platform suspends the agent's machine until Canon's wake callback.
75
+ */
76
+ idleTimeoutMs?: number;
77
+ /** Idle: called with the activity that showed it; it never names a room, message or content. */
78
+ onIdle?(activity: RunnerActivity): void;
71
79
  /** Never receives prompt or reply text. */
72
80
  log?: (line: string) => void;
73
81
  }
82
+ /**
83
+ * What the runner has in hand, for a host that stops or suspends an idle agent (agent-wake.md). Counts only. Idle is all
84
+ * of them 0: once stopped or suspended, the agent loses nothing (input waits in Canon and in its inbox) but answers only
85
+ * when Canon's wake callback, or its host, starts it again.
86
+ */
87
+ export interface RunnerActivity {
88
+ idle: boolean;
89
+ /** When the runner last had something to do (epoch ms), or when it started. */
90
+ lastActiveAt: number;
91
+ /** Turns running or starting, errand turns, restart recovery and New session resets included. */
92
+ turns: number;
93
+ /** Input waiting for a turn: in the inbox, being admitted, or queued behind a running turn (whose own input is in `turns`). */
94
+ inputs: number;
95
+ /** Errands waiting for their turn. */
96
+ errands: number;
97
+ /** In-flight proactive/relay calls plus operations Canon has not answered, including replies, cards and group changes. */
98
+ outbound: number;
99
+ }
74
100
  export interface Runner {
75
101
  /** Connects and starts taking input; resolves once listening is set up. */
76
102
  start(): Promise<void>;
@@ -78,6 +104,8 @@ export interface Runner {
78
104
  stop(): Promise<void>;
79
105
  /** A proactive send outside any turn (cron): the `communicate` tool's handler, under the runner's identity. */
80
106
  communicate(input: CommunicateInput): Promise<CommunicateResult>;
107
+ /** What the runner has in hand now, for a host's idle policy (agent-wake.md). Only between start and stop. */
108
+ activity(): Promise<RunnerActivity>;
81
109
  /**
82
110
  * Relay mode (§11.6): an encrypted room's history as this device verified it and Canon accepted it, newest first, cut
83
111
  * to the channel's response budget. `deadline` (epoch ms, default the channel's answer budget) bounds the read.
@@ -0,0 +1,16 @@
1
+ import type { AgentWakeConfiguration } from '../internal/backend-contracts/index.js';
2
+ /**
3
+ * A wake callback's signing secret: `whsec_` and the standard base64 (padded) of 32 to 64 random bytes. Canon takes at
4
+ * least 32 for a wake callback, and a receiver such as Agent Platform's `/wake/:bindingId` at most 64, padded.
5
+ */
6
+ export declare function isWakeSecret(value: unknown): value is string;
7
+ /** The same checks Canon makes before it sends the callback its verification challenge; Canon checks again. */
8
+ export declare function parseWakeConfiguration(value: unknown): AgentWakeConfiguration;
9
+ /**
10
+ * `--wake-config <path>` (agent-wake.md): the agent's signed wake callback. A private JSON file (0600, owned by this user,
11
+ * one name, no links; on Windows only this user, SYSTEM and Administrators may reach it and its folder), since it holds
12
+ * the callback's signing secret: `{ "version": 1, "callbackUrl", "secret", "conversationIds" }`. Its text is never logged.
13
+ */
14
+ export declare function loadWakeConfiguration(path: string): Promise<AgentWakeConfiguration>;
15
+ /** `--idle-timeout`: whole seconds, or a number with `s`, `m` or `h`; at least one second. Returns milliseconds. */
16
+ export declare function parseIdleTimeout(value: string): number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "13.0.2",
3
+ "version": "13.1.0",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",