@staix/agent-hub 0.29.0 → 0.30.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/CHANGELOG.md +12 -0
  2. package/README.md +5 -3
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -2
  8. package/docs/agent-notes/tests.md +2 -0
  9. package/docs/design/adapters.md +56 -0
  10. package/docs/design/agent-hub-architecture.md +12 -0
  11. package/docs/design/bus.md +8 -0
  12. package/docs/design/task-board.md +18 -0
  13. package/docs/dogfood.md +7 -4
  14. package/docs/operations.md +106 -11
  15. package/docs/quickstart.md +10 -3
  16. package/docs/sdlc.md +1 -1
  17. package/docs/security.md +28 -2
  18. package/docs/smoke.md +13 -2
  19. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  20. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  21. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  22. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  23. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  24. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  25. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  26. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  27. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  28. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  29. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  30. package/docs/verification/pi-150-model-free.ts +57 -0
  31. package/docs/verification/pi-150-native.mjs +21 -0
  32. package/docs/verification/pi-150-title-api.mjs +31 -0
  33. package/docs/verified.json +14 -14
  34. package/package.json +2 -2
  35. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  36. package/plugins/agent-hub/server.js +193 -29
  37. package/src/adapters/compat.ts +10 -10
  38. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  39. package/src/adapters/local-worker.ts +22 -6
  40. package/src/adapters/pi.ts +92 -10
  41. package/src/adapters/registry-types.ts +4 -0
  42. package/src/adapters/registry.ts +2 -0
  43. package/src/cli/autonomy.ts +66 -1
  44. package/src/cli/console-state.ts +6 -2
  45. package/src/cli/help.ts +2 -0
  46. package/src/cli/identity.ts +8 -1
  47. package/src/cli/main.ts +39 -0
  48. package/src/cli/worktree-tidy.ts +158 -0
  49. package/src/cli/worktrees-output.ts +43 -0
  50. package/src/hub/board.ts +28 -3
  51. package/src/hub/bus.ts +4 -4
  52. package/src/hub/conductor.ts +4 -3
  53. package/src/hub/config-apply.ts +2 -1
  54. package/src/hub/daemon.ts +237 -19
  55. package/src/hub/dogfood-read.ts +62 -9
  56. package/src/hub/hub-tools.ts +11 -5
  57. package/src/hub/manager.ts +9 -1
  58. package/src/hub/peer-doing.ts +33 -3
  59. package/src/hub/peers.ts +7 -0
  60. package/src/hub/settings.ts +3 -0
  61. package/src/hub/tasks.ts +30 -8
  62. package/src/hub/ui.ts +2 -0
  63. package/src/hub/worktree-prs.ts +100 -0
  64. package/src/hub/worktree-shelf.ts +246 -0
  65. package/src/memory/capture.ts +1 -1
  66. package/src/pi/extension.ts +43 -3
  67. package/src/ui/index.html +133 -10
  68. package/src/ui/office-model.ts +64 -8
  69. package/src/ui/office-space.ts +1 -0
  70. package/src/ui/plugin.ts +14 -3
@@ -305,13 +305,13 @@ var init_compat = __esm(() => {
305
305
  COMPAT = {
306
306
  pi: {
307
307
  label: "Pi",
308
- verified: ["1.4.0"],
308
+ verified: ["1.4.0", "1.5.0"],
309
309
  gate: "contract",
310
310
  probe: ["pi", "--version"],
311
311
  config: "pi.cmd",
312
312
  parse: (out) => plain(out.trim()),
313
- trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
314
- receipt: "docs/verification/2026-10-26-pi-140-compat.md"
313
+ trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
314
+ receipt: "docs/verification/2026-11-03-pi-150-compat.md"
315
315
  },
316
316
  "claude-implementer": {
317
317
  label: "Claude headless implementer",
@@ -361,23 +361,23 @@ var init_compat = __esm(() => {
361
361
  },
362
362
  agy: {
363
363
  label: "agy",
364
- verified: ["1.4.2", "1.4.7"],
364
+ verified: ["1.4.2", "1.4.7", "1.4.12"],
365
365
  line: { min: "1.4.0", below: "1.5.0" },
366
366
  gate: "contract",
367
367
  probe: ["agy", "--version"],
368
368
  parse: (out) => plain(out.trim()),
369
- trustNote: "Only 1.4.2 and 1.4.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
370
- receipt: "docs/verification/2026-10-26-agy-1.4.7.md"
369
+ trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
370
+ receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md"
371
371
  },
372
372
  qwen: {
373
373
  label: "Qwen Code",
374
- verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4"],
374
+ verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"],
375
375
  gate: "contract",
376
376
  probe: ["qwen", "--version"],
377
377
  config: "qwen_cmd",
378
378
  parse: (out) => plain(out.trim()),
379
- trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4; real model turns remain unverified. Another version may change those contracts.",
380
- receipt: "docs/verification/2026-10-31-qwen-0264-acp.md"
379
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
380
+ receipt: "docs/verification/2026-11-03-qwen-0265-acp.md"
381
381
  },
382
382
  "qwen-dual": {
383
383
  label: "Qwen Code dual-output TUI",
@@ -7288,6 +7288,101 @@ var init_pi = __esm(() => {
7288
7288
  currentReply;
7289
7289
  sessionId = "";
7290
7290
  sessionFile = "";
7291
+ observedTitle;
7292
+ titleRevision = -1;
7293
+ titleEpoch = 0;
7294
+ nativeTitleEpoch;
7295
+ get sessionTitle() {
7296
+ return this.acceptingTools && this.verifiedOwner() ? this.observedTitle : undefined;
7297
+ }
7298
+ get titleSession() {
7299
+ return this.nativeSession;
7300
+ }
7301
+ async setSessionTitle(title, onlyIfUnnamed, current) {
7302
+ const invalid = (error, mayHaveApplied = false) => ({ ok: false, reason: "invalid", error, ...mayHaveApplied ? { mayHaveApplied: true } : {} });
7303
+ if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean")
7304
+ return invalid("Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again.");
7305
+ if (this.stopping || this.state === "offline")
7306
+ return invalid("Pi session is offline or stopped; start or reconnect Pi and check ahub status before naming it.");
7307
+ const session = this.sessionId, generation = this.budgetGeneration, owner = this.sessionGeneration, epoch = this.titleEpoch;
7308
+ if (!owner || !this.verifiedOwner())
7309
+ return invalid("Pi native session owner is unverified; reconnect the retained Pi session and check ahub status before naming it.");
7310
+ if (!this.acceptingTools)
7311
+ return invalid("Pi session is detached or running a personal turn; reconnect its current hub session and check ahub status before naming it.");
7312
+ const valid = () => {
7313
+ try {
7314
+ return this.acceptingTools && this.verifiedOwner() && this.sessionGeneration === owner && this.sessionId === session && this.budgetGeneration === generation && this.titleEpoch === epoch && current?.() !== false;
7315
+ } catch {
7316
+ return false;
7317
+ }
7318
+ };
7319
+ if (!valid())
7320
+ return invalid("Pi session, turn or title admission changed before dispatch; check ahub status before retrying in the current public session.");
7321
+ let dispatched = false;
7322
+ try {
7323
+ const result = await this.sendTui({ type: "set_session_title", title, onlyIfUnnamed, sessionId: session, generation, ...this.nativeTitleEpoch ? { titleEpoch: this.nativeTitleEpoch } : {} }, valid, () => {
7324
+ dispatched = true;
7325
+ });
7326
+ if (!valid())
7327
+ return invalid("Pi session, turn or title admission changed after dispatch; the title may have been applied; check ahub status before retrying.", true);
7328
+ if (result?.ok === false && result.reason === "invalid") {
7329
+ if (result.cause === "session")
7330
+ return invalid("Pi native session changed before the title write; check ahub status and reconnect the current session before retrying.");
7331
+ if (result.cause === "generation")
7332
+ return invalid("Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying.");
7333
+ if (result.cause === "arguments")
7334
+ return invalid("Pi rejected invalid title command arguments; use a 1-80 character normalized title and retry in the current session.");
7335
+ if (result.cause === "native_unavailable")
7336
+ return invalid("Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying.");
7337
+ if (result.cause === "native_failed")
7338
+ return invalid("Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", true);
7339
+ if (result.cause === "native_readback")
7340
+ return invalid("Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying.", true);
7341
+ }
7342
+ if (result?.sessionId !== session || typeof result.sessionName !== "string" || this.nativeTitleEpoch !== undefined && result.titleEpoch !== this.nativeTitleEpoch)
7343
+ return invalid("Pi title acknowledgement did not confirm the current native slot; the title may have been applied; check ahub status before retrying.", true);
7344
+ this.observeTitle(result.sessionName, result.titleRevision, result.titleEpoch);
7345
+ if (result.ok === false && result.reason === "already_named")
7346
+ return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
7347
+ if (result.ok === true && result.sessionName === title)
7348
+ return { ok: true };
7349
+ return invalid("Pi native title write or readback was not confirmed; the title may have been applied; check ahub status before retrying.", true);
7350
+ } catch (error) {
7351
+ const timeout = error instanceof Error && error.message.includes("acknowledgement timed out");
7352
+ if (dispatched)
7353
+ return invalid(`Pi title ${timeout ? "acknowledgement timed out" : "bridge command failed after dispatch"}; the title may have been applied; check ahub status before retrying.`, true);
7354
+ return invalid(`Pi title ${timeout ? "command expired" : "admission changed or bridge failed"} before dispatch; check ahub status before retrying in the current session.`);
7355
+ }
7356
+ }
7357
+ observeTitle(value, revision, epoch) {
7358
+ if (this.nativeTitleEpoch !== undefined && epoch !== this.nativeTitleEpoch)
7359
+ return;
7360
+ if (typeof revision === "number" && Number.isSafeInteger(revision) && revision >= 0) {
7361
+ if (revision < this.titleRevision)
7362
+ return;
7363
+ this.titleRevision = revision;
7364
+ }
7365
+ if (value === undefined)
7366
+ value = "";
7367
+ if (typeof value !== "string")
7368
+ return;
7369
+ if (value)
7370
+ this.noteActivity();
7371
+ if (this.observedTitle !== value) {
7372
+ this.observedTitle = value;
7373
+ this.onDoing?.("title", value);
7374
+ }
7375
+ }
7376
+ observeSnapshotTitle(snapshot, before, beforeEpoch) {
7377
+ if (this.titleEpoch !== beforeEpoch || snapshot.titleEpoch !== undefined && snapshot.titleEpoch !== this.nativeTitleEpoch)
7378
+ return false;
7379
+ if (snapshot.titleRevision === undefined && this.titleRevision !== before)
7380
+ return false;
7381
+ if (typeof snapshot.titleRevision === "number" && snapshot.titleRevision < this.titleRevision)
7382
+ return false;
7383
+ this.observeTitle(snapshot.sessionName, snapshot.titleRevision, snapshot.titleEpoch ?? this.nativeTitleEpoch);
7384
+ return true;
7385
+ }
7291
7386
  executionBudgetTimer;
7292
7387
  executionAbort;
7293
7388
  budgetStops = new Map;
@@ -7406,12 +7501,15 @@ var init_pi = __esm(() => {
7406
7501
  throw new Error("Pi must settle before session capture");
7407
7502
  if (this.sessionFile && existsSync7(this.sessionFile))
7408
7503
  return this.recoveryMetadata();
7504
+ const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
7409
7505
  const snapshot = this.opts.mode === "headless" ? (await this.waitRpc("get_state", 15000)).data : await this.sendTui({ type: "get_session_state" });
7410
7506
  if (this.state !== "idle" || this.activeTools || snapshot?.sessionId !== this.sessionId || snapshot?.sessionFile !== this.sessionFile)
7411
7507
  throw new Error("Pi source session changed during capture");
7508
+ if (!this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore))
7509
+ throw new Error("Pi source session title changed during capture; retry with a fresh native session snapshot");
7412
7510
  const empty = this.opts.mode === "headless" ? snapshot.messageCount === 0 && snapshot.pendingMessageCount === 0 && snapshot.isStreaming === false && snapshot.isCompacting !== true && !snapshot.sessionName : snapshot.empty === true && snapshot.idle === true;
7413
7511
  if (!empty || this.activityObserved || this.persistedOnce)
7414
- throw new Error("Pi session history is not persisted; refusing to recreate it");
7512
+ throw new Error("Pi session history is not persisted; refusing to recreate it. Exchange one message or start a new session, then retry");
7415
7513
  this.emptyResumeVerified = true;
7416
7514
  const captured = this.recoveryMetadata();
7417
7515
  if (typeof captured.sessionId === "string" && !captured.sessionFile)
@@ -7598,6 +7696,10 @@ var init_pi = __esm(() => {
7598
7696
  await this.preflight();
7599
7697
  this.terminalOwnerObservation = undefined;
7600
7698
  this.stopping = false;
7699
+ this.observedTitle = undefined;
7700
+ this.titleRevision = -1;
7701
+ this.nativeTitleEpoch = undefined;
7702
+ this.onDoing?.("reset");
7601
7703
  this.rosterDetached = false;
7602
7704
  this.rosterUnownedRun = false;
7603
7705
  this.requestedStopReason = undefined;
@@ -7698,6 +7800,7 @@ var init_pi = __esm(() => {
7698
7800
  this.fail(new Error(`pi exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
7699
7801
  this.reportExit(exit);
7700
7802
  });
7803
+ const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
7701
7804
  const state = await this.waitRpc("get_state", 15000);
7702
7805
  if (state.success !== true || typeof state.data?.sessionId !== "string" || typeof state.data?.sessionFile !== "string" || !state.data.sessionId || !state.data.sessionFile)
7703
7806
  throw new Error("Pi get_state did not prove session identity");
@@ -7718,6 +7821,7 @@ var init_pi = __esm(() => {
7718
7821
  this.persistedOnce = existsSync7(this.sessionFile);
7719
7822
  if (this.opts.sessionId && this.sessionId !== this.opts.sessionId || this.opts.sessionFile && this.sessionFile !== this.opts.sessionFile)
7720
7823
  throw new Error("Pi startup session identity mismatch");
7824
+ this.observeSnapshotTitle(state.data, titleRevisionBefore, titleEpochBefore);
7721
7825
  this.started = true;
7722
7826
  this.setState("idle");
7723
7827
  }
@@ -7807,11 +7911,13 @@ var init_pi = __esm(() => {
7807
7911
  if (this.pendingPromptId)
7808
7912
  throw new Error("Pi prompt admission is still pending; wait before reverifying the retained terminal");
7809
7913
  const epoch = this.rosterEpoch, lifecycle = this.rosterLifecycleRevision, session = this.sessionId, file = this.sessionFile;
7914
+ const titleRevisionBefore = this.titleRevision, titleEpochBefore = this.titleEpoch;
7810
7915
  const snapshot = await this.sendTui({ type: "get_session_state" });
7811
7916
  if (epoch !== this.rosterEpoch || this.stopping || !this.verifiedOwner() || this.activeTools || snapshot?.sessionId !== session || snapshot?.sessionFile !== file || typeof snapshot?.idle !== "boolean")
7812
7917
  throw new Error("Pi roster return could not verify the current native session");
7813
7918
  if (lifecycle !== this.rosterLifecycleRevision)
7814
7919
  throw new Error("Pi native activity changed during roster return; retry with a fresh session snapshot");
7920
+ this.observeSnapshotTitle(snapshot, titleRevisionBefore, titleEpochBefore);
7815
7921
  this.rosterDetached = false;
7816
7922
  this.hubPromptId = undefined;
7817
7923
  this.hubPromptGeneration = undefined;
@@ -7962,12 +8068,14 @@ var init_pi = __esm(() => {
7962
8068
  attempted = true;
7963
8069
  if (deliveryId)
7964
8070
  this.activeDeliveryIds.add(deliveryId);
8071
+ const message = [envs.some((env) => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join(`
8072
+ `);
7965
8073
  if (this.opts.mode === "tui") {
7966
- const pending = this.sendTui({ type: "prompt", message: renderDigest(envs, true) });
8074
+ const pending = this.sendTui({ type: "prompt", message });
7967
8075
  promptId = this.pendingPromptId;
7968
8076
  await pending;
7969
8077
  } else
7970
- await this.sendRpc({ type: "prompt", message: renderDigest(envs, true) });
8078
+ await this.sendRpc({ type: "prompt", message });
7971
8079
  if (promptId !== undefined && promptId !== this.pendingPromptId)
7972
8080
  return;
7973
8081
  if (this.fencedPrompt && this.rosterDetached) {
@@ -8144,6 +8252,10 @@ var init_pi = __esm(() => {
8144
8252
  this.opts.log?.(`[${this.id}] Pi session identity mismatch`);
8145
8253
  return;
8146
8254
  }
8255
+ this.titleRevision = -1;
8256
+ this.titleEpoch++;
8257
+ this.nativeTitleEpoch = typeof event.titleEpoch === "string" && event.titleEpoch ? event.titleEpoch : undefined;
8258
+ this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
8147
8259
  this.rememberTerminalOwner();
8148
8260
  this.startOwnerMonitor();
8149
8261
  if (this.opts.mode === "tui") {
@@ -8151,7 +8263,13 @@ var init_pi = __esm(() => {
8151
8263
  this.setState("idle");
8152
8264
  }
8153
8265
  }
8266
+ if ((event.type === "session_title" || event.type === "agent_start" && Object.hasOwn(event, "sessionName")) && event.sessionId === this.sessionId && this.verifiedOwner())
8267
+ this.observeTitle(event.sessionName, event.titleRevision, event.titleEpoch);
8154
8268
  if (event.type === "session_shutdown") {
8269
+ this.observedTitle = undefined;
8270
+ this.titleRevision = -1;
8271
+ this.nativeTitleEpoch = undefined;
8272
+ this.onDoing?.("reset");
8155
8273
  const exit = this.exitMetadata("session_shutdown");
8156
8274
  this.shutdownExit ??= exit;
8157
8275
  this.stopping = true;
@@ -8291,9 +8409,19 @@ var init_pi = __esm(() => {
8291
8409
  }
8292
8410
  }
8293
8411
  nextTuiCommand(timeout) {
8294
- const next = this.tuiQueue.shift();
8295
- if (next)
8296
- return Promise.resolve(next);
8412
+ let next = this.tuiQueue.shift();
8413
+ while (next) {
8414
+ const pending = this.tuiCommands.get(String(next.id));
8415
+ if (pending && pending.current?.() !== false) {
8416
+ pending.onDispatch?.();
8417
+ return Promise.resolve(next);
8418
+ }
8419
+ if (pending) {
8420
+ this.tuiCommands.delete(String(next.id));
8421
+ pending.reject(new Error("Pi session title admission changed before dispatch"));
8422
+ }
8423
+ next = this.tuiQueue.shift();
8424
+ }
8297
8425
  return new Promise((resolvePromise) => {
8298
8426
  const entry = { resolve: resolvePromise, timer: setTimeout(() => {
8299
8427
  const i = this.tuiWaiters.indexOf(entry);
@@ -8304,7 +8432,9 @@ var init_pi = __esm(() => {
8304
8432
  this.tuiWaiters.push(entry);
8305
8433
  });
8306
8434
  }
8307
- sendTui(command) {
8435
+ sendTui(command, current, onDispatch) {
8436
+ if (current?.() === false)
8437
+ return Promise.reject(new Error("Pi session title admission changed before dispatch"));
8308
8438
  const id = `ahub-${++this.seq}`;
8309
8439
  const fenced = this.fencedPrompt && command.type === "prompt";
8310
8440
  if (fenced) {
@@ -8319,7 +8449,7 @@ var init_pi = __esm(() => {
8319
8449
  this.tuiQueue = this.tuiQueue.filter((queued) => queued.id !== id);
8320
8450
  reject(new Error(`Pi TUI ${String(command.type)} acknowledgement timed out`));
8321
8451
  }, 30000);
8322
- this.tuiCommands.set(id, { command: wire, resolve: (value) => {
8452
+ this.tuiCommands.set(id, { command: wire, current, onDispatch, resolve: (value) => {
8323
8453
  clearTimeout(timer);
8324
8454
  resolvePromise(value);
8325
8455
  }, reject: (error) => {
@@ -8329,6 +8459,7 @@ var init_pi = __esm(() => {
8329
8459
  const waiter = this.tuiWaiters.shift();
8330
8460
  if (waiter) {
8331
8461
  clearTimeout(waiter.timer);
8462
+ onDispatch?.();
8332
8463
  waiter.resolve(wire);
8333
8464
  } else
8334
8465
  this.tuiQueue.push(wire);
@@ -12502,7 +12633,7 @@ var package_default;
12502
12633
  var init_package = __esm(() => {
12503
12634
  package_default = {
12504
12635
  name: "@staix/agent-hub",
12505
- version: "0.29.0",
12636
+ version: "0.30.0",
12506
12637
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
12507
12638
  license: "MIT",
12508
12639
  type: "module",
@@ -12519,7 +12650,7 @@ var init_package = __esm(() => {
12519
12650
  bun: ">=1.4.2"
12520
12651
  },
12521
12652
  dependencies: {
12522
- "@staix/agent-mem": "0.20.0"
12653
+ "@staix/agent-mem": "0.20.1"
12523
12654
  },
12524
12655
  devDependencies: {
12525
12656
  "@modelcontextprotocol/sdk": "^1.27.1",
@@ -12746,7 +12877,7 @@ function profileTaskTools(profile = "dev", stages, criteria = false) {
12746
12877
  if (customProfile && tool.name === "hub_task_propose")
12747
12878
  properties.class = { type: "string", enum: [...new Set([...Object.keys(data.classes), ...PROFILE_CLASS_NAMES.filter((v) => projectProfile("dev").classes[v])])] };
12748
12879
  if (customProfile && properties.plan && tool.name !== "hub_task_accept" && tool.name !== "hub_experiment_record")
12749
- properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
12880
+ properties.plan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "registered src:<16 hex> source ids; list available ids with ahub source list" : "short claim ids to verify")])) } };
12750
12881
  if (customProfile && tool.name === "hub_task_done" && data.name !== "dev") {
12751
12882
  const summaryProp = properties.summary;
12752
12883
  properties.summary = {
@@ -12756,7 +12887,7 @@ function profileTaskTools(profile = "dev", stages, criteria = false) {
12756
12887
  }
12757
12888
  if (customProfile && tool.name === "hub_backlog_split") {
12758
12889
  const parts = properties.parts;
12759
- const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "citation ids, DOI or source URL ids" : "short claim ids to verify")])) } };
12890
+ const selectedPlan = { ...plan, properties: { ...Object.fromEntries(data.plan_keys.map((key) => [key, plan.properties[key] ?? list(key === "sections" ? "document heading anchors, such as report.md#methods" : key === "sources" ? "registered src:<16 hex> source ids; list available ids with ahub source list" : "short claim ids to verify")])) } };
12760
12891
  properties.parts = { ...parts, items: { ...parts.items, properties: { ...parts.items.properties, plan: selectedPlan } } };
12761
12892
  }
12762
12893
  if (criteria && properties.plan && tool.name !== "hub_experiment_record") {
@@ -12784,7 +12915,7 @@ var IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MESSAGE_
12784
12915
  name,
12785
12916
  description,
12786
12917
  inputSchema: { type: "object", properties: name === "hub_review" || name === "hub_task_propose" ? { ...properties, request_id: toolWriteRequestId } : properties, required, additionalProperties: false }
12787
- }), STUDY_TOOLS, HANDOFF_TOOLS, BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, TASK_TOOLS, PLANNER_REVIEW_TOOLS, TASK_TOOL_NAMES, CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, READ_ONLY_PROJECT_TOOLS, ROLE_TEXT, DEFAULT_ROLES;
12918
+ }), STUDY_TOOLS, HANDOFF_TOOLS, BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, TASK_TOOLS, PLANNER_REVIEW_TOOLS, SESSION_TITLE_TOOL, TASK_TOOL_NAMES, CONDUCTOR_TOOLS, CONDUCTOR_TOOL_NAMES, READ_ONLY_PROJECT_TOOLS, ROLE_TEXT, DEFAULT_ROLES;
12788
12919
  var init_hub_tools = __esm(() => {
12789
12920
  init_test_integrity();
12790
12921
  init_sdlc();
@@ -12847,7 +12978,7 @@ var init_hub_tools = __esm(() => {
12847
12978
  },
12848
12979
  additionalProperties: false
12849
12980
  };
12850
- acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("citation ids, DOI or source URL ids"), claims: list("short claim ids to verify") } };
12981
+ acceptPlan = { ...plan, properties: { ...plan.properties, sections: list("document heading anchors, such as report.md#methods"), sources: list("registered src:<16 hex> source ids; list available ids with ahub source list"), claims: list("short claim ids to verify") } };
12851
12982
  STUDY_TOOLS = new Set(["hub_study_draft", "hub_study_revise", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff"]);
12852
12983
  HANDOFF_TOOLS = new Set(["hub_task_handoff", "hub_task_handoffs"]);
12853
12984
  BOUNDED_TASK_TOOLS = new Set(["hub_task_spec", "hub_check_start", "hub_check_result", "hub_check_cancel"]);
@@ -12916,10 +13047,11 @@ var init_hub_tools = __esm(() => {
12916
13047
  tool2("hub_plan_review", "Record a planner review against the task planning revision and host-read issue edit time. Configured planner or person only; never accepts caller issue timestamps.", { id: id2, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 }, ...costPlanProperties }, ["id", "revision", "reason"]),
12917
13048
  tool2("hub_sdlc_costs", "Read bounded review and CI cost metrics for linked merged PRs. Planner/person only; metadata only, no board writes.", { repo: str2 }),
12918
13049
  tool2("hub_sdlc_metrics", "Read SDLC metrics from board history: time from acceptance to plan, first review and merge, first-pass approval and rework rounds, first-pass CI and same-head reruns, stale-head re-reviews, stalls, and the control band tiers when sdlc.toml sets [bands]. Planner/person only; read-only, no model and no GitHub issue.", { since: str2 }),
12919
- tool2("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Refuses execution/branch/commit evidence; planner or person only.", { id: id2, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 } }, ["id", "revision", "reason"])
13050
+ tool2("hub_backlog_return_untouched", "Return accepted but untouched work to the backlog with its history and planning retained. Optional fields.reserved changes the reservation atomically before capacity is released; null clears it. Refuses execution/branch/commit evidence; planner or person only, and a person's reservation requires the person.", { id: id2, revision: { type: "integer", minimum: 0 }, reason: { ...str2, maxLength: 1000 }, fields: { type: "object", properties: { reserved: { type: ["string", "null"], minLength: 1, maxLength: 32 } }, additionalProperties: false } }, ["id", "revision", "reason"])
12920
13051
  ];
12921
13052
  TASK_TOOLS.push(...PLANNER_REVIEW_TOOLS);
12922
- TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
13053
+ SESSION_TITLE_TOOL = tool2("hub_set_title", "Name your own session once the task is clear. Only an unnamed session accepts the first agent name; the person may replace it. Private contexts refuse the write. No task, branch or instance is renamed.", { title: { type: "string", description: "1-80 characters after doingText normalization (one line, controls removed, surrounding whitespace trimmed)" } }, ["title"]);
13054
+ TASK_TOOL_NAMES = new Set([...TASK_TOOLS.map((t) => t.name), SESSION_TITLE_TOOL.name]);
12923
13055
  CONDUCTOR_TOOLS = [
12924
13056
  tool2("hub_status", "Inspect public team state, holds, quota windows, task counts and pending approval peer/tool/age. Only the configured conductor may use this tool.", {}),
12925
13057
  tool2("hub_task_show", "Read a task's public view and history, its done summary and check result included. PII tasks remain stubs. Only the conductor, the configured planner and the task's current owner and reviewer may use this tool.", { id: id2 }, ["id"]),
@@ -13030,6 +13162,32 @@ var init_local_worker = __esm(() => {
13030
13162
  deliveryOnly = true;
13031
13163
  hubOwned = true;
13032
13164
  history = [];
13165
+ title = "";
13166
+ get sessionTitle() {
13167
+ return this.title;
13168
+ }
13169
+ get titleSession() {
13170
+ return this.sessionId;
13171
+ }
13172
+ async setSessionTitle(title, onlyIfUnnamed, current) {
13173
+ if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean")
13174
+ return { ok: false, reason: "invalid", error: "Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again." };
13175
+ if (this.state === "offline")
13176
+ return { ok: false, reason: "invalid", error: "Local worker session is offline; start the peer and check ahub status before naming it." };
13177
+ let admitted = false;
13178
+ try {
13179
+ admitted = current?.() !== false;
13180
+ } catch {}
13181
+ if (!admitted)
13182
+ return { ok: false, reason: "invalid", error: "Session title admission changed before the write; check ahub status and retry only in the current public session." };
13183
+ if (onlyIfUnnamed && this.title)
13184
+ return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
13185
+ this.title = title;
13186
+ try {
13187
+ this.onDoing?.("title", title);
13188
+ } catch {}
13189
+ return { ok: true };
13190
+ }
13033
13191
  sessionId = `agent-hub-local-${randomUUID7()}`;
13034
13192
  routes;
13035
13193
  warnedLegacyRoutes = new Set;
@@ -13060,7 +13218,7 @@ var init_local_worker = __esm(() => {
13060
13218
  throw new Error("route belongs to an ended turn");
13061
13219
  if (signal.aborted)
13062
13220
  throw new Error("turn cancelled before model request");
13063
- const tools = judge ? undefined : [...this.toolSchemas(), ...this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter((tool) => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : []];
13221
+ const tools = judge ? undefined : [...this.toolSchemas(), ...this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter((tool) => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : []];
13064
13222
  const result = await this.opts.omni.chat({ model, messages, ...tools ? { tools } : {}, ...judge ? { max_tokens: maxTokens ?? 2048 } : {} }, { signal, ...pii ? { onCampusOnly: true } : {} });
13065
13223
  this.recordUsage(result, model, task);
13066
13224
  if (generation !== this.turn)
@@ -13113,7 +13271,8 @@ var init_local_worker = __esm(() => {
13113
13271
  this.setState("busy");
13114
13272
  if (deliveryId)
13115
13273
  this.delivery({ id: deliveryId, state: "accepted" });
13116
- const msgs = [{ role: "user", content: renderDigest(envs, true) }];
13274
+ const msgs = [{ role: "user", content: [envs.some((env) => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join(`
13275
+ `) }];
13117
13276
  const progress = { sideEffects: 0, last: "" };
13118
13277
  const requestedPolicy = this.opts.turnPolicy?.(envs);
13119
13278
  const policy = envs.some((env) => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
@@ -13250,7 +13409,7 @@ var init_local_worker = __esm(() => {
13250
13409
  this.abort?.abort();
13251
13410
  throw new ApprovalWaitStop(this.approvalStopReason);
13252
13411
  }
13253
- if (!remoteTurn)
13412
+ if (!remoteTurn && name !== "hub_set_title")
13254
13413
  this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
13255
13414
  if (name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name))
13256
13415
  capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
@@ -13385,7 +13544,7 @@ var init_local_worker = __esm(() => {
13385
13544
  }
13386
13545
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
13387
13546
  const task = policy?.task === undefined ? undefined : Number(policy.task);
13388
- const tools = [...this.toolSchemas(), ...this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter((tool) => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : []];
13547
+ const tools = [...this.toolSchemas(), ...this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter((tool) => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : []];
13389
13548
  const signal = this.abort.signal;
13390
13549
  const messages = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
13391
13550
  const routed = await this.hubCall(route, messages, policy, signal);
@@ -15912,6 +16071,8 @@ var init_builtin_metadata = __esm(() => {
15912
16071
  projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
15913
16072
  api: 1,
15914
16073
  kind: "pi",
16074
+ title: "hub",
16075
+ titlePersistence: "native",
15915
16076
  enabledSwitchStartOnly: true,
15916
16077
  displayName: "Pi",
15917
16078
  family: "pi",
@@ -15936,6 +16097,7 @@ var init_builtin_metadata = __esm(() => {
15936
16097
  projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
15937
16098
  api: 1,
15938
16099
  kind: "local",
16100
+ title: "hub",
15939
16101
  displayName: "Local worker",
15940
16102
  family: "local",
15941
16103
  transport: "hub-native",
@@ -16543,6 +16705,8 @@ function assertNativeCapabilityScope(definition) {
16543
16705
  return;
16544
16706
  const declared = (block, key) => block !== undefined && (key in block);
16545
16707
  const restricted = [
16708
+ ["title", definition.title !== undefined],
16709
+ ["titlePersistence", definition.titlePersistence !== undefined],
16546
16710
  ["start.acpCommand", declared(definition.start, "acpCommand")],
16547
16711
  ["attach.admission", declared(definition.attach, "admission")],
16548
16712
  ["budget.resetSoftTokensOnResume", declared(definition.budget, "resetSoftTokensOnResume")],
@@ -29,11 +29,11 @@ export interface CompatPolicy {
29
29
 
30
30
  const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
31
31
  export const COMPAT = {
32
- // Receipt: docs/verification/2026-10-26-pi-140-compat.md
33
- pi: { label: "Pi", verified: ["1.4.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
32
+ // Receipts: docs/verification/2026-10-26-pi-140-compat.md (1.4.0); docs/verification/2026-11-03-pi-150-compat.md (1.5.0)
33
+ pi: { label: "Pi", verified: ["1.4.0", "1.5.0"], gate: "contract", probe: ["pi", "--version"], config: "pi.cmd",
34
34
  parse: out => plain(out.trim()),
35
- trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
- receipt: "docs/verification/2026-10-26-pi-140-compat.md" },
35
+ trustNote: "The model-free extension load, RPC session identity and managed tool receipt path were checked on 1.4.0 and 1.5.0. Permissions remain host-owned; account-backed inference and another version's extension contract are unverified.",
36
+ receipt: "docs/verification/2026-11-03-pi-150-compat.md" },
37
37
  // Transport receipts: docs/verification/2026-10-23-headless-claude-permissions.md; 2.1.310: docs/verification/2026-10-26-claude-implementer-2.1.310.md; 2.1.314 (transport and wrapper): docs/verification/2026-10-31-claude-implementer-2.1.314.md; 2.1.316: docs/verification/2026-10-31-claude-implementer-2.1.316.md; host-wrapper receipt below repeats with them.
38
38
  "claude-implementer": { label: "Claude headless implementer", verified: ["2.1.309", "2.1.310", "2.1.314", "2.1.316"], gate: "contract", probe: ["claude", "--version"],
39
39
  parse: out => plain(out.trim().replace(/ \(Claude Code\)$/, "")),
@@ -53,14 +53,14 @@ export const COMPAT = {
53
53
  parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
54
54
  trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and 0.99.1 and steering on 0.99.0 and 0.99.1; an untested version may change them.",
55
55
  receipt: "docs/verification/2026-10-26-jcode-0.99.1.md" },
56
- agy: { label: "agy", verified: ["1.4.2", "1.4.7"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
56
+ agy: { label: "agy", verified: ["1.4.2", "1.4.7", "1.4.12"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
57
57
  parse: out => plain(out.trim()),
58
- trustNote: "Only 1.4.2 and 1.4.7 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
- receipt: "docs/verification/2026-10-26-agy-1.4.7.md" },
60
- qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
58
+ trustNote: "Only 1.4.2, 1.4.7 and 1.4.12 are T0-verified; the plugin MCP and hook contract of another version is unverified.",
59
+ receipt: "docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md" },
60
+ qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0", "0.26.1", "0.26.4", "0.26.5"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
61
61
  parse: out => plain(out.trim()),
62
- trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4; real model turns remain unverified. Another version may change those contracts.",
63
- receipt: "docs/verification/2026-10-31-qwen-0264-acp.md" },
62
+ trustNote: "Native ACP identity, mode ids and permission callbacks were re-checked without inference on 0.26.4 and 0.26.5; real model turns remain unverified. Another version may change those contracts.",
63
+ receipt: "docs/verification/2026-11-03-qwen-0265-acp.md" },
64
64
  // Receipt: docs/verification/2026-10-25-qwen-dual-output-t0.md. The dual-output contract (input file, event file, can_use_tool control requests) is its own entry:
65
65
  // the ACP receipts of the headless `qwen` entry say nothing about it, so that entry is not widened and this one carries no `line`.
66
66
  "qwen-dual": { label: "Qwen Code dual-output TUI", verified: ["0.26.1"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
@@ -119,7 +119,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
119
119
  },
120
120
  {
121
121
  projection: { roleRank: 4, startRank: 0, helpRank: 6, plannerEligible: false },
122
- api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
122
+ api: 1, kind: "pi", title: "hub", titlePersistence: "native", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
123
123
  roles: implementerRoles, defaultOnRoster: false, trust: { onPrem: false, hubSandboxed: true },
124
124
  start: { create: createPi, exclusiveStartRefusal: "Pi start is in progress; inspect status before retrying", refreshTools: true, explicitHeadlessMode: true, daemonPort: "pi-rpc", forms: dualForms, defaultForm: "tui", conductorStartable: true, upStart: { allowed: true }, autoStart: "pi.auto_start", conductorStartArgs: piConductorStartArgs, hubSwitches: [], ownedFlags: [] },
125
125
  identity: { mcp: "env" }, instances: { mode: "headless" },
@@ -132,7 +132,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
132
132
  },
133
133
  {
134
134
  projection: { roleRank: 3, helpRank: 7, plannerEligible: false },
135
- api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
135
+ api: 1, kind: "local", title: "hub", displayName: "Local worker", family: "local", transport: "hub-native",
136
136
  roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: true, hubSandboxed: true },
137
137
  start: { create: createLocal, reconfigureWhileAttached: true, refreshTools: true, daemonPort: "local-worker", forms: headlessForms, defaultForm: "headless", conductorStartable: true, upStart: { allowed: true }, hubSwitches: [], ownedFlags: [] },
138
138
  identity: { mcp: "env" }, instances: { mode: "headless" },
@@ -5,8 +5,8 @@ import type { StaySwitchPolicy } from "../models/route/stage.ts";
5
5
  import type { ToolObservation } from "../models/route/signals.ts";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { MAX_HOP, remoteEnvelope, renderDigest, replyAudience, replyParent, STANDING_INSTRUCTION, USER, type Envelope, type EnvelopeOpts, type PeerId } from "../hub/envelope.ts";
8
- import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools } from "../hub/hub-tools.ts";
9
- import { BasePeer } from "../hub/peers.ts";
8
+ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, profileTaskTools, SESSION_TITLE_TOOL } from "../hub/hub-tools.ts";
9
+ import { BasePeer, type SessionTitleResult } from "../hub/peers.ts";
10
10
  import type { PublishResult } from "../hub/bus.ts";
11
11
  import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
12
12
  import type { DocReadPort } from "../local/doc-read.ts";
@@ -22,6 +22,8 @@ export interface LocalOptions {
22
22
  profile?: import("../hub/profiles.ts").ProfileSelection;
23
23
  sdlcStages?: readonly string[];
24
24
  sdlcCriteria?: boolean;
25
+ /** Host-owned conditional instruction, sampled once per delivered turn. */
26
+ titlePreface?: () => string | undefined;
25
27
  cwd: string;
26
28
  omni: OmniRoute;
27
29
  /** In-process hub route id; legacy routes use the fixed model. */
@@ -82,6 +84,20 @@ export class LocalPeer extends BasePeer {
82
84
  readonly deliveryOnly: boolean = true;
83
85
  readonly hubOwned: boolean = true;
84
86
  private readonly history: ChatMessage[] = [];
87
+ private title = "";
88
+ get sessionTitle(): string { return this.title; }
89
+ get titleSession(): string { return this.sessionId; }
90
+ async setSessionTitle(title: string, onlyIfUnnamed: boolean, current?: () => boolean): Promise<SessionTitleResult> {
91
+ if (typeof title !== "string" || !title.trim() || [...title].length > 80 || typeof onlyIfUnnamed !== "boolean") return { ok: false, reason: "invalid", error: "Session title must contain 1-80 normalized characters and use a conditional-write flag; correct the title request and try again." };
92
+ if (this.state === "offline") return { ok: false, reason: "invalid", error: "Local worker session is offline; start the peer and check ahub status before naming it." };
93
+ let admitted = false;
94
+ try { admitted = current?.() !== false; } catch { /* host admission failure is a refusal */ }
95
+ if (!admitted) return { ok: false, reason: "invalid", error: "Session title admission changed before the write; check ahub status and retry only in the current public session." };
96
+ if (onlyIfUnnamed && this.title) return { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title." };
97
+ this.title = title;
98
+ try { this.onDoing?.("title", title); } catch { /* observation failure cannot undo the saved local title */ }
99
+ return { ok: true };
100
+ }
85
101
  private readonly sessionId = `agent-hub-local-${randomUUID()}`;
86
102
  private readonly routes: HubRouteRuntime;
87
103
  private readonly warnedLegacyRoutes = new Set<string>();
@@ -113,7 +129,7 @@ export class LocalPeer extends BasePeer {
113
129
  await this.requireBudget(envs, "model_calls");
114
130
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
115
131
  if (signal.aborted) throw new Error("turn cancelled before model request");
116
- const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
132
+ const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
117
133
  const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
118
134
  this.recordUsage(result, model, task);
119
135
  if (generation !== this.turn) throw new Error("route belongs to an ended turn");
@@ -165,7 +181,7 @@ export class LocalPeer extends BasePeer {
165
181
  if (deliveryId) this.delivery({ id: deliveryId, state: "accepted" });
166
182
  // The turn works on its own message list and joins the history only as a whole, so a failed or aborted turn can
167
183
  // never leave a tool call without its result (strict servers reject that history forever after).
168
- const msgs: ChatMessage[] = [{ role: "user", content: renderDigest(envs, true) }];
184
+ const msgs: ChatMessage[] = [{ role: "user", content: [envs.some(env => env.private || remoteEnvelope(env)) ? undefined : this.opts.titlePreface?.(), renderDigest(envs, true)].filter(Boolean).join("\n") }];
169
185
  const progress = { sideEffects: 0, last: "" };
170
186
  const requestedPolicy = this.opts.turnPolicy?.(envs);
171
187
  const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
@@ -288,7 +304,7 @@ export class LocalPeer extends BasePeer {
288
304
  const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
289
305
  msgs.push(result); results.push(result);
290
306
  if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
291
- if (!remoteTurn) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
307
+ if (!remoteTurn && name !== "hub_set_title") this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
292
308
  // Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
293
309
  // doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
294
310
  // web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
@@ -401,7 +417,7 @@ export class LocalPeer extends BasePeer {
401
417
  }
402
418
  const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
403
419
  const task = policy?.task === undefined ? undefined : Number(policy.task);
404
- const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
420
+ const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].concat(SESSION_TITLE_TOOL).map(asFunction) : [])];
405
421
  const signal = this.abort!.signal;
406
422
  const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
407
423
  const routed = await this.hubCall(route, messages, policy, signal);