@staix/agent-hub 0.29.0 → 0.31.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 (154) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -7
  3. package/docs/agent-notes/README.md +13 -0
  4. package/docs/agent-notes/adapters.md +7 -3
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +10 -4
  7. package/docs/agent-notes/daemon.md +16 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +10 -4
  10. package/docs/agent-notes/tests.md +8 -4
  11. package/docs/design/adapters.md +108 -0
  12. package/docs/design/agent-hub-architecture.md +58 -0
  13. package/docs/design/bus.md +147 -1
  14. package/docs/design/task-board.md +100 -0
  15. package/docs/dogfood.md +29 -8
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +410 -86
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +25 -5
  21. package/docs/sdlc.md +1 -1
  22. package/docs/security.md +182 -62
  23. package/docs/smoke.md +13 -2
  24. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  25. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  26. package/docs/specs/2026-10-12-office-view-design.md +86 -3
  27. package/docs/verification/2026-11-03-0.30.0.md +54 -0
  28. package/docs/verification/2026-11-03-agy-1.4.12-launcher-nonce.md +42 -0
  29. package/docs/verification/2026-11-03-agy-1.4.12.md +90 -0
  30. package/docs/verification/2026-11-03-agy-tui-close.md +49 -0
  31. package/docs/verification/2026-11-03-grok-1.0.62-t0.md +150 -0
  32. package/docs/verification/2026-11-03-pi-150-compat.md +40 -0
  33. package/docs/verification/2026-11-03-pi-title-api.md +26 -0
  34. package/docs/verification/2026-11-03-qwen-0265-acp.md +55 -0
  35. package/docs/verification/2026-11-03-qwen-0265-dual-output.md +40 -0
  36. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  37. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  38. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  39. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  40. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  41. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  42. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  43. package/docs/verification/dogfood/2026-11-03.md +3095 -0
  44. package/docs/verification/pi-150-model-free.ts +57 -0
  45. package/docs/verification/pi-150-native.mjs +21 -0
  46. package/docs/verification/pi-150-title-api.mjs +31 -0
  47. package/docs/verified.json +14 -14
  48. package/package.json +3 -4
  49. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  50. package/plugins/agent-hub/server.js +4565 -3617
  51. package/src/adapters/acp.ts +5 -4
  52. package/src/adapters/channel-peer.ts +72 -3
  53. package/src/adapters/claude-channel-evidence.ts +52 -0
  54. package/src/adapters/claude-channel.ts +61 -18
  55. package/src/adapters/claude-implementer.ts +29 -7
  56. package/src/adapters/claude-print-child.ts +12 -2
  57. package/src/adapters/codex-appserver.ts +13 -12
  58. package/src/adapters/compat.ts +13 -13
  59. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  60. package/src/adapters/definitions/qwen.ts +1 -0
  61. package/src/adapters/local-worker.ts +82 -23
  62. package/src/adapters/pi.ts +140 -16
  63. package/src/adapters/registry-types.ts +16 -0
  64. package/src/adapters/registry.ts +4 -0
  65. package/src/adapters/reviewer-print.ts +50 -11
  66. package/src/broker/server.ts +44 -13
  67. package/src/broker/store.ts +38 -8
  68. package/src/cli/answer-args.ts +50 -0
  69. package/src/cli/attention.ts +17 -2
  70. package/src/cli/autonomy.ts +66 -1
  71. package/src/cli/backlog-return-args.ts +37 -0
  72. package/src/cli/console-question.ts +110 -0
  73. package/src/cli/console-state.ts +82 -17
  74. package/src/cli/console.ts +54 -1
  75. package/src/cli/handoff-args.ts +15 -5
  76. package/src/cli/handoff-output.ts +15 -2
  77. package/src/cli/help.ts +10 -5
  78. package/src/cli/identity.ts +21 -4
  79. package/src/cli/main.ts +167 -16
  80. package/src/cli/memory.ts +107 -3
  81. package/src/cli/output.ts +29 -6
  82. package/src/cli/pet.ts +4 -4
  83. package/src/cli/results-args.ts +30 -0
  84. package/src/cli/runtime-version-warning.ts +50 -0
  85. package/src/cli/status-lines.ts +41 -1
  86. package/src/cli/tail-render.ts +1 -0
  87. package/src/cli/upgrade-interactive.ts +14 -2
  88. package/src/cli/worktree-tidy.ts +158 -0
  89. package/src/cli/worktrees-output.ts +43 -0
  90. package/src/hub/action-class.ts +9 -1
  91. package/src/hub/board.ts +396 -20
  92. package/src/hub/bus.ts +497 -57
  93. package/src/hub/completion-detail.ts +50 -0
  94. package/src/hub/completions-store.ts +168 -0
  95. package/src/hub/completions.ts +120 -0
  96. package/src/hub/conductor.ts +4 -3
  97. package/src/hub/config-apply.ts +8 -5
  98. package/src/hub/config-reload.ts +10 -1
  99. package/src/hub/control-client.ts +40 -4
  100. package/src/hub/daemon.ts +1795 -142
  101. package/src/hub/delivery-failure.ts +14 -0
  102. package/src/hub/delivery-hints.ts +14 -4
  103. package/src/hub/delivery-journal.ts +751 -42
  104. package/src/hub/delivery-superseded.ts +4 -1
  105. package/src/hub/dogfood-read.ts +62 -9
  106. package/src/hub/envelope.ts +3 -3
  107. package/src/hub/events.ts +6 -0
  108. package/src/hub/handoff.ts +145 -6
  109. package/src/hub/hub-tools.ts +66 -11
  110. package/src/hub/manager.ts +67 -3
  111. package/src/hub/memory-policy.ts +44 -1
  112. package/src/hub/memory-sync-policy.ts +109 -0
  113. package/src/hub/memory-sync-state.ts +145 -0
  114. package/src/hub/no-writes.ts +1 -1
  115. package/src/hub/operational-settings.ts +11 -2
  116. package/src/hub/operational-view.ts +12 -1
  117. package/src/hub/peer-doing.ts +33 -3
  118. package/src/hub/peers.ts +32 -2
  119. package/src/hub/planning.ts +6 -1
  120. package/src/hub/question-desk.ts +201 -0
  121. package/src/hub/question-detach.ts +6 -0
  122. package/src/hub/questions.ts +260 -0
  123. package/src/hub/research-evidence.ts +106 -0
  124. package/src/hub/restart.ts +14 -0
  125. package/src/hub/review-anchor.ts +181 -0
  126. package/src/hub/review-brief.ts +13 -0
  127. package/src/hub/review-comments.ts +115 -0
  128. package/src/hub/review-findings.ts +7 -2
  129. package/src/hub/review-material.ts +76 -0
  130. package/src/hub/routing.ts +3 -1
  131. package/src/hub/settings.ts +3 -0
  132. package/src/hub/task-sweep.ts +2 -0
  133. package/src/hub/tasks.ts +607 -71
  134. package/src/hub/ui.ts +2 -0
  135. package/src/hub/worktree-prs.ts +100 -0
  136. package/src/hub/worktree-shelf.ts +246 -0
  137. package/src/memory/capture.ts +5 -2
  138. package/src/pet/config.ts +25 -13
  139. package/src/pi/extension.ts +43 -3
  140. package/src/rooms/link.ts +209 -21
  141. package/src/rooms/remote-peer.ts +186 -19
  142. package/src/rooms/service.ts +338 -22
  143. package/src/rooms/store.ts +437 -65
  144. package/src/rooms/wire.ts +24 -6
  145. package/src/ui/index.html +271 -17
  146. package/src/ui/needs.ts +10 -4
  147. package/src/ui/office-model.ts +64 -8
  148. package/src/ui/office-space.ts +1 -0
  149. package/src/ui/plugin-questions.ts +56 -0
  150. package/src/ui/plugin-results.ts +81 -0
  151. package/src/ui/plugin.ts +219 -28
  152. package/src/pet/window.html +0 -114
  153. package/src/pet/window.jxa.js +0 -122
  154. package/src/pet/window.ts +0 -154
package/src/hub/ui.ts CHANGED
@@ -149,7 +149,9 @@ export function startDashboard(options: DashboardOptions) {
149
149
  return reject(403, "live-test dashboard cannot write person-attributed content; use peer tools");
150
150
  }
151
151
  if (input.action === "sdlc_queue" || input.action === "sdlc_release") server.timeout(req, 35);
152
+ else if (input.action === "worktrees_refresh_prs") server.timeout(req, 45); // #1057: bounded gh reads (25 s) and two git passes
152
153
  else if (input.action === "backlog_split") server.timeout(req, Math.ceil((BACKLOG_SPLIT_SCREEN_TIMEOUT_MS + HUB_DB_BUSY_TIMEOUT_MS + 5_000) / 1000) + 5);
154
+ else if (input.action === "intake") server.timeout(req, 95); // the CLI's 90 s intake cap plus the HTTP response margin
153
155
  return json(await options.action(input, sessionOf(cookie)));
154
156
  } catch {
155
157
  // Exceptions may quote private task text or backend details. Never echo them to the page.
@@ -0,0 +1,100 @@
1
+ import { readSdlcPr, type SdlcGh } from "./sdlc.ts";
2
+ import type { PrObserver, WorktreeFact } from "./worktree-shelf.ts";
3
+
4
+ /**
5
+ * #1057 D1: the explicit "refresh PR states" action. It is the ONLY code that lets the worktree shelf learn a PR state from GitHub, through `readSdlcPr`
6
+ * (the same readback the guarded merge uses), and it runs only when a person asks. A panel refresh, `ahub worktrees` and the inbox alert never reach it, so
7
+ * they make no GitHub call (AC5): `worktree-shelf.ts` imports nothing from here and holds no GitHub access.
8
+ *
9
+ * What it reads is kept in memory, keyed by repository and PR number, and offered back to the shelf as a `PrObserver`.
10
+ * ponytail: the observations live in the daemon's memory, so a restart forgets them and those rows read `unknown` again until the next refresh; the upgrade is a
11
+ * `worktree_pr` table beside `worktree_override`, which is not worth a schema change before live use shows people refresh often.
12
+ */
13
+ export const REFRESH_MAX_PRS = 8, REFRESH_CALL_MS = 8_000, REFRESH_TOTAL_MS = 25_000, OBSERVATIONS_MAX = 512;
14
+
15
+ /**
16
+ * When GitHub says a PR that is no longer open was merged or closed, in ms. A second, narrow read (`pr view <n> --json mergedAt,closedAt`) that only a refresh makes and
17
+ * only for a PR `readSdlcPr` found merged or closed, so the shared readback the guarded merge and the queue use is unchanged. A missing, malformed or non-finite time is undefined,
18
+ * never 0: a merged PR with no change time is `unknown` to the partition, not settled.
19
+ */
20
+ export async function readPrChangeTime(repo: string, pr: number, state: "merged" | "closed", gh: SdlcGh): Promise<number | undefined> {
21
+ // An owner and a name, each starting with a letter or digit: `..`, `.x` and `-x` are never a repository, so a hostile slug cannot reach gh as a path or a flag.
22
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(repo) || repo.split("/").some(part => part === "." || part === "..") || !Number.isSafeInteger(pr) || pr < 1) return undefined;
23
+ try {
24
+ const view = await gh(["pr", "view", String(pr), "--repo", repo, "--json", "mergedAt,closedAt"], 8_000, undefined, 4096);
25
+ if (view.code !== 0) return undefined;
26
+ const value = JSON.parse(view.stdout) as { mergedAt?: unknown; closedAt?: unknown } | null;
27
+ const text = state === "merged" ? value?.mergedAt ?? value?.closedAt : value?.closedAt;
28
+ const at = typeof text === "string" && text.length <= 40 ? Date.parse(text) : NaN;
29
+ return Number.isFinite(at) && at > 0 ? at : undefined;
30
+ } catch { return undefined; }
31
+ }
32
+
33
+ export interface PrObservation { state: "open" | "merged" | "closed"; changedAt?: number; at: number }
34
+ export interface RefreshResult {
35
+ /** PRs read and stored. */
36
+ read: number;
37
+ /** PRs GitHub could not answer for (unreadable, no access, a gh failure): their state stays what it was. */
38
+ failed: number;
39
+ /** PRs not asked about because the cap or the time budget was reached. */
40
+ skipped: number;
41
+ }
42
+
43
+ /** A bounded, insertion-ordered map of what the last refreshes read. */
44
+ export class PrObservations {
45
+ private readonly seen = new Map<string, PrObservation>();
46
+ record(repo: string, pr: number, observation: PrObservation): void {
47
+ const key = `${repo}#${pr}`;
48
+ this.seen.delete(key); this.seen.set(key, observation);
49
+ while (this.seen.size > OBSERVATIONS_MAX) this.seen.delete(this.seen.keys().next().value!);
50
+ }
51
+ /** The shelf's view of it: state and change time only. */
52
+ observer: PrObserver = (repo, pr) => {
53
+ const found = this.seen.get(`${repo}#${pr}`);
54
+ return found ? { state: found.state, ...(found.changedAt !== undefined ? { changedAt: found.changedAt } : {}) } : undefined;
55
+ };
56
+ /** When the newest observation was read, or undefined when none was. */
57
+ get lastAt(): number | undefined { let at: number | undefined; for (const value of this.seen.values()) at = Math.max(at ?? 0, value.at); return at; }
58
+ get size(): number { return this.seen.size; }
59
+ }
60
+
61
+ /** The PRs a refresh would ask about: bound tasks with a repository and a PR that the board does not already show as merged, oldest activity first. */
62
+ export function refreshTargets(facts: readonly WorktreeFact[], limit = REFRESH_MAX_PRS): { repo: string; pr: number }[] {
63
+ const seen = new Set<string>(), out: { repo: string; pr: number }[] = [];
64
+ const candidates = facts.filter(f => !f.main && !f.instance && f.task?.repo !== undefined && f.task.pr !== undefined && f.pr.state !== "merged")
65
+ .sort((a, b) => a.lastActiveAt - b.lastActiveAt);
66
+ for (const fact of candidates) {
67
+ const key = `${fact.task!.repo}#${fact.task!.pr}`;
68
+ if (seen.has(key)) continue;
69
+ seen.add(key); out.push({ repo: fact.task!.repo!, pr: fact.task!.pr! });
70
+ }
71
+ return out.slice(0, limit);
72
+ }
73
+
74
+ /**
75
+ * Read each target once, one at a time, under one total budget. `gh` is the host's runner (`sdlcGh` bound to the project), never an argument a request supplies.
76
+ * `readSdlcPr` validates the repository and PR number itself and builds a fixed read-only argv. Every failure is counted and the old state stands.
77
+ */
78
+ export async function refreshPrStates(
79
+ targets: readonly { repo: string; pr: number }[],
80
+ gh: SdlcGh,
81
+ store: PrObservations,
82
+ options: { now?: () => number; totalMs?: number; callMs?: number; max?: number } = {},
83
+ ): Promise<RefreshResult> {
84
+ const now = options.now ?? Date.now, started = now(), total = options.totalMs ?? REFRESH_TOTAL_MS, perCall = options.callMs ?? REFRESH_CALL_MS;
85
+ const left = () => Math.max(0, started + total - now());
86
+ const bounded: SdlcGh = async (args, timeoutMs, cwd, maxOutputBytes) => left() <= 0 ? { code: 1, stdout: "" } : gh(args, Math.max(1, Math.min(timeoutMs ?? perCall, perCall, left())), cwd, maxOutputBytes);
87
+ const asked = targets.slice(0, options.max ?? REFRESH_MAX_PRS);
88
+ let read = 0, failed = 0, done = 0;
89
+ for (const target of asked) {
90
+ if (left() <= 0) break;
91
+ done++;
92
+ const evidence = await readSdlcPr({ stage: "pr", repo: target.repo, pr: target.pr }, bounded).catch(() => undefined);
93
+ if (!evidence) { failed++; continue; }
94
+ const state = evidence.state === "MERGED" ? "merged" : evidence.state === "CLOSED" ? "closed" : "open";
95
+ const changedAt = state === "open" ? undefined : await readPrChangeTime(target.repo, target.pr, state, bounded);
96
+ store.record(target.repo, target.pr, { state, ...(changedAt !== undefined ? { changedAt } : {}), at: now() });
97
+ read++;
98
+ }
99
+ return { read, failed, skipped: targets.length - done };
100
+ }
@@ -0,0 +1,246 @@
1
+ import { execFile } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, join, relative, sep } from "node:path";
4
+ import { childEnv } from "./child-process.ts";
5
+ import { hubGitArgv, hubGitEnv } from "./git.ts";
6
+ import { HUB } from "./envelope.ts";
7
+ import { realPath } from "./project.ts";
8
+ import type { Task } from "./board.ts";
9
+
10
+ /**
11
+ * #1057 worktree shelf. Which worktrees are finished is DERIVED at read time from facts and two person overrides, never stored as a flag.
12
+ * This module reads facts and computes the partition. It never deletes a worktree or a local branch: removal exists only in the person's
13
+ * CLI process (`worktree-tidy.ts`, a later change), and `test/worktree-shelf.test.ts` greps the daemon-reachable modules for any removal argv.
14
+ */
15
+
16
+ export type PrState = "open" | "merged" | "closed" | "unknown";
17
+ /** `settled` is what a PII-bound row says in a reply for a merged or closed PR: the reason is real on the host, but a reply carries no PR state of a private task. */
18
+ export type SettledReason = "merged" | "closed" | "quiet" | "parked" | "settled";
19
+ export type OverrideValue = "settled" | "active";
20
+ export interface WorktreeOverride { path: string; value: OverrideValue; at: number }
21
+
22
+ export interface WorktreeFact {
23
+ /** Absolute, host-only. A row shown to a person is displayed through `displayPath`. */
24
+ path: string;
25
+ branch: string | null;
26
+ head: string;
27
+ locked: boolean;
28
+ prunable: boolean;
29
+ /** The project's main checkout: never shelved. */
30
+ main: boolean;
31
+ /** A configured instance worktree (`.worktrees/<instance>`): never shelved. */
32
+ instance: boolean;
33
+ /** Max of the HEAD commit time, the worktree index mtime and the latest time someone other than the hub wrote to the bound task. 0 when none could be read. */
34
+ lastActiveAt: number;
35
+ /** False when the commit time or the index could not be read (a failed git call or a spent budget): such a row is never settled by a PR or by quiet time, because 'no newer work' is unknown. */
36
+ activityKnown?: boolean;
37
+ /** `private`: the bound task is a PII task. The binding still drives the partition and tidy's refusals on the host, but `row()` never puts its id, PR or state in a reply. */
38
+ task?: { id: number; state: string; pr?: number; repo?: string; private?: true };
39
+ pr: { state: PrState; changedAt?: number };
40
+ }
41
+ /** A row built for a reply. A PII-bound row carries `private: true` and `taskState: "private"` and no task id or PR. This is the one place a reply row is made, so redaction happens here (AGENTS.md), never at a call site. */
42
+ export interface ShelfRow { path: string; branch: string | null; reason?: SettledReason; task?: number; taskState?: string; pr?: number; private?: true; /** The main checkout or a declared instance worktree: never shelved, so a panel offers no Park for it. */ fixed?: true; lastActiveAt: number }
43
+ export interface Partition { live: ShelfRow[]; settled: ShelfRow[] }
44
+
45
+ const DAY_MS = 86_400_000;
46
+ const row = (fact: WorktreeFact, reason?: SettledReason): ShelfRow => ({
47
+ path: fact.path, branch: fact.branch, lastActiveAt: fact.lastActiveAt, ...(fact.main || fact.instance ? { fixed: true as const } : {}),
48
+ ...(reason ? { reason: fact.task?.private && (reason === "merged" || reason === "closed") ? "settled" as const : reason } : {}),
49
+ ...(fact.task?.private ? { private: true as const, taskState: "private" }
50
+ : { ...(fact.task ? { task: fact.task.id, taskState: fact.task.state } : {}), ...(fact.task?.pr !== undefined ? { pr: fact.task.pr } : {}) }),
51
+ });
52
+
53
+ /** An override counts only while no newer activity exists: `lastActiveAt <= at` (D2). Newer activity makes it inert. */
54
+ export const overrideInForce = (override: WorktreeOverride | undefined, lastActiveAt: number): override is WorktreeOverride =>
55
+ !!override && lastActiveAt <= override.at;
56
+
57
+ /**
58
+ * D3, pure. A row whose activity is unknown (`activityKnown === false`) is live: every settling rule compares with `lastActiveAt`, which is then not a fact.
59
+ * Rules in order: (1) the main checkout and instance worktrees are never shelved; (2) an effective override wins; (3) a merged or
60
+ * closed PR with `lastActiveAt <= changedAt` is settled; (4) `quietDays > 0` and a quiet span of at least that long is settled; (5) live.
61
+ * `now`, the facts and the overrides are arguments: no clock, no I/O.
62
+ */
63
+ export function partition(facts: readonly WorktreeFact[], overrides: readonly WorktreeOverride[], now: number, quietDays: number): Partition {
64
+ const byPath = new Map(overrides.map(o => [o.path, o]));
65
+ const out: Partition = { live: [], settled: [] };
66
+ for (const fact of facts) {
67
+ if (fact.main || fact.instance || fact.activityKnown === false) { out.live.push(row(fact)); continue; }
68
+ const override = byPath.get(fact.path);
69
+ if (overrideInForce(override, fact.lastActiveAt)) {
70
+ if (override.value === "settled") out.settled.push(row(fact, "parked")); else out.live.push(row(fact));
71
+ continue;
72
+ }
73
+ if ((fact.pr.state === "merged" || fact.pr.state === "closed") && fact.pr.changedAt !== undefined && fact.lastActiveAt <= fact.pr.changedAt) {
74
+ out.settled.push(row(fact, fact.pr.state)); continue;
75
+ }
76
+ if (quietDays > 0 && fact.lastActiveAt > 0 && now - fact.lastActiveAt >= quietDays * DAY_MS) { out.settled.push(row(fact, "quiet")); continue; }
77
+ out.live.push(row(fact));
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Project-relative inside the root (the root itself is `.`), otherwise the basename only: paths stay host-only, as in `snapshots.ts`. */
83
+ export function displayPath(root: string, path: string): string {
84
+ if (path === root) return ".";
85
+ const rel = relative(root, path);
86
+ return rel && !rel.startsWith("..") && !rel.startsWith(sep) && rel !== path ? rel : basename(path);
87
+ }
88
+
89
+ /** Whether a path is the root or below it, so a row can be resolved against the project root and one outside it cannot. */
90
+ export const shelfInsideRoot = (root: string, path: string): boolean => path === root || (!relative(root, path).startsWith("..") && !relative(root, path).startsWith(sep) && relative(root, path) !== path);
91
+
92
+ /**
93
+ * The latest time anyone but the hub wrote to a task: its work, not its bookkeeping. The hub's own entries (the merge result, the owner notice,
94
+ * sweeps) follow the event they record, so counting them would put `lastActiveAt` after a merge's `changedAt` and a merged task could never settle (#1057 D1/D3).
95
+ */
96
+ export const taskWorkAt = (task: Pick<Task, "history" | "created">): number =>
97
+ Math.max(task.created, ...task.history.filter(h => h.by !== HUB && Number.isFinite(h.at)).map(h => h.at));
98
+
99
+ /** The task a worktree branch belongs to: the open task, else the most recently updated one, whose `refs.branch` equals the branch. */
100
+ export function boundTask(tasks: readonly Pick<Task, "id" | "state" | "refs" | "sdlc" | "updated" | "created" | "history">[], branch: string | null): WorktreeFact["task"] & { workAt: number } | undefined {
101
+ if (!branch) return undefined;
102
+ const matching = tasks.filter(t => t.refs.branch === branch);
103
+ if (!matching.length) return undefined;
104
+ const closed = (state: string) => state === "approved" || state === "cancelled";
105
+ const pick = [...matching].sort((a, b) => Number(closed(a.state)) - Number(closed(b.state)) || b.updated - a.updated)[0]!;
106
+ const pr = pick.sdlc?.pr, repo = pick.sdlc?.repo;
107
+ return { id: pick.id, state: pick.state, ...(typeof pr === "number" ? { pr } : {}), ...(typeof repo === "string" && typeof pr === "number" ? { repo } : {}), workAt: taskWorkAt(pick) };
108
+ }
109
+
110
+ /**
111
+ * PR state from evidence already on the board (D1): only the guarded merge's own record says `merged`. A cancelled task says nothing about its PR (`Tasks.cancel`
112
+ * touches only the board, so the PR can still be open on GitHub), and a task with no record says nothing either: both are `unknown`. Only the explicit refresh
113
+ * action, which reads GitHub, may learn `closed` or `open` (a later change).
114
+ */
115
+ export function prFromTask(task: Pick<Task, "state" | "sdlc" | "history"> | undefined, observed?: PrObserver): WorktreeFact["pr"] {
116
+ const merge = task?.sdlc?.merge;
117
+ if (merge && merge.state === "merged" && Number.isFinite(merge.at)) return { state: "merged", changedAt: merge.at };
118
+ // A PR state the person asked the hub to read (the explicit refresh action) is a stored fact, never fetched here: this module has no GitHub access (AC5).
119
+ const repo = task?.sdlc?.repo, pr = task?.sdlc?.pr;
120
+ const seen = typeof repo === "string" && typeof pr === "number" ? observed?.(repo, pr) : undefined;
121
+ if (seen) return { state: seen.state, ...(seen.changedAt !== undefined && seen.state !== "open" ? { changedAt: seen.changedAt } : {}) };
122
+ return { state: "unknown" };
123
+ }
124
+ /** The stored result of an explicit PR refresh for one repository and PR number, or undefined when none was read. */
125
+ export type PrObserver = (repo: string, pr: number) => { state: "open" | "merged" | "closed"; changedAt?: number } | undefined;
126
+
127
+ export interface RawWorktree { path: string; branch: string | null; head: string; locked: boolean; prunable: boolean }
128
+ /** `git worktree list --porcelain -z`: one NUL-separated field per attribute, an empty field ends a record. Bounded. */
129
+ export function parseWorktreeList(stdout: string, limit = 256): { rows: RawWorktree[]; complete: boolean } {
130
+ const rows: RawWorktree[] = [];
131
+ let current: Partial<RawWorktree> | undefined;
132
+ const flush = () => { if (current?.path) rows.push({ path: current.path, branch: current.branch ?? null, head: current.head ?? "", locked: !!current.locked, prunable: !!current.prunable }); current = undefined; };
133
+ for (const field of stdout.split("\0")) {
134
+ if (field === "") { flush(); continue; }
135
+ if (field.startsWith("worktree ")) { flush(); current = { path: field.slice(9) }; continue; }
136
+ if (!current) continue;
137
+ if (field.startsWith("HEAD ")) current.head = field.slice(5);
138
+ else if (field.startsWith("branch refs/heads/")) current.branch = field.slice("branch refs/heads/".length);
139
+ else if (field === "locked" || field.startsWith("locked ")) current.locked = true;
140
+ else if (field === "prunable" || field.startsWith("prunable ")) current.prunable = true;
141
+ }
142
+ flush();
143
+ return { rows: rows.slice(0, limit), complete: rows.length <= limit };
144
+ }
145
+
146
+ export interface ShelfGitPort {
147
+ /** Read-only git, bounded by `timeoutMs`, never blocking the event loop. `undefined` is a failed or timed-out read, never an empty answer. */
148
+ run(cwd: string, args: string[], timeoutMs: number): Promise<{ status: number | null; stdout: string } | undefined>;
149
+ }
150
+ /** One git call's ceiling, and the whole read's: a slow repository answers `complete: false` after this, never a stalled hub (D1's snapshot-budget pattern). */
151
+ export const READ_TIMEOUT_MS = 5_000, TOTAL_BUDGET_MS = 3_000, READ_CONCURRENCY = 4;
152
+ export const realShelfGit: ShelfGitPort = {
153
+ async run(cwd, args, timeoutMs) {
154
+ if (!shelfReadAllowed(args)) throw new Error("the worktree shelf runs read-only git commands only");
155
+ const argv = hubGitArgv(["-C", cwd, "-c", "gc.auto=0", "-c", "maintenance.auto=false", ...args]);
156
+ return await new Promise(resolve => {
157
+ execFile(argv[0]!, argv.slice(1), { encoding: "utf8", timeout: Math.max(1, Math.min(READ_TIMEOUT_MS, timeoutMs)), killSignal: "SIGKILL", maxBuffer: 1024 * 1024, env: hubGitEnv(childEnv(), { repositoryIsolation: true }) }, (error, stdout) => {
158
+ const code = (error as (Error & { code?: unknown }) | null)?.code;
159
+ // a non-zero exit is an answer (status), a spawn failure, timeout or overflow is not
160
+ resolve(error && typeof code !== "number" ? undefined : { status: typeof code === "number" ? code : 0, stdout: String(stdout ?? "") });
161
+ });
162
+ });
163
+ },
164
+ };
165
+ /**
166
+ * The only git argv shapes the shelf runs, all reads. `realShelfGit.run` refuses anything else before spawning, so no removal, branch, ref or
167
+ * prune argv can reach git from the daemon (AC4); the boundary test also greps the module text for them.
168
+ */
169
+ export const SHELF_GIT_READS: readonly (readonly string[])[] = [
170
+ ["worktree", "list", "--porcelain", "-z"],
171
+ ["log", "-1", "--format=%ct", "HEAD"],
172
+ ["rev-parse", "--absolute-git-dir"],
173
+ ];
174
+ export const shelfReadAllowed = (args: readonly string[]): boolean => SHELF_GIT_READS.some(shape => shape.length === args.length && shape.every((word, i) => word === args[i]));
175
+
176
+ export interface ShelfInput {
177
+ root: string;
178
+ /** Called AFTER the git reads, so the board is read once and fresh, never held across an await. */
179
+ tasks: () => readonly Pick<Task, "id" | "state" | "refs" | "sdlc" | "updated" | "created" | "history">[];
180
+ /** Whether a task is a PII task (`Tasks.isPii`). Its binding is kept for the partition and the refusals and redacted in the reply row. A host that does not pass it treats no task as private. */
181
+ isPrivate?: (taskId: number) => boolean;
182
+ /** Resolved instance worktree paths (`.worktrees/<instance>`): never shelved. */
183
+ instancePaths: readonly string[];
184
+ /** Stored PR observations (D1); called after the git reads, in the same synchronous segment as `tasks`. */
185
+ observed?: PrObserver;
186
+ git?: ShelfGitPort;
187
+ now?: () => number;
188
+ budgetMs?: number;
189
+ }
190
+ export interface ShelfFacts { facts: WorktreeFact[]; complete: boolean; available: boolean }
191
+
192
+ /** The worktree's own index mtime (D1: it covers uncommitted editing). `undefined` is unknown; a missing index file is a known 0. */
193
+ async function indexMtime(git: ShelfGitPort, path: string, timeoutMs: number): Promise<number | undefined> {
194
+ const dir = await git.run(path, ["rev-parse", "--absolute-git-dir"], timeoutMs);
195
+ if (!dir || dir.status !== 0) return undefined;
196
+ try { const info = await stat(join(dir.stdout.trim(), "index")); return info.isFile() ? info.mtimeMs : 0; }
197
+ catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? 0 : undefined; }
198
+ }
199
+
200
+ interface Probe { commitAt: number; touched: number; known: boolean }
201
+
202
+ /**
203
+ * D1: facts per worktree. One `worktree list`, then a commit-time read and an index read per worktree, at most `READ_CONCURRENCY` at a time and all under one
204
+ * total budget. Asynchronous throughout: the daemon's event loop is never held. When the budget is spent, the rows not yet read are still listed, with unknown
205
+ * activity (live, never settled), and the result says `complete: false`. Never writes.
206
+ */
207
+ export async function readWorktreeFacts(input: ShelfInput): Promise<ShelfFacts> {
208
+ const git = input.git ?? realShelfGit, now = input.now ?? Date.now, deadline = now() + (input.budgetMs ?? TOTAL_BUDGET_MS);
209
+ const left = () => Math.max(0, deadline - now());
210
+ let root: string;
211
+ try { root = realPath(input.root); } catch { return { facts: [], complete: false, available: false }; }
212
+ const listed = await git.run(root, ["worktree", "list", "--porcelain", "-z"], left());
213
+ if (!listed || listed.status !== 0) return { facts: [], complete: false, available: false };
214
+ const parsed = parseWorktreeList(listed.stdout);
215
+ const paths = parsed.rows.map(raw => { try { return realPath(raw.path); } catch { return raw.path; } }); // a prunable worktree's directory is gone: keep the registered path
216
+ const probes: (Probe | undefined)[] = parsed.rows.map(() => undefined);
217
+ let next = 0, cut = false;
218
+ const worker = async () => {
219
+ for (;;) {
220
+ const i = next++;
221
+ if (i >= paths.length) return;
222
+ if (left() <= 0) { cut = true; return; }
223
+ const stamp = await git.run(paths[i]!, ["log", "-1", "--format=%ct", "HEAD"], left());
224
+ const text = stamp && stamp.status === 0 ? stamp.stdout.trim() : "";
225
+ const touched = left() > 0 ? await indexMtime(git, paths[i]!, left()) : undefined;
226
+ if (!/^\d{1,12}$/.test(text) || touched === undefined) { probes[i] = { commitAt: 0, touched: 0, known: false }; cut = cut || left() <= 0; continue; }
227
+ probes[i] = { commitAt: Number(text) * 1000, touched, known: true };
228
+ }
229
+ };
230
+ await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, paths.length) }, worker));
231
+ // From here to the return nothing is awaited: the board is read once, synchronously, so it is current for every row.
232
+ const tasks = input.tasks(), instances = new Set(input.instancePaths), facts: WorktreeFact[] = [];
233
+ let unread = false;
234
+ parsed.rows.forEach((raw, i) => {
235
+ const path = paths[i]!, probe = probes[i], task = boundTask(tasks, raw.branch), bound = task ? tasks.find(t => t.id === task.id) : undefined;
236
+ if (!probe?.known) unread = true;
237
+ facts.push({
238
+ path, branch: raw.branch, head: raw.head, locked: raw.locked, prunable: raw.prunable,
239
+ main: path === root, instance: instances.has(path),
240
+ lastActiveAt: Math.max(probe?.commitAt ?? 0, probe?.touched ?? 0, task?.workAt ?? 0), activityKnown: probe?.known === true,
241
+ ...(task ? { task: { id: task.id, state: task.state, ...(task.pr !== undefined ? { pr: task.pr } : {}), ...(task.repo !== undefined ? { repo: task.repo } : {}), ...(input.isPrivate?.(task.id) ? { private: true as const } : {}) } } : {}),
242
+ pr: prFromTask(bound, input.observed),
243
+ });
244
+ });
245
+ return { facts, complete: parsed.complete && !cut && !unread, available: true };
246
+ }
@@ -1,6 +1,7 @@
1
+ import { reviewCaptureExcluded } from "../hub/memory-policy.ts";
1
2
  import { isAbsolute, relative, resolve, sep } from "node:path";
2
3
  import { defaultCapturePeer } from "../adapters/definitions/native-identity-defaults.ts";
3
- import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
4
+ import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, QUESTION_TOOLS, STUDY_TOOLS } from "../hub/hub-tools.ts";
4
5
  import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
5
6
  import { isDenied } from "../local/deny.ts";
6
7
  import type { MemoryClient } from "./client.ts";
@@ -38,7 +39,9 @@ export class Capture {
38
39
  }
39
40
 
40
41
  observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
41
- if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
42
+ if (!this.sessionId || reviewCaptureExcluded(call.tool, call.args) || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || CAPABILITY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || STUDY_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || HANDOFF_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_set_title" || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_evidence_record" || this.opts.skip?.includes(call.tool)) return;
43
+ // #1053: a question and its answer are the person's and the peer's words: never memory evidence, whatever the call's paths say.
44
+ if (QUESTION_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) return;
42
45
  // Anything that names a denylisted path is kept out of memory entirely, input and output alike.
43
46
  if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
44
47
  // ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
package/src/pet/config.ts CHANGED
@@ -8,7 +8,14 @@ const PALETTES: Record<string, PetPalette> = { a: "blue", b: "teal", c: "violet"
8
8
  export function petPalette(value: unknown): PetPalette | undefined {
9
9
  return typeof value === "string" ? PALETTES[value.toLowerCase()] : undefined;
10
10
  }
11
- export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string; window?: boolean }
11
+ export const PET_WINDOW_RETIRED_NOTICE = "The floating pet window moved to acon Mini; run acon ui and open Mini.";
12
+ /** Source compatibility only. Removed window data is never a terminal option or a settings capability. */
13
+ export function stripLegacyPetWindow(value: unknown): unknown {
14
+ if (!value || typeof value !== "object" || Array.isArray(value) || !Object.hasOwn(value, "window")) return value;
15
+ const { window: _retired, ...terminal } = value as Record<string, unknown>;
16
+ return terminal;
17
+ }
18
+ export interface PetConfig { enabled: boolean; blink: boolean; bell: boolean; character?: CharacterId; size?: PetSize; glyphs?: PetGlyphs; palette?: PetPalette; notice?: string }
12
19
  /** Console-local presentation only; it merges pet.palette into config.local.json and grants no authority. */
13
20
  export function savePetPalette(cwd: string, value: string): string {
14
21
  const palette = petPalette(value);
@@ -35,34 +42,39 @@ export function savePetPalette(cwd: string, value: string): string {
35
42
  /** Console-local presentation only; this reader never writes config or grants authority. */
36
43
  export function loadPetConfig(cwd: string): PetConfig {
37
44
  let value: unknown;
38
- let enabled = true;
45
+ let enabled = true, retiredWindow = false;
39
46
  for (const name of ["config.json", "config.local.json"]) {
40
47
  try {
41
48
  const file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
42
- // A window-only local opt-in does not undo inherited pet:false presentation.
43
- if (file.pet === false) enabled = false;
44
- else if (file.pet !== undefined && !(file.pet && typeof file.pet === "object" && !Array.isArray(file.pet) && Object.keys(file.pet).length === 1 && Object.hasOwn(file.pet, "window"))) enabled = true;
45
- if (file.pet !== undefined) value = value && typeof value === "object" && file.pet && typeof file.pet === "object" ? { ...value, ...file.pet } : file.pet;
49
+ const pet = stripLegacyPetWindow(file.pet);
50
+ if (pet !== file.pet) {
51
+ retiredWindow = true;
52
+ // A retired-only block contributes no terminal override, including no enablement over inherited pet:false.
53
+ if (pet && typeof pet === "object" && !Object.keys(pet).length) continue;
54
+ }
55
+ if (pet === false) enabled = false;
56
+ else if (pet !== undefined) enabled = true;
57
+ if (pet !== undefined) value = value && typeof value === "object" && pet && typeof pet === "object" ? { ...value, ...pet } : pet;
46
58
  } catch (error) {
47
59
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
48
60
  }
49
61
  }
50
- if (value === false) return { enabled: false, blink: true, bell: false };
62
+ const notice = (other?: string) => [retiredWindow ? PET_WINDOW_RETIRED_NOTICE : undefined, other].filter(Boolean).join(" ");
63
+ if (value === false) return { enabled: false, blink: true, bell: false, ...(retiredWindow ? { notice: notice() } : {}) };
51
64
  const defaults = { enabled: true, blink: true, bell: false };
52
- if (value === undefined) return defaults;
53
- if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: "Unknown pet configuration; using stai line defaults." };
65
+ if (value === undefined) return { ...defaults, ...(retiredWindow ? { notice: notice() } : {}) };
66
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { ...defaults, notice: notice("Unknown pet configuration; using stai line defaults.") };
54
67
  const row = value as Record<string, unknown>;
55
- const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette", "window"].includes(key)) ||
68
+ const invalid = Object.keys(row).some(key => !["character", "size", "glyphs", "blink", "bell", "palette"].includes(key)) ||
56
69
  row.character !== undefined && !characterId(row.character) || row.size !== undefined && !["line", "mini", "full"].includes(row.size as string) ||
57
70
  row.glyphs !== undefined && (typeof row.glyphs !== "string" || !["auto", "ascii", "blocks"].includes(row.glyphs)) ||
58
71
  row.palette !== undefined && !petPalette(row.palette) ||
59
- row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean" || row.window !== undefined && typeof row.window !== "boolean";
72
+ row.blink !== undefined && typeof row.blink !== "boolean" || row.bell !== undefined && typeof row.bell !== "boolean";
60
73
  const palette = petPalette(row.palette);
61
74
  return { enabled, blink: typeof row.blink === "boolean" ? row.blink : true, bell: typeof row.bell === "boolean" ? row.bell : false,
62
- ...(typeof row.window === "boolean" ? { window: row.window } : {}),
63
75
  ...(characterId(row.character) && row.character !== "stai" ? { character: row.character } : {}),
64
76
  ...(row.size === "mini" || row.size === "full" ? { size: row.size } : {}),
65
77
  ...(row.glyphs === "ascii" || row.glyphs === "blocks" ? { glyphs: row.glyphs } : {}),
66
78
  ...(palette && palette !== "teal" ? { palette } : {}),
67
- ...(invalid ? { notice: "Unknown pet option; using stai line defaults for that option." } : {}) };
79
+ ...(invalid || retiredWindow ? { notice: notice(invalid ? "Unknown pet option; using stai line defaults for that option." : undefined) } : {}) };
68
80
  }
@@ -23,6 +23,8 @@ let shellSeq = 0;
23
23
  let forcedFailure = "";
24
24
  let turnGeneration = 0;
25
25
  let sessionId = "";
26
+ let titleRevision = 0;
27
+ let titleEpoch = "";
26
28
  let pendingPromptId: string | undefined;
27
29
  let activePromptId: string | undefined;
28
30
  let settlement: Promise<void> | undefined;
@@ -93,11 +95,36 @@ async function poll(pi: ExtensionAPI): Promise<void> {
93
95
  await post("/ack", { id: command.id, ok: false, error: "Pi prompt insertion did not synchronously claim a native turn", refusal: { kind: "prompt_uncertain", promptId: command.id, sessionId, generation: turnGeneration } });
94
96
  continue;
95
97
  }
98
+ } else if (command.type === "set_session_title") {
99
+ const refuse = (cause: string, error: string) => post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "invalid", cause, error, sessionId, titleEpoch, titleRevision } });
100
+ const nativeId = runtimeCtx?.sessionManager?.getHeader?.()?.id;
101
+ if (command.sessionId !== sessionId || nativeId !== sessionId || command.titleEpoch !== undefined && command.titleEpoch !== titleEpoch) {
102
+ await refuse("session", "Pi native session changed before the title write; check ahub status and reconnect the current session before retrying."); continue;
103
+ }
104
+ if (command.generation !== turnGeneration) {
105
+ await refuse("generation", "Pi native turn changed before the title write; wait for the current turn and check ahub status before retrying."); continue;
106
+ }
107
+ if (typeof command.title !== "string" || !command.title.trim() || [...command.title].length > 80 || typeof command.onlyIfUnnamed !== "boolean") {
108
+ await refuse("arguments", "Pi title command arguments are invalid; use a 1-80 character normalized title and a conditional-write flag before retrying."); continue;
109
+ }
110
+ if (typeof pi.getSessionName !== "function" || typeof pi.setSessionName !== "function") {
111
+ await refuse("native_unavailable", "Pi native title API is unavailable; restart Pi with the supported hub extension and check ahub status before retrying."); continue;
112
+ }
113
+ // The native read and synchronous write share one JS turn: a manual rename always wins against the agent.
114
+ const before = pi.getSessionName() ?? "";
115
+ if (command.onlyIfUnnamed && before) {
116
+ await post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "already_named", error: "This session already has a title; keep it or ask the person to rename it with ahub title.", sessionId, titleEpoch, titleRevision, sessionName: before } }); continue;
117
+ }
118
+ pi.setSessionName(command.title);
119
+ const sessionName = pi.getSessionName() ?? "";
120
+ await post("/ack", { id: command.id, ok: true, result: { ok: sessionName === command.title, ...(sessionName === command.title ? {} : { reason: "invalid", cause: "native_readback", mayHaveApplied: true, error: "Pi native title readback did not confirm the write; the title may have been applied; check ahub status before retrying." }), sessionId, titleEpoch, titleRevision, sessionName } });
121
+ continue;
96
122
  } else if (command.type === "get_session_state") {
97
123
  const entries = runtimeCtx.sessionManager.getEntries();
98
124
  await post("/ack", { id: command.id, ok: true, result: {
99
125
  sessionId: runtimeCtx.sessionManager.getHeader()?.id,
100
126
  sessionFile: runtimeCtx.sessionManager.getSessionFile(),
127
+ ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}),
101
128
  idle: runtimeCtx.isIdle() === true,
102
129
  empty: entries.every((entry: any) => ["model_change", "thinking_level_change"].includes(entry.type)),
103
130
  } });
@@ -120,7 +147,14 @@ async function poll(pi: ExtensionAPI): Promise<void> {
120
147
  }
121
148
  }
122
149
  await post("/ack", { id: command.id, ok: true });
123
- } catch (error) { if (pendingPromptId === command.id) pendingPromptId = undefined; await post("/ack", { id: command.id, ok: false, error: (error as Error).message }); }
150
+ } catch (error) {
151
+ if (command.type === "set_session_title") {
152
+ await post("/ack", { id: command.id, ok: true, result: { ok: false, reason: "invalid", cause: "native_failed", mayHaveApplied: true, error: "Pi native title API failed without confirming the write; the title may have been applied; check ahub status before retrying.", sessionId, titleEpoch, titleRevision } });
153
+ continue;
154
+ }
155
+ if (pendingPromptId === command.id) pendingPromptId = undefined;
156
+ await post("/ack", { id: command.id, ok: false, error: (error as Error).message });
157
+ }
124
158
  } catch { await new Promise((resolve) => setTimeout(resolve, 250)); }
125
159
  }
126
160
  }
@@ -142,8 +176,9 @@ export default function(pi: ExtensionAPI): void {
142
176
  shutdown = ctx.shutdown;
143
177
  const state = ctx.sessionManager.getHeader();
144
178
  sessionId = String(state?.id ?? "");
179
+ titleRevision = 0; titleEpoch = crypto.randomUUID();
145
180
  try {
146
- const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function", fencedPrompt: true });
181
+ const claimed = await post("/event", { type: "session_start", ownerToken, pid: process.pid, signature: processSignature(process.pid), ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}), sessionId: state?.id, sessionFile: ctx.sessionManager.getSessionFile(), approvalTurnAbort: typeof ctx.abort === "function", fencedPrompt: true });
147
182
  if (claimed?.ok === false) { ctx.shutdown?.(); return; }
148
183
  } catch (error) { ctx.shutdown?.(); throw error; }
149
184
  if (!pollStarted) { pollStarted = true; void poll(pi); }
@@ -168,8 +203,13 @@ export default function(pi: ExtensionAPI): void {
168
203
  lastActivity = now;
169
204
  try { await post("/event", { type: "activity" }); } catch { /* shutdown owns bridge cleanup */ }
170
205
  });
206
+ pi.on("session_info_changed", async () => {
207
+ if (runtimeCtx?.sessionManager?.getHeader?.()?.id !== sessionId || typeof pi.getSessionName !== "function") return;
208
+ try { await post("/event", { type: "session_title", sessionId, titleEpoch, titleRevision: ++titleRevision, sessionName: pi.getSessionName() ?? "" }); }
209
+ catch { /* title observation must not disrupt the native session */ }
210
+ });
171
211
  // Native retries/continuations emit more starts before the one logical agent_settled.
172
- pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; activePromptId = pendingPromptId ?? activePromptId; pendingPromptId = undefined; await post("/event", { type: "agent_start", generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}) }); });
212
+ pi.on("agent_start", async () => { toolSteps = 0; forcedFailure = ""; turnGeneration++; activePromptId = pendingPromptId ?? activePromptId; pendingPromptId = undefined; await post("/event", { type: "agent_start", sessionId, ...(typeof pi.getSessionName === "function" ? { sessionName: pi.getSessionName() ?? "", titleEpoch, titleRevision } : {}), generation: turnGeneration, ...(activePromptId ? { promptId: activePromptId } : {}) }); });
173
213
  pi.on("model_select", async (_event: any, ctx: any) => { if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") ctx.shutdown?.(); });
174
214
  pi.on("before_agent_start", async (event: any, ctx: any) => {
175
215
  if (ctx.model?.provider && ctx.model.provider !== "agent-hub-local") { ctx.abort?.(); return { systemPrompt: event.systemPrompt }; }