@staix/agent-hub 0.30.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 (125) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +13 -7
  3. package/docs/agent-notes/README.md +1 -0
  4. package/docs/agent-notes/adapters.md +5 -2
  5. package/docs/agent-notes/budget.md +1 -0
  6. package/docs/agent-notes/bus.md +9 -4
  7. package/docs/agent-notes/daemon.md +15 -8
  8. package/docs/agent-notes/local-worker.md +1 -0
  9. package/docs/agent-notes/tasks.md +7 -3
  10. package/docs/agent-notes/tests.md +6 -4
  11. package/docs/design/adapters.md +52 -0
  12. package/docs/design/agent-hub-architecture.md +46 -0
  13. package/docs/design/bus.md +139 -1
  14. package/docs/design/task-board.md +82 -0
  15. package/docs/dogfood.md +22 -4
  16. package/docs/events.md +3 -0
  17. package/docs/memory.md +17 -1
  18. package/docs/operations.md +307 -78
  19. package/docs/profiles.md +35 -0
  20. package/docs/quickstart.md +18 -5
  21. package/docs/security.md +154 -60
  22. package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
  23. package/docs/specs/2026-09-19-multi-project-design.md +7 -2
  24. package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
  25. package/docs/verification/2026-11-05-0.31.0.md +182 -0
  26. package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
  27. package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
  28. package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
  29. package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
  30. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  31. package/docs/verified.json +14 -14
  32. package/package.json +3 -4
  33. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  34. package/plugins/agent-hub/server.js +4393 -3609
  35. package/src/adapters/acp.ts +5 -4
  36. package/src/adapters/channel-peer.ts +72 -3
  37. package/src/adapters/claude-channel-evidence.ts +52 -0
  38. package/src/adapters/claude-channel.ts +61 -18
  39. package/src/adapters/claude-implementer.ts +29 -7
  40. package/src/adapters/claude-print-child.ts +12 -2
  41. package/src/adapters/codex-appserver.ts +13 -12
  42. package/src/adapters/compat.ts +3 -3
  43. package/src/adapters/definitions/builtin-metadata.ts +13 -13
  44. package/src/adapters/definitions/qwen.ts +1 -0
  45. package/src/adapters/local-worker.ts +61 -18
  46. package/src/adapters/pi.ts +48 -6
  47. package/src/adapters/registry-types.ts +12 -0
  48. package/src/adapters/registry.ts +2 -0
  49. package/src/adapters/reviewer-print.ts +50 -11
  50. package/src/broker/server.ts +44 -13
  51. package/src/broker/store.ts +38 -8
  52. package/src/cli/answer-args.ts +50 -0
  53. package/src/cli/attention.ts +17 -2
  54. package/src/cli/backlog-return-args.ts +37 -0
  55. package/src/cli/console-question.ts +110 -0
  56. package/src/cli/console-state.ts +77 -16
  57. package/src/cli/console.ts +54 -1
  58. package/src/cli/handoff-args.ts +15 -5
  59. package/src/cli/handoff-output.ts +15 -2
  60. package/src/cli/help.ts +8 -5
  61. package/src/cli/identity.ts +13 -3
  62. package/src/cli/main.ts +128 -16
  63. package/src/cli/memory.ts +107 -3
  64. package/src/cli/output.ts +29 -6
  65. package/src/cli/pet.ts +4 -4
  66. package/src/cli/results-args.ts +30 -0
  67. package/src/cli/runtime-version-warning.ts +50 -0
  68. package/src/cli/status-lines.ts +41 -1
  69. package/src/cli/tail-render.ts +1 -0
  70. package/src/cli/upgrade-interactive.ts +14 -2
  71. package/src/hub/action-class.ts +9 -1
  72. package/src/hub/board.ts +369 -18
  73. package/src/hub/bus.ts +493 -53
  74. package/src/hub/completion-detail.ts +50 -0
  75. package/src/hub/completions-store.ts +168 -0
  76. package/src/hub/completions.ts +120 -0
  77. package/src/hub/config-apply.ts +7 -5
  78. package/src/hub/config-reload.ts +10 -1
  79. package/src/hub/control-client.ts +40 -4
  80. package/src/hub/daemon.ts +1569 -134
  81. package/src/hub/delivery-failure.ts +14 -0
  82. package/src/hub/delivery-hints.ts +14 -4
  83. package/src/hub/delivery-journal.ts +751 -42
  84. package/src/hub/delivery-superseded.ts +4 -1
  85. package/src/hub/envelope.ts +3 -3
  86. package/src/hub/events.ts +6 -0
  87. package/src/hub/handoff.ts +145 -6
  88. package/src/hub/hub-tools.ts +56 -7
  89. package/src/hub/manager.ts +59 -3
  90. package/src/hub/memory-policy.ts +44 -1
  91. package/src/hub/memory-sync-policy.ts +109 -0
  92. package/src/hub/memory-sync-state.ts +145 -0
  93. package/src/hub/no-writes.ts +1 -1
  94. package/src/hub/operational-settings.ts +11 -2
  95. package/src/hub/operational-view.ts +12 -1
  96. package/src/hub/peers.ts +25 -2
  97. package/src/hub/planning.ts +6 -1
  98. package/src/hub/question-desk.ts +201 -0
  99. package/src/hub/question-detach.ts +6 -0
  100. package/src/hub/questions.ts +260 -0
  101. package/src/hub/research-evidence.ts +106 -0
  102. package/src/hub/restart.ts +14 -0
  103. package/src/hub/review-anchor.ts +181 -0
  104. package/src/hub/review-brief.ts +13 -0
  105. package/src/hub/review-comments.ts +115 -0
  106. package/src/hub/review-findings.ts +7 -2
  107. package/src/hub/review-material.ts +76 -0
  108. package/src/hub/routing.ts +3 -1
  109. package/src/hub/task-sweep.ts +2 -0
  110. package/src/hub/tasks.ts +577 -63
  111. package/src/memory/capture.ts +5 -2
  112. package/src/pet/config.ts +25 -13
  113. package/src/rooms/link.ts +209 -21
  114. package/src/rooms/remote-peer.ts +186 -19
  115. package/src/rooms/service.ts +338 -22
  116. package/src/rooms/store.ts +437 -65
  117. package/src/rooms/wire.ts +24 -6
  118. package/src/ui/index.html +138 -7
  119. package/src/ui/needs.ts +10 -4
  120. package/src/ui/plugin-questions.ts +56 -0
  121. package/src/ui/plugin-results.ts +81 -0
  122. package/src/ui/plugin.ts +207 -27
  123. package/src/pet/window.html +0 -114
  124. package/src/pet/window.jxa.js +0 -122
  125. package/src/pet/window.ts +0 -154
@@ -0,0 +1,109 @@
1
+ import { configuredPolicy, inspectSyncPolicy, looksSecret, memoryPolicySnapshot, type MemoryStore } from "@staix/agent-mem";
2
+ import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
4
+
5
+ /** Fields produced by agent-mem itself. Every other field is outbound text, including fields this hub has not seen yet. */
6
+ // ponytail: preserve #1189 write-input metadata stripping. SDK-canonical callbacks bypass it; migrate write inputs to syncOutboundContent only after a note-input compatibility proof.
7
+ const GENERATED = new Set(["id", "schema", "op", "at", "machine", "actor", "target", "promotedFrom", "supersedes", "expires", "by", "tier", "scope", "kind", "source"]);
8
+ const hostRule = (value: string | undefined): string => value === "hub privacy policy: PII pattern or private path"
9
+ ? value : "hub privacy policy";
10
+
11
+ export function projectMemorySyncText(row: Record<string, unknown>, canonical = false): Record<string, unknown> {
12
+ if (!row || typeof row !== "object" || Array.isArray(row) || types.isProxy(row)) throw new Error("invalid memory sync row");
13
+ const prototype = Object.getPrototypeOf(row);
14
+ if (prototype !== Object.prototype && prototype !== null || Object.getOwnPropertySymbols(row).length) throw new Error("invalid memory sync row");
15
+ const descriptors = Object.getOwnPropertyDescriptors(row);
16
+ const text = Object.create(null) as Record<string, unknown>;
17
+ for (const [key, descriptor] of Object.entries(descriptors)) {
18
+ if (!descriptor.enumerable || !("value" in descriptor)) throw new Error("invalid memory sync row");
19
+ if (canonical || !GENERATED.has(key)) Object.defineProperty(text, key, { value: descriptor.value, enumerable: true, configurable: true, writable: true });
20
+ }
21
+ return text;
22
+ }
23
+
24
+ export type MemorySyncHostScreen = (row: Record<string, unknown>) => { ok: boolean; rule?: string };
25
+ export type MemorySyncPolicyResult = { ok: true } | { ok: false; rule: string };
26
+
27
+ export interface MemorySyncPolicyOptions {
28
+ home: string;
29
+ cwd?: string;
30
+ /** Additional host deny paths and patterns. Machine and repository policy is always loaded and retained. */
31
+ denyPaths?: readonly string[];
32
+ piiPatterns?: readonly string[];
33
+ /** Hub-owned narrowing policy (routing PII and project-private paths). */
34
+ hostScreen?: MemorySyncHostScreen;
35
+ }
36
+
37
+ /**
38
+ * The outbound screen used by agent-mem sync, plus the hub's narrower privacy rules.
39
+ * Machine and repository policy remains authoritative through the public configuredPolicy API.
40
+ */
41
+ export function createMemorySyncPolicy(options: MemorySyncPolicyOptions): { screen(row: Record<string, unknown>, canonical?: boolean): MemorySyncPolicyResult } {
42
+ const standalone = configuredPolicy(options.home, options.cwd, { denyPaths: options.denyPaths, piiPatterns: options.piiPatterns });
43
+ return {
44
+ screen(row, canonical = false) {
45
+ let text: Record<string, unknown>;
46
+ try { text = projectMemorySyncText(row, canonical); } catch { return { ok: false, rule: "configured standalone policy: invalid input" }; }
47
+ let accepted: boolean;
48
+ try { accepted = standalone.screenStandalone(text); } catch { return { ok: false, rule: "configured standalone policy: invalid input" }; }
49
+ if (!accepted) {
50
+ const safe = memoryPolicySnapshot(text);
51
+ const serialized = safe === undefined ? undefined : safeJson(safe);
52
+ const rule = serialized !== undefined && looksSecret(serialized)
53
+ ? "configured standalone policy: secret"
54
+ : standalone.declinesPersonal(serialized ?? "")
55
+ ? "configured standalone policy: personal data, configured pattern, or deny path"
56
+ : "configured standalone policy: privacy rule";
57
+ return { ok: false, rule };
58
+ }
59
+ if (options.hostScreen) {
60
+ let host: unknown;
61
+ try { host = options.hostScreen(text); } catch { return { ok: false, rule: "hub privacy policy unavailable" }; }
62
+ const result = safeHostResult(host);
63
+ if (!result) return { ok: false, rule: "hub privacy policy unavailable" };
64
+ if (result.ok !== true) return { ok: false, rule: hostRule(result.rule) };
65
+ }
66
+ return { ok: true };
67
+ },
68
+ };
69
+ }
70
+
71
+ function safeJson(value: unknown): string | undefined {
72
+ try { return JSON.stringify(value); } catch { return undefined; }
73
+ }
74
+
75
+ function safeHostResult(value: unknown): { ok: boolean; rule?: string } | undefined {
76
+ if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value)) return undefined;
77
+ const prototype = Object.getPrototypeOf(value);
78
+ if (prototype !== Object.prototype && prototype !== null || Object.getOwnPropertySymbols(value).length) return undefined;
79
+ const descriptors = Object.getOwnPropertyDescriptors(value);
80
+ if (Object.keys(descriptors).some(key => !["ok", "rule"].includes(key))) return undefined;
81
+ for (const descriptor of Object.values(descriptors)) if (!descriptor.enumerable || !("value" in descriptor)) return undefined;
82
+ const ok = descriptors.ok?.value;
83
+ const rule = descriptors.rule?.value;
84
+ if (typeof ok !== "boolean" || rule !== undefined && typeof rule !== "string") return undefined;
85
+ return { ok, ...(typeof rule === "string" ? { rule } : {}) };
86
+ }
87
+
88
+ /** Project the package's bounded physical-row inspection into IDs and public rule categories only. */
89
+ export function inspectDeclinedMemoryRows(store: MemoryStore, policy: ReturnType<typeof createMemorySyncPolicy>, policyVersion?: () => string): {
90
+ rows: { id: string; rule: string }[];
91
+ declinedIdsFingerprint: string;
92
+ complete: boolean;
93
+ reason?: string;
94
+ } {
95
+ const report = inspectSyncPolicy(store, {
96
+ screen: row => policy.screen(row, true).ok,
97
+ ...(policyVersion ? { policyVersion } : {}),
98
+ });
99
+ if (!report.complete) return { rows: [], declinedIdsFingerprint: fingerprint([]), complete: false, reason: report.reason };
100
+ return {
101
+ rows: report.declined.map(row => ({ id: row.id, rule: row.categories.join(", ") })),
102
+ declinedIdsFingerprint: report.fingerprint,
103
+ complete: true,
104
+ };
105
+ }
106
+
107
+ function fingerprint(rows: readonly { id: string }[]): string {
108
+ return createHash("sha256").update([...rows.map(row => row.id)].sort().join("\n")).digest("hex");
109
+ }
@@ -0,0 +1,145 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ /** Safe, process-local view of the most recent machine memory sync. */
4
+ export type MemorySyncCause = "declined" | "remote" | "binding" | "stopped" | "transport" | "unknown";
5
+ export interface MemorySyncPlacementCounts { withheldCount: number; exportedCount: number }
6
+ export type MemorySyncOutcome =
7
+ | { state: "notrun" }
8
+ | { state: "not-configured"; at: number }
9
+ | { state: "stopped"; at: number }
10
+ | { state: "ok"; at: number; withheldCount?: number; exportedCount?: number }
11
+ | { state: "failed"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsKnown?: false; placementActive?: false };
12
+
13
+ export interface MemorySyncState {
14
+ outcome: MemorySyncOutcome;
15
+ /** Private dedupe key: never include this in a status or log line. */
16
+ failureSignature?: string;
17
+ }
18
+
19
+ export type MemorySyncEvent =
20
+ | { type: "success"; at: number; withheldCount?: number; exportedCount?: number }
21
+ | { type: "quiet"; at: number; state: "not-configured" | "stopped" }
22
+ | { type: "failure"; at: number; cause: MemorySyncCause; declinedCount?: number; declinedIdsFingerprint?: string; declinedIdsKnown?: false; failureDetailHash?: string; placementActive?: false };
23
+
24
+ export const initialMemorySyncState = (): MemorySyncState => ({ outcome: { state: "notrun" } });
25
+ const MAX_DECLINED_ROWS = 20_000;
26
+
27
+ function placementCounts(withheldCount: unknown, exportedCount: unknown): MemorySyncPlacementCounts | undefined {
28
+ if (typeof withheldCount !== "number" || !Number.isSafeInteger(withheldCount) || withheldCount < 0
29
+ || typeof exportedCount !== "number" || !Number.isSafeInteger(exportedCount) || exportedCount < 0) return undefined;
30
+ return { withheldCount, exportedCount };
31
+ }
32
+
33
+ /** Project only public counts from a completed placement publication, never its retained hashes or policy metadata. */
34
+ export function memorySyncPlacementCounts(value: unknown): MemorySyncPlacementCounts | undefined {
35
+ try {
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
37
+ const prototype = Object.getPrototypeOf(value);
38
+ if (prototype !== Object.prototype && prototype !== null) return undefined;
39
+ const read = (key: string): unknown => {
40
+ const field = Object.getOwnPropertyDescriptor(value, key);
41
+ return field?.enumerable && "value" in field ? field.value : undefined;
42
+ };
43
+ const outcome = read("outcome");
44
+ if (outcome !== "committed" && outcome !== "not-needed") return undefined;
45
+ return placementCounts(read("withheld"), read("exported"));
46
+ } catch { return undefined; }
47
+ }
48
+
49
+ /** Reduces one completed sync. The boolean says whether this failure is a new loggable episode. */
50
+ export function reduceMemorySync(state: MemorySyncState, event: MemorySyncEvent): { state: MemorySyncState; logFailure: boolean } {
51
+ if (event.type === "success") return { state: { outcome: { state: "ok", at: event.at, ...placementCounts(event.withheldCount, event.exportedCount) } }, logFailure: false };
52
+ if (event.type === "quiet") return { state: { outcome: { state: event.state, at: event.at } }, logFailure: false };
53
+ const signature = [event.cause, event.declinedCount ?? "", event.declinedIdsFingerprint ?? (event.declinedIdsKnown === false ? "ids-unknown" : ""), event.failureDetailHash ?? ""].join("\0");
54
+ const changed = state.failureSignature !== signature;
55
+ return {
56
+ state: { outcome: { state: "failed", at: event.at, cause: event.cause, ...(event.declinedCount === undefined ? {} : { declinedCount: event.declinedCount }), ...(event.declinedIdsKnown === false ? { declinedIdsKnown: false as const } : {}), ...(event.cause === "declined" && event.placementActive === false ? { placementActive: false as const } : {}) }, failureSignature: signature },
57
+ logFailure: changed,
58
+ };
59
+ }
60
+
61
+ export interface MemorySyncView {
62
+ state: "disabled" | "unavailable" | "notrun" | "not-configured" | "stopped" | "ok" | "failed" | "unknown";
63
+ at?: number;
64
+ cause?: MemorySyncCause;
65
+ declinedCount?: number;
66
+ declinedIdsKnown?: false;
67
+ withheldCount?: number;
68
+ exportedCount?: number;
69
+ placementActive?: false;
70
+ nextStep?: string;
71
+ }
72
+
73
+ export function memorySyncNextStep(cause: MemorySyncCause): string {
74
+ return cause === "declined" ? "ahub memory declined"
75
+ : cause === "remote" ? "configure a private sync remote"
76
+ : cause === "binding" ? "ahub memory profile-check"
77
+ : cause === "stopped" ? "retry after hub restart"
78
+ : "run ahub memory sync as a person";
79
+ }
80
+
81
+ /** Public status projection. It never includes an id, error string, path, home or fingerprint. */
82
+ export function memorySyncView(state: MemorySyncState, availability: "enabled" | "disabled" | "unavailable" | "unknown"): MemorySyncView {
83
+ if (availability !== "enabled") return { state: availability };
84
+ const outcome = state.outcome;
85
+ if (outcome.state === "notrun") return { state: "notrun" };
86
+ if (outcome.state === "not-configured") return { state: "not-configured", at: outcome.at, nextStep: memorySyncNextStep("remote") };
87
+ if (outcome.state === "stopped") return { state: "stopped", at: outcome.at, nextStep: memorySyncNextStep("stopped") };
88
+ if (outcome.state === "ok") return { state: "ok", at: outcome.at, ...placementCounts(outcome.withheldCount, outcome.exportedCount) };
89
+ return {
90
+ state: "failed", at: outcome.at, cause: outcome.cause,
91
+ ...(outcome.declinedCount === undefined ? {} : { declinedCount: outcome.declinedCount }),
92
+ ...(outcome.declinedIdsKnown === false ? { declinedIdsKnown: false } : {}),
93
+ ...(outcome.cause === "declined" && outcome.placementActive === false ? { placementActive: false } : {}),
94
+ nextStep: memorySyncNextStep(outcome.cause),
95
+ };
96
+ }
97
+
98
+ /** Map the upstream free-form reason into a closed, non-sensitive status cause. */
99
+ export function classifyMemorySyncFailure(error: unknown): { cause: "declined"; declinedCount: number } | { cause: Exclude<MemorySyncCause, "declined">; declinedCount?: never } {
100
+ const text = typeof error === "string" ? error : "";
101
+ const declined = /^the configured privacy policy declines (\d+) long-term entr(y|ies) \([^()\r\n]*\); fix the entry or the policy, then sync again$/.exec(text);
102
+ if (declined) {
103
+ const count = Number(declined[1]);
104
+ const singular = declined[2] === "y";
105
+ if (Number.isSafeInteger(count) && count > 0 && count <= MAX_DECLINED_ROWS && (count === 1) === singular)
106
+ return { cause: "declined", declinedCount: count };
107
+ return { cause: "unknown" };
108
+ }
109
+ if (text === "stopped") return { cause: "stopped" };
110
+ if (text === "no sync remote: a person sets one with `amem sync --remote <private git url>`"
111
+ || text === "no generation sync remote configured"
112
+ || text === "the sync remote is public; memory syncs only to a private remote"
113
+ || text === "generation sync remote is invalid"
114
+ || text === "generation sync requires positively verified private remote visibility"
115
+ || text === "generation remote lookup failed"
116
+ || text === "generation remote branch identity mismatch") return { cause: "remote" };
117
+ if (text === "binding changed" || text === "sharded sync binding or policy unavailable or changed"
118
+ || text === "generation profile unavailable") return { cause: "binding" };
119
+ if (["git init failed: ", "the remote could not be set: ", "git add failed: ", "commit failed: ", "fetch failed: ", "checkout failed: ", "merge failed: ", "push failed: ",
120
+ "generation staging failed", "generation commit failed", "generation fetch failed", "generation push failed"].some(prefix => text.startsWith(prefix))) return { cause: "transport" };
121
+ return { cause: "unknown" };
122
+ }
123
+ export function memorySyncFailureDetailHash(error: unknown): string {
124
+ const canonical = typeof error === "string" ? error : "<missing-or-non-string-error>";
125
+ return createHash("sha256").update(canonical).digest("hex");
126
+ }
127
+
128
+ export interface DeclinedMemoryInspection {
129
+ rows: readonly { id: string }[];
130
+ declinedIdsFingerprint: string;
131
+ complete: boolean;
132
+ }
133
+
134
+ /** Use complete all-target inspection when available; otherwise keep the package's first-target count and say ids are unknown. */
135
+ export function memorySyncDeclinedDetails(
136
+ canonical: { cause: "declined"; declinedCount: number },
137
+ inspection?: DeclinedMemoryInspection,
138
+ ): { cause: "declined"; declinedCount: number; declinedIdsFingerprint?: string; declinedIdsKnown?: false } {
139
+ const fingerprintValid = /^[a-f0-9]{64}$/.test(inspection?.declinedIdsFingerprint ?? "");
140
+ const idsAvailable = !!inspection && inspection.rows.every(row => typeof row.id === "string" && row.id !== "(no id)");
141
+ if (inspection?.complete && inspection.rows.length > 0 && inspection.rows.length <= MAX_DECLINED_ROWS && fingerprintValid && idsAvailable) {
142
+ return { cause: "declined", declinedCount: inspection.rows.length, declinedIdsFingerprint: inspection.declinedIdsFingerprint };
143
+ }
144
+ return { ...canonical, declinedIdsKnown: false };
145
+ }
@@ -37,7 +37,7 @@ export class NoWriteTurns {
37
37
  }
38
38
 
39
39
 
40
- const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff", "hub_experiment_record"]);
40
+ const OWNER_TOOL_EFFECTS = new Set(["hub_task_propose", "hub_task_decline", "hub_task_done", "hub_task_cancel", "hub_task_assign", "hub_task_escalate", "hub_review", "hub_checkpoint", "hub_remember", "hub_memory_correct", "hub_check_start", "hub_check_cancel", "hub_sdlc_record", "hub_plan_review", "hub_backlog_plan", "hub_backlog_pull", "hub_backlog_undo", "hub_backlog_split", "hub_backlog_return_untouched", "hub_release_new", "hub_release_configure", "hub_release_assign", "hub_release_cut", "hub_study_handoff", "hub_experiment_record", "hub_evidence_record"]);
41
41
  /** A read does not prove progress; failed/pending mutations are handled conservatively by the host. */
42
42
  export function ownerToolEffect(op: string, input: Record<string, unknown> | undefined): boolean {
43
43
  return OWNER_TOOL_EFFECTS.has(op) || op === "hub_task_accept" && !!input?.plan && typeof input.plan === "object" && Object.keys(input.plan).length > 0;
@@ -79,6 +79,14 @@ export const BUS_TERMINAL_HISTORY_LIMIT_SETTING = busNumber({
79
79
  key: "bus.terminal_history_limit", path: ["bus", "terminal_history_limit"], label: "Unprotected terminal delivery history target",
80
80
  max: 2048, defaultValue: 2048, unit: "rows", inventory: "src/hub/delivery-journal.ts:TERMINAL_CAP",
81
81
  });
82
+ /** #1172: bounded wait for a push channel's explicit native delivery confirmation. */
83
+ export const BUS_ACCEPTED_SETTLE_MS_SETTING = {
84
+ ...busNumber({
85
+ key: "bus.accepted_settle_ms", path: ["bus", "accepted_settle_ms"], label: "Accepted channel delivery confirmation timeout",
86
+ max: 86_400_000, defaultValue: 600_000, unit: "milliseconds", inventory: "src/hub/operational-settings.ts:BUS_ACCEPTED_SETTLE_MS_SETTING.defaultValue",
87
+ }),
88
+ min: 1000,
89
+ } as const satisfies OperationalNumberSetting;
82
90
 
83
91
  /**
84
92
  * #489 H4, recovery and upgrade waits. These are read by the upgrade coordinator (a CLI process) from the project's own config at the START of each wait, so a change applies at the next wait and a wait
@@ -178,7 +186,7 @@ export const CONSOLE_ATTENTION_SETTING = consoleNumber({
178
186
 
179
187
  /** Fixed metadata; never registration from a file, request, adapter or environment variable. */
180
188
  export const OPERATIONAL_NUMBERS: readonly OperationalNumberSetting[] = [GROOM_FYI_WINDOW_SETTING, DEMOTE_AT_SETTING, DEMOTION_HALF_LIFE_SETTING, CONTRADICTION_WINDOW_SETTING, RESCREEN_MAX_SETTING, SPLIT_MIN_SETTING, SPLIT_FAILURE_SHARE_SETTING,
181
- BUS_MAX_ATTEMPTS_SETTING, BUS_DIGEST_MAX_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_NOTES_KEPT_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING,
189
+ BUS_MAX_ATTEMPTS_SETTING, BUS_DIGEST_MAX_SETTING, BUS_PULL_BUDGET_BYTES_SETTING, BUS_NOTES_KEPT_SETTING, BUS_TERMINAL_HISTORY_LIMIT_SETTING, BUS_ACCEPTED_SETTLE_MS_SETTING,
182
190
  RECOVERY_RECONNECT_WAIT_SETTING, RECOVERY_UNMANAGED_RECONNECT_WAIT_SETTING, RECOVERY_CONTROL_TIMEOUT_SETTING, RECOVERY_SHUTDOWN_WAIT_SETTING, RECOVERY_TERMINAL_IDLE_WAIT_SETTING,
183
191
  ACP_TOOL_CEILING_SETTING, ACP_ACTIVITY_BATCH_SETTING, JCODE_STEER_TIMEOUT_SETTING,
184
192
  MODEL_COOLDOWN_FAILURES_SETTING, MODEL_COOLDOWN_SETTING, MODEL_COOLDOWN_CAP_SETTING, MODEL_TERMINAL_HISTORY_SETTING, INFERENCE_BACKOFF_SETTING, INFERENCE_DIGEST_ITEMS_SETTING, INFERENCE_DIGEST_CHARS_SETTING,
@@ -197,7 +205,7 @@ export const taskOperationalReaders = (config: () => TaskOperationalConfig) => (
197
205
  splitMinObservations: () => config().tasks.split_min_observations ?? SPLIT_MIN_SETTING.defaultValue,
198
206
  splitMaxFailureShare: () => config().tasks.split_max_failure_share ?? SPLIT_FAILURE_SHARE_SETTING.defaultValue,
199
207
  });
200
- export interface BusOperationalConfig { bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number } }
208
+ export interface BusOperationalConfig { bus?: { max_attempts?: number; digest_max?: number; pull_budget_bytes?: number; notes_kept?: number; terminal_history_limit?: number; accepted_settle_ms?: number } }
201
209
  /** Read accepted configuration at the next operation; a reload itself never trims or retries work. */
202
210
  export const busOperationalReaders = (config: () => BusOperationalConfig) => ({
203
211
  maxAttempts: () => config().bus?.max_attempts ?? BUS_MAX_ATTEMPTS_SETTING.defaultValue,
@@ -205,6 +213,7 @@ export const busOperationalReaders = (config: () => BusOperationalConfig) => ({
205
213
  pullBudgetBytes: () => config().bus?.pull_budget_bytes ?? BUS_PULL_BUDGET_BYTES_SETTING.defaultValue,
206
214
  notesKept: () => config().bus?.notes_kept ?? BUS_NOTES_KEPT_SETTING.defaultValue,
207
215
  terminalHistoryLimit: () => config().bus?.terminal_history_limit ?? BUS_TERMINAL_HISTORY_LIMIT_SETTING.defaultValue,
216
+ acceptedSettleMs: () => config().bus?.accepted_settle_ms ?? BUS_ACCEPTED_SETTLE_MS_SETTING.defaultValue,
208
217
  });
209
218
  export interface RecoveryOperationalConfig { recovery?: { reconnect_wait_s?: unknown; unmanaged_reconnect_wait_s?: unknown; control_timeout_s?: unknown; shutdown_wait_s?: unknown; terminal_idle_wait_s?: unknown } }
210
219
  export interface RecoveryWaits { reconnectWaitMs: number; unmanagedReconnectWaitMs: number; controlTimeoutMs: number; shutdownWaitMs: number; terminalIdleWaitMs: number }
@@ -1,3 +1,4 @@
1
+ import { isReviewObserverExcluded } from "./memory-policy.ts";
1
2
  import { sanitize } from "./envelope.ts";
2
3
  import type { BusEvent } from "./bus.ts";
3
4
  import { CLASSES, type Task } from "./board.ts";
@@ -77,7 +78,17 @@ function metadataValue(type:string,key:string,value:unknown):boolean {
77
78
 
78
79
  /** Shared by the daemon's existing console stream and operational projection tests. */
79
80
  export function redactBusEvent(e:BusEvent):BusEvent {
80
- if (!("env" in e) || !e.env.private) return e;
81
+ if (!("env" in e)) return e;
82
+ if (isReviewObserverExcluded(e.env)) {
83
+ const { refs, ...env } = e.env;
84
+ return { ...e, env: { ...env, refs: { ...(refs?.reviewPackage ? { reviewPackage: refs.reviewPackage } : {}), ...(refs?.reviewResponse ? { reviewResponse: refs.reviewResponse } : {}), ...(refs?.task && /^[0-9]+$/.test(refs.task) ? { task: refs.task } : {}) }, body: "[review package: inspect task review comments]" } };
85
+ }
86
+ // #1053: the person's answer to a question is theirs and the asking peer's: the console tail and the dashboard feed show a stub, whoever the envelope is private for.
87
+ if (e.env.kind === "answer" || e.env.refs?.question) {
88
+ const set = e.env.refs?.question;
89
+ return { ...e, env: { ...e.env, refs: set ? { question: set } : undefined, body: `[question ${set ?? "answer"}: body shown only to its peer]` } };
90
+ }
91
+ if (!e.env.private) return e;
81
92
  const {refs,...env}=e.env;
82
93
  const task=refs?.task && /^[0-9]+$/.test(refs.task)?refs.task:undefined;
83
94
  return {...e,env:{...env,...(task?{refs:{task}}:{}),body:`[private${task?`: task #${task}, see ahub task show ${task}`:""}]`}};
package/src/hub/peers.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { DeliveryFailureCause } from "./delivery-failure.ts";
1
2
  import type { Envelope, EnvelopeOpts, PeerId, PeerState } from "./envelope.ts";
2
3
  import type { PermissionMode } from "./permission-mode.ts";
3
4
  import type { StartForm } from "../adapters/registry-types.ts";
@@ -9,6 +10,8 @@ export interface DeliveryReceipt {
9
10
  id: string;
10
11
  state: "accepted" | "completed" | "failed_safe" | "needs_review";
11
12
  reason?: string;
13
+ /** Public-safe transport cause only, never native error text. */
14
+ failureCause?: DeliveryFailureCause;
12
15
  /** A provider refused before acceptance for quota; requeue without spending poison retries while paused. */
13
16
  quota?: true;
14
17
  }
@@ -18,6 +21,18 @@ export interface OwnedToolsLaunch {
18
21
  launchId: string; generation: string; sessionId: string; signal: AbortSignal; task?: number; permissionHook: true;
19
22
  }
20
23
 
24
+ /** Display-only identity for a live channel connection waiting for a person's handover. */
25
+ export interface PeerStandbySession { id: string; pid?: number; since: number; }
26
+
27
+ /** Opaque, generation-fenced claim produced by a peer's person-requested takeover port. */
28
+ export interface PeerTakeoverClaim {
29
+ standbyId: string;
30
+ socket: object;
31
+ generation: string | number;
32
+ isCurrent(): boolean;
33
+ attach(): boolean;
34
+ }
35
+
21
36
  export interface FrontendProxyPort {
22
37
  readonly url: string;
23
38
  readonly attached: boolean;
@@ -29,6 +44,14 @@ export interface FrontendProxyPort {
29
44
 
30
45
  export interface PeerAdapter {
31
46
  readonly id: PeerId;
47
+ /** Live waiting sessions, for display only; ids and pids grant no native identity or authority. */
48
+ readonly standbySessions?: readonly PeerStandbySession[];
49
+ /** Person-authorized takeover port. The host still performs its own admission and claim fences. */
50
+ takeover?(standbyId: string | undefined, eligible: (socket: object) => boolean): PeerTakeoverClaim | undefined;
51
+ /** Exact transport ownership check for guarding a selected standby through its asynchronous preface. */
52
+ ownsSocket?(socket: object): boolean;
53
+ /** The explicit own-delivery settlement surface; only a live push channel session opts in. */
54
+ readonly acceptedSettlement?: "explicit";
32
55
  /** Host-owned room transport binding; remote ids never gain local role/console authority. */
33
56
  readonly remoteRoom?: string;
34
57
  /** Verified roster fingerprint, pinned separately from the reusable display label. */
@@ -95,7 +118,7 @@ export interface PeerAdapter {
95
118
  /** Set by the bus. */
96
119
  onState?: (state: PeerState) => void;
97
120
  /** Set by the bus. A delivery that had resolved turned out not to reach the agent: put it back. */
98
- onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
121
+ onFailed?: (envs: Envelope[], failure?: { quota?: true; failureCause?: DeliveryFailureCause }) => void;
99
122
  /** Correlated durable-delivery lifecycle, emitted only when the bus supplied an id. */
100
123
  onDelivery?: (receipt: DeliveryReceipt) => void;
101
124
  /** Set by the bus (#878): a native session title or current plan item the adapter observed; `reset` on a new session. */
@@ -113,7 +136,7 @@ export abstract class BasePeer implements PeerAdapter {
113
136
  /** Returns a reason (a string) when the hub refused the message (issue #38). */
114
137
  onMessage?: (body: string, opts?: EnvelopeOpts) => unknown;
115
138
  onState?: (state: PeerState) => void;
116
- onFailed?: (envs: Envelope[], failure?: { quota?: true }) => void;
139
+ onFailed?: (envs: Envelope[], failure?: { quota?: true; failureCause?: DeliveryFailureCause }) => void;
117
140
  onDelivery?: (receipt: DeliveryReceipt) => void;
118
141
  onDoing?: (kind: DoingKind, text?: unknown) => void;
119
142
  private _state: PeerState = "offline";
@@ -4,7 +4,7 @@ import type { Task } from "./board.ts";
4
4
 
5
5
  export const BACKLOG_SPLIT_SCREEN_TIMEOUT_MS = 8000;
6
6
 
7
- export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "deps", "reserved", "sdlc"] as const;
7
+ export const PLANNING_FIELDS = ["rank", "priority", "release", "plannedStart", "due", "pinned", "needsPerson", "deps", "reserved", "sdlc"] as const;
8
8
  export interface PlanningFields {
9
9
  rank?: number | null;
10
10
  priority?: number | null;
@@ -12,6 +12,8 @@ export interface PlanningFields {
12
12
  plannedStart?: number | null;
13
13
  due?: number | null;
14
14
  pinned?: boolean;
15
+ /** #1176: a leg only the person can run (live receipts, their accounts, machines or approvals). It keeps its reservation as the "who will run it" hint, but no agent is ever offered it. */
16
+ needsPerson?: boolean;
15
17
  deps?: number[];
16
18
  reserved?: string | null;
17
19
  sdlc?: PlanningIssueIdentity;
@@ -61,6 +63,9 @@ export function planningPatch(input: unknown = {}): PlanningPatch {
61
63
  } else if (key === "pinned") {
62
64
  if (typeof value !== "boolean") throw new Error("pinned must be a boolean");
63
65
  result.pinned = value;
66
+ } else if (key === "needsPerson") {
67
+ if (typeof value !== "boolean") throw new Error("needsPerson must be a boolean");
68
+ result.needsPerson = value;
64
69
  } else {
65
70
  if (value !== null && (typeof value !== "number" || !Number.isSafeInteger(value) || value < (key === "release" || key === "plannedStart" || key === "due" ? 1 : 0) || (key === "priority" && value > 3) || (["plannedStart", "due"].includes(key) && value > 8_640_000_000_000_000))) throw new Error(`invalid planning field ${key}`);
66
71
  result[key] = value as number | null;
@@ -0,0 +1,201 @@
1
+ import { HUB, newEnvelope, USER, type Envelope, type PeerId } from "./envelope.ts";
2
+ import { PreEffectToolRefusal } from "./tool-refusal.ts";
3
+ import { QuestionWaitDetached } from "./question-detach.ts";
4
+ import { parseAsk, QUESTION_SET, type AnswerResult, type QuestionAnswer, type QuestionReason, type QuestionRow, type QuestionStore, type QuestionSurface } from "./questions.ts";
5
+
6
+ /**
7
+ * #1053 T3: what `hub_ask` does with the store. The store owns the row and its one terminal transition; this desk owns the waiting call (in memory, never
8
+ * persisted), the person's supersede, the cancel on stop or abort, the expiry sweep and the hub-built envelope a late answer needs. It reaches the rest of the
9
+ * daemon only through `DeskPorts`, so a fake peer and a fake bus exercise every path. Nothing here logs: question and answer text stay in the store and in the
10
+ * envelope addressed to the asking peer.
11
+ */
12
+
13
+ export type AskResult =
14
+ | { status: "answered"; questionSet: string; answers: QuestionAnswer[]; answeredBy: "person"; surface: QuestionSurface }
15
+ | { status: "pending"; questionSet: string }
16
+ | { status: "superseded"; questionSet: string }
17
+ | { status: "cancelled"; questionSet: string; reason: QuestionReason }
18
+ | { status: "unavailable"; reason: QuestionReason };
19
+
20
+ /** The events the desk reports: ids, peers, counts and a closed reason, never a prompt, an option or an answer. */
21
+ export interface QuestionEvent {
22
+ type: "question";
23
+ set: string;
24
+ peer: string;
25
+ event: "asked" | "answered" | "pending" | "superseded" | "cancelled" | "expired" | "unavailable";
26
+ latencyMs?: number;
27
+ surface?: QuestionSurface;
28
+ reason?: QuestionReason;
29
+ questions?: number;
30
+ private?: boolean;
31
+ }
32
+
33
+ export interface DeskPorts {
34
+ store: QuestionStore;
35
+ now(): number;
36
+ /** How long a `hub_ask` call of this peer waits before it answers `pending`. */
37
+ waitMs(peer: PeerId): number;
38
+ ttlMs(): number;
39
+ /** The peer's native session id; "" when the adapter reports none or the peer is offline: unknown never cancels. */
40
+ session(peer: PeerId): string;
41
+ unattended(peer: PeerId): boolean;
42
+ /** The peer's current turn is a PII turn (`piiTurn`, from the tool call), or it holds an open PII task, or `task` is one. */
43
+ isPrivate(peer: PeerId, task: number | undefined, piiTurn?: boolean): boolean;
44
+ /** The hub is stopping or in recovery: a new question is refused before any row exists. */
45
+ held(): boolean;
46
+ /** Publish a hub-built envelope; throws when the bus refuses it (the row keeps its owed flag and the sweep retries). */
47
+ publish(env: Envelope): void;
48
+ event(e: QuestionEvent): void;
49
+ /** Peers whose queue can still take an envelope; an unknown peer id never gets a row. */
50
+ peerKnown(peer: PeerId): boolean;
51
+ }
52
+
53
+ interface Waiter { resolve(result: AskResult): void; timer: ReturnType<typeof setTimeout>; detach(): void }
54
+ /** Settled rows are kept this long for `ahub` readbacks, then pruned; an open row or an owed envelope is never pruned. */
55
+ const KEEP_SETTLED_MS = 7 * 86_400_000;
56
+
57
+ /** The late-answer envelope. `from` is the person, `kind` is `answer`, and the set rides in `refs.question` and as the body's first line `reply_to: <set>` (an envelope has no reply field of its own: `reply_to` is the `hub_send` argument that resolves an envelope id). The id is derived from the set so a republish after a crash is dropped by the bus's own dedupe. */
58
+ export function answerEnvelope(row: QuestionRow): Envelope {
59
+ const result: AskResult = { status: "answered", questionSet: row.set, answers: row.answers ?? [], answeredBy: "person", surface: row.surface ?? "console" };
60
+ const body = `reply_to: ${row.set}\nYour question ${row.set} was answered by the person${row.surface ? ` (${row.surface})` : ""}. Use this answer; do not ask it again.\n${JSON.stringify(result)}`;
61
+ return { ...newEnvelope(USER, body, { to: [row.peer], kind: "answer", priority: "important", refs: { question: row.set }, ...(row.private ? { private: true } : {}) }), id: `answer-${row.set}` };
62
+ }
63
+ /** The expiry notice. A hub notice of kind `status`, never an answer: the agent decides for itself. */
64
+ export function expiryEnvelope(row: QuestionRow, ttlMs: number): Envelope {
65
+ const hours = Math.max(1, Math.round(ttlMs / 3_600_000));
66
+ const body = `Your question ${row.set} expired unanswered after about ${hours} hour${hours === 1 ? "" : "s"}. This is not an answer: decide yourself and say so.`;
67
+ return { ...newEnvelope(HUB, body, { to: [row.peer], kind: "status", priority: "status", refs: { question: row.set }, ...(row.private ? { private: true } : {}) }), id: `question-expired-${row.set}` };
68
+ }
69
+
70
+ export class QuestionDesk {
71
+ private readonly waiters = new Map<string, Waiter>();
72
+ private closed = false;
73
+ constructor(private readonly p: DeskPorts) {}
74
+
75
+ /** The waiting calls right now (a row without one is orphaned: its answer takes the late path). */
76
+ waiting(): number { return this.waiters.size; }
77
+
78
+ async ask(peer: PeerId, raw: unknown, ctx: { signal?: AbortSignal; piiTurn?: boolean } = {}): Promise<AskResult> {
79
+ if (this.closed || this.p.held()) throw new PreEffectToolRefusal("the hub is stopping or recovering; ask again once it is back");
80
+ if (peer === USER || peer === HUB || !this.p.peerKnown(peer)) throw new PreEffectToolRefusal("hub_ask is for the agents of this project; the person answers with the console");
81
+ const parsed = parseAsk(raw);
82
+ if (!parsed.ok) throw new PreEffectToolRefusal(parsed.error);
83
+ // `--unattended` means nobody is watching: no row, no wait. The agent decides and says so.
84
+ if (this.p.unattended(peer)) {
85
+ this.p.event({ type: "question", set: "-", peer, event: "unavailable", reason: "unattended", questions: parsed.value.questions.length });
86
+ return { status: "unavailable", reason: "unattended" };
87
+ }
88
+ if (ctx.signal?.aborted) throw new PreEffectToolRefusal("the turn was aborted before the question was asked");
89
+ const created = this.p.store.ask({ peer, session: this.p.session(peer), ...(parsed.value.task !== undefined ? { task: parsed.value.task } : {}), private: this.p.isPrivate(peer, parsed.value.task, ctx.piiTurn === true), questions: parsed.value.questions }, this.p.now());
90
+ if ("error" in created) throw new PreEffectToolRefusal(created.error);
91
+ const row = created;
92
+ this.p.event({ type: "question", set: row.set, peer, event: "asked", questions: row.questions.length, private: row.private });
93
+ return new Promise<AskResult>(resolve => {
94
+ const waitMs = Math.max(1000, this.p.waitMs(peer));
95
+ const onAbort = () => {
96
+ if (!this.waiters.has(row.set)) return;
97
+ if (ctx.signal?.reason instanceof QuestionWaitDetached) { this.p.event({ type: "question", set: row.set, peer, event: "pending", latencyMs: this.p.now() - row.created, private: row.private }); this.settleWait(row.set, { status: "pending", questionSet: row.set }); }
98
+ else this.cancelRow(row.set, "turn_aborted");
99
+ };
100
+ const timer = setTimeout(() => {
101
+ // The window ended: the row stays open and its answer arrives as an envelope.
102
+ if (!this.waiters.has(row.set)) return;
103
+ this.p.event({ type: "question", set: row.set, peer, event: "pending", latencyMs: this.p.now() - row.created, private: row.private });
104
+ this.settleWait(row.set, { status: "pending", questionSet: row.set });
105
+ }, waitMs);
106
+ timer.unref?.();
107
+ ctx.signal?.addEventListener("abort", onAbort, { once: true });
108
+ this.waiters.set(row.set, { resolve, timer, detach: () => ctx.signal?.removeEventListener("abort", onAbort) });
109
+ });
110
+ }
111
+
112
+ /**
113
+ * The person's answer, from the console role only (the daemon checks the role before it calls this). Of any number of concurrent answers, stops and expiries,
114
+ * the store's compare-and-set lets exactly one win; a loser is told the row's real terminal state. An answer to a row with no waiting call is the late path.
115
+ */
116
+ answer(set: unknown, raw: unknown, surface: QuestionSurface, person?: string): AnswerResult {
117
+ if (typeof set !== "string" || !QUESTION_SET.test(set)) return { ok: false, error: "no such question" };
118
+ const current = this.p.store.get(set);
119
+ if (current?.state === "open" && this.sessionChanged(current)) {
120
+ this.cancelRow(set, "session_changed");
121
+ return { ok: false, error: "question is already cancelled", state: "cancelled" };
122
+ }
123
+ const late = !this.waiters.has(set);
124
+ const result = this.p.store.answer(set, raw, surface, this.p.now(), late, person);
125
+ if (!result.ok) return result;
126
+ this.p.event({ type: "question", set, peer: result.row.peer, event: "answered", latencyMs: this.p.now() - result.row.created, surface, ...(person ? { person } : {}), private: result.row.private });
127
+ if (late) this.publishOwed(result.row);
128
+ else this.settleWait(set, { status: "answered", questionSet: set, answers: result.row.answers ?? [], answeredBy: "person", surface });
129
+ return result;
130
+ }
131
+
132
+ /** The person sent these peers a normal message: every open question of each is superseded and its waiting call told. That message is delivered as usual. */
133
+ supersede(peers: readonly PeerId[]): void {
134
+ for (const peer of new Set(peers)) for (const row of this.p.store.settleFor(peer, "superseded", "superseded", this.p.now())) {
135
+ this.p.event({ type: "question", set: row.set, peer, event: "superseded", latencyMs: this.p.now() - row.created, private: row.private });
136
+ this.settleWait(row.set, { status: "superseded", questionSet: row.set });
137
+ }
138
+ }
139
+ /** A stop of the peer: its open questions are cancelled and its waiting calls told. */
140
+ cancelPeer(peer: PeerId, reason: QuestionReason): void {
141
+ for (const row of this.p.store.settleFor(peer, "cancelled", reason, this.p.now())) this.announceCancel(row, reason);
142
+ }
143
+ /** Peers with at least one open row, for a broadcast from the person. */
144
+ openPeers(): PeerId[] { return [...new Set(this.p.store.list({ state: "open" }, 200).map(row => row.peer))]; }
145
+
146
+ /** Expiry, session changes, envelopes still owed and old settled rows. Run on a timer and once at start. */
147
+ sweep(): void {
148
+ if (this.closed) return;
149
+ const now = this.p.now();
150
+ for (const row of this.p.store.expireDue(now, this.p.ttlMs())) {
151
+ this.p.event({ type: "question", set: row.set, peer: row.peer, event: "expired", latencyMs: now - row.created, reason: "expired", private: row.private });
152
+ // A waiting call carries the result itself, so no envelope is owed; an orphaned row gets the hub notice.
153
+ if (this.waiters.has(row.set)) { this.p.store.published(row.set); this.settleWait(row.set, { status: "cancelled", questionSet: row.set, reason: "expired" }); }
154
+ else this.publishOwed(row);
155
+ }
156
+ for (const row of this.p.store.list({ state: "open" }, 200)) if (this.sessionChanged(row)) this.cancelRow(row.set, "session_changed");
157
+ for (const row of this.p.store.owed()) this.publishOwed(row);
158
+ this.p.store.prune(now, KEEP_SETTLED_MS);
159
+ }
160
+ /** Start repair: a row whose session changed is cancelled, every other open row stays open for the late path, and an owed envelope goes out. */
161
+ repair(): void { this.sweep(); }
162
+
163
+ /** Shutdown: waiting calls end `pending` (their rows stay open and survive the restart); nothing new is admitted. */
164
+ close(): void {
165
+ this.closed = true;
166
+ this.endWaits();
167
+ }
168
+ /** Recovery `prepare`: waiting calls end `pending` now, so a restart never waits out a person's window; every row stays open for the late path. */
169
+ endWaits(): void {
170
+ for (const [set, waiter] of [...this.waiters]) { if (this.waiters.get(set) === waiter) this.settleWait(set, { status: "pending", questionSet: set }); }
171
+ }
172
+
173
+ private sessionChanged(row: QuestionRow): boolean {
174
+ if (!row.session) return false;
175
+ const now = this.p.session(row.peer);
176
+ return now !== "" && now !== row.session;
177
+ }
178
+ private cancelRow(set: string, reason: QuestionReason): void {
179
+ const row = this.p.store.settle(set, "cancelled", reason, this.p.now());
180
+ if (row) this.announceCancel(row, reason);
181
+ }
182
+ private announceCancel(row: QuestionRow, reason: QuestionReason): void {
183
+ this.p.event({ type: "question", set: row.set, peer: row.peer, event: "cancelled", latencyMs: this.p.now() - row.created, reason, private: row.private });
184
+ this.settleWait(row.set, { status: "cancelled", questionSet: row.set, reason });
185
+ }
186
+ private settleWait(set: string, result: AskResult): void {
187
+ const waiter = this.waiters.get(set);
188
+ if (!waiter) return;
189
+ this.waiters.delete(set);
190
+ clearTimeout(waiter.timer);
191
+ waiter.detach();
192
+ waiter.resolve(result);
193
+ }
194
+ /** Publish the envelope a row owes its peer, then mark it published. A refusal leaves the flag, and the sweep tries again. */
195
+ private publishOwed(row: QuestionRow): void {
196
+ try {
197
+ this.p.publish(row.state === "answered" ? answerEnvelope(row) : expiryEnvelope(row, this.p.ttlMs()));
198
+ this.p.store.published(row.set);
199
+ } catch { /* the bus refused it (recovery hold, storage error): the row still owes it */ }
200
+ }
201
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * #1053: the abort reason a transport gives when only the CLIENT of a waiting `hub_ask` is gone (Pi's bridge connection dropped), not the turn. The desk ends that wait
3
+ * `pending` and keeps the row open, so a later answer takes the late-envelope path instead of going to a dead response; any other abort cancels the row (`turn_aborted`).
4
+ * Its own file so an adapter can name it without importing the desk.
5
+ */
6
+ export class QuestionWaitDetached extends Error { constructor() { super("the client of a waiting question went away"); this.name = "QuestionWaitDetached"; } }