@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
@@ -9,6 +9,7 @@ import { KIMI_MODE_IDS } from "./definitions/kimi-native.ts";
9
9
  import { QWEN_MODE_IDS, qwenNativeIdentity } from "./definitions/qwen-native.ts";
10
10
  export { QwenIdentityError } from "./definitions/qwen-native.ts";
11
11
  import { BasePeer } from "../hub/peers.ts";
12
+ import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
12
13
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
13
14
  import { AcpOwnedGroup } from "./acp-owned-group.ts";
14
15
  import type { StartForm } from "./registry-types.ts";
@@ -637,8 +638,8 @@ export class AcpPeer extends BasePeer {
637
638
  this.cancelPermissions();
638
639
  this.activeLegacy = undefined; // the cancelled prompt is discarded, never returned by a later stop
639
640
  const durable = !!this.activeDeliveryId;
640
- if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout" });
641
- this.settleSteers("needs_review", "ACP turn watchdog timeout");
641
+ if (this.activeDeliveryId) this.delivery({ id: this.activeDeliveryId, state: "needs_review", reason: "ACP turn watchdog timeout", failureCause: "timeout" });
642
+ this.settleSteers("needs_review", "ACP turn watchdog timeout", "timeout");
642
643
  this.activeDeliveryId = undefined;
643
644
  this.notify("session/cancel", { sessionId: this.sessionId });
644
645
  this.turn++; // whatever the cancelled prompt still reports is stale
@@ -693,9 +694,9 @@ export class AcpPeer extends BasePeer {
693
694
  if (deliveryId) { this.steerIds.push(deliveryId); this.delivery({ id: deliveryId, state: "accepted" }); }
694
695
  }
695
696
 
696
- private settleSteers(state: "completed" | "needs_review", reason?: string): void {
697
+ private settleSteers(state: "completed" | "needs_review", reason?: string, failureCause?: DeliveryFailureCause): void {
697
698
  const ids = this.steerIds; this.steerIds = [];
698
- for (const id of ids) this.delivery({ id, state, ...(state === "needs_review" ? { reason: reason ?? "steered turn did not complete normally" } : {}) });
699
+ for (const id of ids) this.delivery({ id, state, ...(state === "needs_review" ? { reason: reason ?? "steered turn did not complete normally", ...(failureCause ? { failureCause } : {}) } : {}) });
699
700
  }
700
701
 
701
702
  private acceptDelivery(): void {
@@ -1,18 +1,21 @@
1
- import { BasePeer, type PeerAdapter } from "../hub/peers.ts";
1
+ import { BasePeer, type PeerAdapter, type PeerStandbySession, type PeerTakeoverClaim } from "../hub/peers.ts";
2
2
  import type { Envelope } from "../hub/envelope.ts";
3
3
  import { definitionOf } from "./registry.ts";
4
4
  import type { StartForm } from "./registry-types.ts";
5
+ import type { ClaudeChannelEvidence } from "./claude-channel-evidence.ts";
5
6
 
6
7
  /** Only the control socket operations the peer owns; authentication remains in the daemon. */
7
8
  export interface ChannelSocket {
8
9
  readyState: number;
9
- data: { launchId?: string; protocol?: number };
10
+ data: { launchId?: string; protocol?: number; standbyId?: string; retainStandby?: boolean; clientPid?: number; channel?: boolean; channelEvidence?: ClaudeChannelEvidence; cwd?: string; peerGeneration?: string | number; detachedByPerson?: boolean };
10
11
  send(data: string): unknown;
11
12
  close(code?: number, reason?: string): void;
12
13
  }
13
14
 
14
15
  /** A peer that lives in another process and attaches over the control WS (the Claude channel plugin). */
15
16
  export class WsPeer extends BasePeer {
17
+ private static readonly MAX_STANDBY_SESSIONS = 8;
18
+ readonly acceptedSettlement = "explicit" as const;
16
19
  readonly form: StartForm = "tui";
17
20
  private generation = crypto.randomUUID();
18
21
  // Checkpoints become stale at hello/claim, before asynchronous recall finishes and attach changes delivery generation.
@@ -25,31 +28,45 @@ export class WsPeer extends BasePeer {
25
28
  private delivered = new Set<string>();
26
29
  private sock: ChannelSocket | undefined;
27
30
  private claimed: ChannelSocket | undefined;
31
+ private readonly standbys = new Map<ChannelSocket, PeerStandbySession>();
28
32
  private nativeTurnSession: string | undefined;
29
33
  private nativeActive = false;
30
34
  /** The attached session said it cannot show channel pushes (issue #205): the bus keeps its queue for `inbox`. */
31
35
  pullOnly = false;
36
+ attachMode: "channel" | "tools-only" = "tools-only";
37
+ attachEvidence: ClaudeChannelEvidence = "unknown";
32
38
  steer: PeerAdapter["steer"] = undefined;
33
39
  lastInboxAt: number | undefined;
34
40
  private claimedPullOnly = false;
41
+ private claimedEvidence: ClaudeChannelEvidence = "unknown";
35
42
  private readonly attachmentWaiters: { generation: string; resolve: (attached: boolean) => void }[] = [];
36
43
  private settleAttachmentWaiters(attached: boolean): void {
37
44
  for (const waiter of this.attachmentWaiters.splice(0)) waiter.resolve(attached && waiter.generation === this.claimGeneration);
38
45
  }
46
+ private pruneStandbys(): void {
47
+ for (const [socket] of this.standbys) if (socket.readyState !== WebSocket.OPEN) this.standbys.delete(socket);
48
+ }
39
49
  /** Bounded native-hook observations wait for this exact claim, never a later socket. */
40
50
  waitForAttach(generation: string): Promise<boolean> {
41
51
  if (generation !== this.claimGeneration || !this.claiming || this.attachmentWaiters.length >= 64) return Promise.resolve(false);
42
52
  return new Promise(resolve => { this.attachmentWaiters.push({ generation, resolve }); });
43
53
  }
44
54
  /** Called at hello, before the async preface: the newest hello wins even if an older one's recall finishes last. */
45
- claim(sock: ChannelSocket, pullOnly = false): void {
55
+ claim(sock: ChannelSocket, pullOnly = false, evidence: ClaudeChannelEvidence = "unknown"): void {
56
+ this.standbys.delete(sock);
57
+ if (this.claimed && this.claimed !== this.sock && this.claimed !== sock) this.claimed.close(4000, "replaced");
46
58
  this.settleAttachmentWaiters(false);
47
59
  this.claimGeneration = crypto.randomUUID();
48
60
  this.claimed = sock;
49
61
  this.claimedPullOnly = pullOnly;
62
+ this.claimedEvidence = evidence;
50
63
  }
51
64
  /** Host transport observation, distinct from the peer's activity state. */
52
65
  get attached(): boolean { return !!this.sock && this.sock.readyState === WebSocket.OPEN && !this.claiming; }
66
+ get standbySessions(): readonly PeerStandbySession[] {
67
+ this.pruneStandbys();
68
+ return [...this.standbys.values()].map(session => ({ ...session }));
69
+ }
53
70
  get managedLaunchId(): string | undefined { return this.attached ? this.sock?.data.launchId : undefined; }
54
71
 
55
72
  /** A session that shows pushes holds the peer or is arriving: a session without them must not take it (issue #205). */
@@ -60,6 +77,46 @@ export class WsPeer extends BasePeer {
60
77
  standbyHeld(pullOnly: boolean): boolean {
61
78
  return this.claiming || (!!this.sock && this.sock.readyState === WebSocket.OPEN && (pullOnly || !this.pullOnly));
62
79
  }
80
+ /** Retain an opt-in contender without giving it peer authority or changing current delivery ownership. */
81
+ retainStandby(sock: ChannelSocket, pid?: number): PeerStandbySession | undefined {
82
+ this.pruneStandbys();
83
+ const existing = this.standbys.get(sock);
84
+ if (existing) return { ...existing };
85
+ if (sock.readyState !== WebSocket.OPEN || this.standbys.size >= WsPeer.MAX_STANDBY_SESSIONS) return undefined;
86
+ const session: PeerStandbySession = { id: crypto.randomUUID(), ...(pid !== undefined ? { pid } : {}), since: Date.now() };
87
+ sock.data.standbyId = session.id;
88
+ this.standbys.set(sock, session);
89
+ return { ...session };
90
+ }
91
+ /** Promote one currently retained connection. Selection is by stable id, or oldest live when omitted. */
92
+ takeover(standbyId: string | undefined, eligible: (socket: object) => boolean): PeerTakeoverClaim | undefined {
93
+ this.pruneStandbys();
94
+ if (this.claiming || this.sock && !this.attached) return undefined;
95
+ const candidate = [...this.standbys.entries()]
96
+ .find(([socket, session]) => socket.readyState === WebSocket.OPEN && (standbyId === undefined || session.id === standbyId) && eligible(socket));
97
+ if (!candidate) return undefined;
98
+ const [socket, session] = candidate;
99
+ const previous = this.sock;
100
+ this.standbys.delete(socket);
101
+ if (previous) {
102
+ previous.data.detachedByPerson = true;
103
+ this.detach(previous);
104
+ previous.close(previous.data.retainStandby === true ? 4405 : 4403, "detached by the person");
105
+ }
106
+ this.claim(socket, socket.data.channel === false, socket.data.channelEvidence ?? "unknown");
107
+ const generation = this.claimGeneration;
108
+ return {
109
+ standbyId: session.id,
110
+ socket,
111
+ generation,
112
+ isCurrent: () => this.claimGeneration === generation && this.claimed === socket && socket.readyState === WebSocket.OPEN,
113
+ attach: () => {
114
+ if (this.claimGeneration !== generation || this.claimed !== socket || socket.readyState !== WebSocket.OPEN) return false;
115
+ this.attach(socket);
116
+ return this.owns(socket);
117
+ },
118
+ };
119
+ }
63
120
  /** A hello that has not finished its preface yet. The peer reads as offline until then, and a session standing by
64
121
  * for the id must not take it from a claimant that is still arriving. */
65
122
  get claiming(): boolean {
@@ -75,6 +132,8 @@ export class WsPeer extends BasePeer {
75
132
  this.nativeTurnSession = undefined;
76
133
  this.nativeActive = false;
77
134
  this.pullOnly = this.claimedPullOnly; // before idle: the idle transition drains
135
+ this.attachMode = this.pullOnly ? "tools-only" : "channel";
136
+ this.attachEvidence = this.claimedEvidence;
78
137
  // Native TUI readback in #493 proves important channel input can be consumed before the turn ends.
79
138
  // Pull-only transports expose no steer capability; the bus keeps their important work queued.
80
139
  this.steer = this.pullOnly ? undefined : async (envs, deliveryId) => {
@@ -82,10 +141,15 @@ export class WsPeer extends BasePeer {
82
141
  await this.deliver(envs, deliveryId);
83
142
  };
84
143
  this.lastInboxAt = undefined;
144
+ if (sock.data.standbyId) {
145
+ sock.send(JSON.stringify({ t: "peer_attached", standbyId: sock.data.standbyId }));
146
+ sock.data.standbyId = undefined;
147
+ }
85
148
  this.setState("idle");
86
149
  this.settleAttachmentWaiters(true);
87
150
  }
88
151
  detach(sock: ChannelSocket): void {
152
+ this.standbys.delete(sock);
89
153
  if (this.claimed === sock) { this.claimed = undefined; this.settleAttachmentWaiters(false); }
90
154
  if (this.sock !== sock) return;
91
155
  this.sock = undefined;
@@ -98,6 +162,8 @@ export class WsPeer extends BasePeer {
98
162
  this.settleAttachmentWaiters(false);
99
163
  this.claimed?.close(4000, "peer is off roster; retry when a person adds it");
100
164
  this.sock?.close(4000, "peer is off roster; retry when a person adds it");
165
+ for (const socket of this.standbys.keys()) socket.close(4000, "peer is off roster; retry when a person adds it");
166
+ this.standbys.clear();
101
167
  this.claimed = undefined;
102
168
  this.sock = undefined;
103
169
  this.steer = undefined;
@@ -116,6 +182,7 @@ export class WsPeer extends BasePeer {
116
182
  this.sock.send(JSON.stringify({ t: "queued", queued: counts.queued, important: counts.important }));
117
183
  }
118
184
  owns(sock: ChannelSocket): boolean { return this.sock === sock; }
185
+ ownsSocket(socket: object): boolean { return this.sock === socket; }
119
186
  nativeStarted(session: string): void {
120
187
  if (!this.sock) return;
121
188
  this.nativeTurnSession = session;
@@ -139,6 +206,8 @@ export class WsPeer extends BasePeer {
139
206
  async stop(): Promise<void> {
140
207
  this.settleAttachmentWaiters(false);
141
208
  this.sock?.close();
209
+ for (const socket of this.standbys.keys()) socket.close();
210
+ this.standbys.clear();
142
211
  }
143
212
 
144
213
  recoveryMetadata(): Record<string, unknown> {
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export type ClaudeChannelEvidence = "development-channel-launch" | "inherited-marker" | "launch-unverified" | "unknown" | "unreported";
4
+ export type ClaudeAttachMode = "channel" | "tools-only";
5
+
6
+ /** Internal host-observer seam; tests may replace only the raw process row. */
7
+ export const claudeParentCommandPort: { read: (ppid: number) => string | undefined } = { read: readClaudeParentCommand };
8
+
9
+ const TARGETS = new Set(["plugin:agent-hub@agent-hub", "server:agent-hub"]);
10
+ const DISABLING = new Set(["-p", "--print", "--output-format", "--background", "--detach", "--daemon", "daemon", "bg", "bg-spare"]);
11
+
12
+ // ponytail: current native launches expose only a flattened direct-parent argv row; this does not prove registration or authenticate the host. Replace it with an explicit per-server client opt-in when Claude publishes one.
13
+ /** Classify a flattened `ps` command only when its leading argv tokens are unambiguous. */
14
+ export function classifyClaudeParentCommand(command: string): ClaudeChannelEvidence {
15
+ if (!command || command.length > 4096 || /[\n\r\0]/.test(command)) return "launch-unverified";
16
+ const argv = command.trim().split(/\s+/);
17
+ const executable = argv[0]?.split("/").at(-1);
18
+ if (executable !== "claude" || argv.length < 3 || argv[1] !== "--dangerously-load-development-channels" || !TARGETS.has(argv[2]!)) return "launch-unverified";
19
+ // The channel flag is ignored by print/background/daemon forms. A later repeated
20
+ // flag can change the selected channels, so those commands fail closed as well.
21
+ if (argv.slice(3).some(arg => DISABLING.has(arg) || arg.startsWith("--print=") || arg.startsWith("--output-format=") || arg.startsWith("-p") || arg === "--dangerously-load-development-channels" || arg.startsWith("--dangerously-load-development-channels="))) return "launch-unverified";
22
+ return "development-channel-launch";
23
+ }
24
+
25
+ function readClaudeParentCommand(ppid: number): string | undefined {
26
+ try {
27
+ const result = spawnSync("/bin/ps", ["-ww", "-p", String(ppid), "-o", "command="], {
28
+ encoding: "utf8", timeout: 1500, maxBuffer: 4096,
29
+ env: { PATH: "/usr/bin:/bin", LC_ALL: "C" },
30
+ });
31
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string" || !result.stdout.trim() || result.stdout.trim().includes("\n")) return undefined;
32
+ return result.stdout.trim();
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ /** The plugin's direct parent is Claude Code. Failure to read its bounded process row is unknown. */
39
+ export function claudeChannelEvidence(env: NodeJS.ProcessEnv = process.env, ppid = process.ppid, read: (pid: number) => string | undefined = claudeParentCommandPort.read): ClaudeChannelEvidence {
40
+ if (env.AGENTHUB_CHANNEL !== "1") return "unknown";
41
+ if (!Number.isSafeInteger(ppid) || ppid <= 1) return "unknown";
42
+ try {
43
+ const command = read(ppid);
44
+ if (!command || process.ppid !== ppid) return "unknown";
45
+ return classifyClaudeParentCommand(command);
46
+ } catch { return "unknown"; }
47
+ }
48
+
49
+ /** Tests may substitute the raw parent row, but this function always checks the inherited marker and classifier. */
50
+ export function currentClaudeChannelEvidence(): ClaudeChannelEvidence {
51
+ return claudeChannelEvidence();
52
+ }
@@ -12,7 +12,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
12
12
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS } from "../hub/control-client.ts";
15
+ import { ControlClient, INVOCATION_CONTROL_TIMEOUT_MS, QUESTION_CONTROL_TIMEOUT_MS } from "../hub/control-client.ts";
16
16
  import { toolWriteOperation } from "../hub/tool-request.ts";
17
17
  import { channelProject } from "../hub/channel-project.ts";
18
18
  import { VERSION } from "../version.ts";
@@ -20,6 +20,7 @@ import { IMPLEMENTER_HUB_TOOL_NAMES, IMPLEMENTER_PERMISSION_TOOL, ACTIONABLE_MES
20
20
  import { frame, replyParent, sanitize, HUB_MESSAGE_INSTRUCTION, type Envelope } from "../hub/envelope.ts";
21
21
  import { renderHeldHint } from "../hub/delivery-hints.ts";
22
22
  import { acceptsNativePermissionRequest, buildNativePermissionProbe, NATIVE_PERMISSION_PROBE_MAX_PER_LAUNCH } from "../hub/native-permission-request.ts";
23
+ import { currentClaudeChannelEvidence } from "./claude-channel-evidence.ts";
23
24
 
24
25
  // A native session is pinned at launch. Unlike a new CLI invocation after `cd`,
25
26
  // its MCP server must not silently move to another hub (including while offline).
@@ -56,12 +57,14 @@ const UNIDENTIFIED = "agent-hub tools refused: this process was not started by t
56
57
  // Exposure hint only; the daemon verifies the actual adapter before any retry mutation.
57
58
  const deliveryOnly = process.env.AGENTHUB_DELIVERY_ONLY === "true";
58
59
  /**
59
- * Channel evidence (issue #205): `ahub claude` sets AGENTHUB_CHANNEL=1 beside the development-channel flag. Without the
60
- * flag Claude Code drops channel notifications without an error, so a Claude session without the marker attaches
60
+ * Channel evidence (issue #1171): the marker alone is inherited by background and nested Claude processes. The direct
61
+ * parent argv must also prove the native development-channel launch. Without that proof Claude may drop notifications,
62
+ * so the session attaches
61
63
  * "tools-only" (as status calls it; unrelated to toolsMode): no channel capability, its messages wait at the hub for
62
64
  * hub_inbox, and it never reports a delivery `accepted`.
63
65
  */
64
- const channel = !toolsMode && process.env.AGENTHUB_CHANNEL === "1";
66
+ const channelEvidence = !toolsMode ? currentClaudeChannelEvidence() : "unknown";
67
+ const channel = !toolsMode && channelEvidence === "development-channel-launch";
65
68
 
66
69
  function roles(): Record<string, string[]> {
67
70
  // The machine's own file overrides the shared one, as in loadConfig (issue #17).
@@ -113,6 +116,7 @@ const TERMINAL_CLOSES: Record<number, string> = {
113
116
  4401: "the hub refused the control token",
114
117
  4404: "the hub belongs to a different project or instance; restart this session from the intended project",
115
118
  4403: `the hub refused "${peerId}": the id is reserved or malformed, or this process is not an owned launch of that peer (start it with ahub jcode)`,
119
+ 4405: "detached by the person",
116
120
  4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
117
121
  4430: "this Claude launch ended; start a new session with ahub claude, or use a plain session with hub_inbox",
118
122
  4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
@@ -183,7 +187,13 @@ const server = new Server(
183
187
  const inbox: string[] = []; // pushes that failed; drained by hub_inbox
184
188
  let hub: ControlClient | undefined;
185
189
  let detached: string | undefined; // why this server stopped reconnecting; tool calls report it
186
- const offline = () => detached ?? "hub is not running for this project (start it with: ahub up).";
190
+ const offline = () => detached === "detached by the person"
191
+ ? `detached by the person; this session no longer acts as ${peerId}. Stop here and tell the person. If they want this session back, start a fresh ahub claude.`
192
+ : detached ?? "hub is not running for this project (start it with: ahub up).";
193
+ const offlineToolResult = (suffix = "") => {
194
+ const result = text(`${offline()}${suffix}`);
195
+ return detached === "detached by the person" ? { ...result, isError: true } : result;
196
+ };
187
197
  /**
188
198
  * The hub's advisory `queued` nudge (issue #398): important work waiting in this pull-only session's queue.
189
199
  * Surfaced on the next tool result; the pull itself still happens only through hub_inbox.
@@ -191,8 +201,20 @@ const offline = () => detached ?? "hub is not running for this project (start it
191
201
  let pendingQueued: { queued: number; important: number } | undefined;
192
202
  /** Marks a tool result whose text already states the queued count (#221), so the fresh-count block does not repeat it. Symbol keys never reach the client. */
193
203
  const QUEUED_COUNTED = Symbol("queuedCounted");
204
+ /** Marks an inbox result that already renders its own bounded held/accepted delivery metadata. */
205
+ const HELD_HINT_INCLUDED = Symbol("heldHintIncluded");
206
+ const heldMetadataUnavailable = (value: unknown): boolean => !!value && typeof value === "object" && !Array.isArray(value)
207
+ && Object.keys(value).length === 1 && (value as { unavailable?: unknown }).unavailable === true;
208
+ const heldMetadataNeedsReviewOnly = (value: unknown): boolean => {
209
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
210
+ const held = value as { count?: unknown; rows?: unknown; truncated?: unknown };
211
+ return Number.isSafeInteger(held.count) && (held.count as number) > 0 && held.truncated === false && Array.isArray(held.rows) && held.rows.length > 0
212
+ && held.rows.every(row => !!row && typeof row === "object" && !Array.isArray(row)
213
+ && ((row as { state?: unknown }).state === undefined || (row as { state?: unknown }).state === "needs_review"));
214
+ };
194
215
  /** Fresh own-peer counts accompany all tool modes; only old plain sessions use advisory queued nudges. */
195
216
  async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T> {
217
+ if (hub?.standby) return result;
196
218
  const r = result as { content?: { type: string; text?: string }[] };
197
219
  const appendQueued = (hint: string) => {
198
220
  // Preserve the inbox's existing first-block refusal/readback contract; counts remain separate advisory text.
@@ -221,7 +243,7 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
221
243
  appendQueued(counted ? `(${response.important} important hub message(s) waiting${next})` : `(${response.queued} queued hub message(s), ${response.important} important hub message(s) waiting${next})`);
222
244
  }
223
245
  }
224
- if (hint) r.content.push({ type: "text", text: hint });
246
+ if (hint && (result as { [HELD_HINT_INCLUDED]?: boolean })[HELD_HINT_INCLUDED] !== true) r.content.push({ type: "text", text: hint });
225
247
  } catch { /* advisory metadata can never change a saved tool result or its effect classification */ }
226
248
  // A pre-#493 daemon sends held rows without counts; the legacy nudge must not advise a read the hold refuses (#221).
227
249
  if (!freshCounts && !hint && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
@@ -287,13 +309,20 @@ async function connectLoop(): Promise<void> {
287
309
  try { executionCwd = realPath(process.cwd()); }
288
310
  catch { /* A removed native cwd revokes root evidence, never the messaging connection. */ }
289
311
  }
290
- const client = await ControlClient.connect(stateDir, { role: toolsMode || sessionJoin ? "tools" : "peer", peer: peerId, ...(toolsMode || sessionJoin ? {} : { channel }),
312
+ const client = await ControlClient.connect(stateDir, { role: toolsMode || sessionJoin ? "tools" : "peer", peer: peerId, ...(toolsMode || sessionJoin ? {} : { channel, channelEvidence }),
313
+ ...(!toolsMode && !sessionJoin ? { retainStandby: true, clientPid: process.pid } : {}),
291
314
  ...(sessionJoin ? { session: sessionJoin.label } : {}),
292
315
  ...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
293
316
  ...(executionCwd !== undefined ? { cwd: executionCwd } : {}),
294
317
  ...(!toolsMode && standingBy ? { standby: true } : {}),
295
318
  ...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
296
319
  client.onPush = (msg) => {
320
+ if (msg.t === "peer_attached") {
321
+ standingBy = false;
322
+ detached = undefined;
323
+ log(`connected to hub as "${peerId}"`);
324
+ return;
325
+ }
297
326
  if (msg.t === "queued") {
298
327
  // Advisory counts for a pull-only session (issue #398); surfaced by withPendingHint on the next tool result.
299
328
  if (!channel && !toolsMode && Number.isSafeInteger(msg.important) && Number.isSafeInteger(msg.queued) && msg.important >= 0 && msg.queued >= msg.important) pendingQueued = { queued: msg.queued, important: msg.important };
@@ -313,9 +342,10 @@ async function connectLoop(): Promise<void> {
313
342
  };
314
343
  hub = client;
315
344
  attempt = -1;
316
- standingBy = false;
317
- detached = undefined;
318
- log(sessionJoin ? `connected to hub as the distinct session "${client.sessionId ?? "?"}", not as "${peerId}": it can send and read, and holds none of ${peerId}'s roles` : `connected to hub as "${peerId}"`);
345
+ standingBy = client.standby;
346
+ detached = client.standby ? `${HELD}; a person can hand it over with ahub peer takeover ${peerId}` : undefined;
347
+ log(sessionJoin ? `connected to hub as the distinct session "${client.sessionId ?? "?"}", not as "${peerId}": it can send and read, and holds none of ${peerId}'s roles`
348
+ : client.standby ? `standing by: ${detached}` : `connected to hub as "${peerId}"`);
319
349
  // The tool list depends on the connected hub (its lifecycle): when it differs from what the host last listed,
320
350
  // ask the host to list it again (#490).
321
351
  if (listedTools !== undefined) void reannounce(client).catch(() => {});
@@ -415,6 +445,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
415
445
 
416
446
  async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }, signal?: AbortSignal) {
417
447
  const { name, arguments: args } = req.params;
448
+ if (hub?.standby) return text(detached ?? HELD);
418
449
  if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
419
450
  if (!own) return { ...text(NO_PROJECT), isError: true };
420
451
  if (sessionJoin && !sessionSupported()) return { ...text(NO_SESSION_SUPPORT), isError: true };
@@ -441,23 +472,27 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
441
472
  } catch { return deny("hub permission unavailable"); }
442
473
  }
443
474
  if (name === "hub_delivery_done" && channel) {
444
- if (!hub) return text(offline());
475
+ if (!hub) return offlineToolResult();
445
476
  const a = args ?? {};
446
477
  const result = await hub.request({ t: "delivery_complete", deliveryId: a.delivery_id, generation: a.delivery_generation });
447
478
  if (result.outcome === "unknown") return text(typeof result.error === "string" ? result.error : "Write outcome unknown. Inspect the delivery before another write; call again only if it is absent, with the same delivery id and generation.");
448
479
  return text(result.ok ? "delivery completed" : `not completed: ${result.error}`);
449
480
  }
450
481
  if (name === "hub_inbox" && (!toolsMode || permissionTool)) {
451
- let remote: string[] = [], notice = "", polling = "", counted = false;
482
+ let remote: string[] = [], notice = "", polling = "", counted = false, heldHint = "", metadataWarning = "", heldMetadata: unknown;
452
483
  if (hub) {
453
484
  try {
454
485
  const res = await hub.request({ t: "inbox" });
486
+ heldMetadata = res.held;
487
+ const metadataUnavailable = heldMetadataUnavailable(res.held);
488
+ heldHint = renderHeldHint(res.held, Date.now(), !deliveryOnly);
489
+ if (metadataUnavailable) metadataWarning = `Delivery confirmation metadata is unavailable; inspect ahub queue list --peer ${sanitize(peerId)} before deciding whether anything is waiting.`;
455
490
  if (!res.ok) notice = `not read: ${res.error}`;
456
491
  else if (!Array.isArray(res.envs)) notice = "not read: malformed daemon inbox response";
457
492
  else {
458
493
  pendingQueued = { queued: typeof res.waiting === "number" ? res.waiting : 0, important: typeof res.waitingImportant === "number" ? res.waitingImportant : 0 };
459
494
  remote = (res.envs as Envelope[]).map(frame);
460
- if (Number.isSafeInteger(res.pollingAdvice?.count) && res.pollingAdvice.count >= 3 && Number.isSafeInteger(res.pollingAdvice?.minutes) && res.pollingAdvice.minutes >= 0 && res.pollingAdvice.minutes <= 10) polling = `[polling detected] ${res.pollingAdvice.count} empty reads in ${res.pollingAdvice.minutes} min with no assigned work: end your turn`;
495
+ if (!heldHint && Number.isSafeInteger(res.pollingAdvice?.count) && res.pollingAdvice.count >= 3 && Number.isSafeInteger(res.pollingAdvice?.minutes) && res.pollingAdvice.minutes >= 0 && res.pollingAdvice.minutes <= 10) polling = `[polling detected] ${res.pollingAdvice.count} empty reads in ${res.pollingAdvice.minutes} min with no assigned work: end your turn`;
461
496
  // #713: a pull drains what fits one read; what is left is said plainly, with what to do about it. The count already says how many remain, so the pending hint adds only the important ones (#221).
462
497
  if (res.waiting) { notice = `(${res.waiting} more waiting: call hub_inbox again before you start work)`; counted = true; }
463
498
  }
@@ -466,14 +501,20 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
466
501
  // Only pushes without a journal delivery id enter this buffer. Pull completion belongs to the daemon.
467
502
  const out = [...inbox.splice(0), ...remote];
468
503
  if (notice) out.push(notice);
469
- const body = out.length ? out.join("\n\n") : "(no queued hub messages)\nNothing is waiting. Do not poll: if you have no assigned task, report and end your turn; new work waits here for your next boundary.";
504
+ if (heldHint) out.push(heldHint);
505
+ if (metadataWarning) out.push(metadataWarning);
506
+ let body = out.length ? out.join("\n\n") : "(no queued hub messages)\nNothing is waiting. Do not poll: if you have no assigned task, report and end your turn; new work waits here for your next boundary.";
507
+ if (out.length === 1 && out[0] === heldHint && heldHint && heldMetadataNeedsReviewOnly(heldMetadata)) {
508
+ body = `(no queued hub messages)\n\n${body}`;
509
+ }
470
510
  const result = text(polling ? `${body}\n${polling}` : body);
471
511
  if (counted) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
472
- return result;
512
+ if (heldHint || metadataWarning) Object.defineProperty(result, HELD_HINT_INCLUDED, { value: true });
513
+ return detached === "detached by the person" ? { ...result, isError: true } : result;
473
514
  }
474
515
  if (name === "hub_send") {
475
516
  const { text: body, to, reply_to } = (args ?? {}) as { text?: string; to?: string[]; reply_to?: string };
476
- if (!hub) return text(`${offline()} Message not sent.`);
517
+ if (!hub) return offlineToolResult(" Message not sent.");
477
518
  const res = await hub.request({ t: "send", body, to, reply_to });
478
519
  if (!res.ok) return text(`not sent: ${res.error}`);
479
520
  if (res.recorded) return text("recorded only ([FYI]): it is on the hub console and log, and no peer spent a turn on it");
@@ -482,9 +523,11 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
482
523
  return text(typeof res.notice === "string" ? `${sent}; ${res.notice}` : sent);
483
524
  }
484
525
  if (TASK_TOOL_NAMES.has(name) || CONDUCTOR_TOOL_NAMES.has(name)) {
485
- if (!hub) return text(offline());
526
+ if (!hub) return offlineToolResult();
486
527
  // #445 C2: a daemon that predates invocations would answer with a generic unknown-operation error; say what is missing instead.
487
528
  if (name === "hub_invoke" && !hub.features.includes("invocation")) return { ...text("error: this hub does not support invocations; restart it with the current agent-hub release (ahub restart)"), isError: true };
529
+ // #1053: a hub that predates questions would answer "unknown operation"; say what is missing, and what to do meanwhile.
530
+ if (name === "hub_ask" && !hub.features.includes("questions")) return { ...text("error: this hub does not support questions; restart it with the current agent-hub release (ahub restart), or ask the person with hub_send"), isError: true };
488
531
  const rawArgs = args ?? {}, operation = toolWriteOperation({ t: "task", op: name });
489
532
  let taskArgs = rawArgs, requestId: unknown;
490
533
  if (operation && Object.hasOwn(rawArgs, "request_id")) {
@@ -492,7 +535,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
492
535
  const { request_id: _requestId, ...withoutRequestId } = rawArgs;
493
536
  taskArgs = withoutRequestId;
494
537
  }
495
- const res = await hub.request({ t: "task", op: name, args: taskArgs, ...(requestId !== undefined ? { requestId } : {}) }, name === "hub_invoke" ? INVOCATION_CONTROL_TIMEOUT_MS : undefined, name === "hub_invoke" ? signal : undefined);
538
+ const res = await hub.request({ t: "task", op: name, args: taskArgs, ...(requestId !== undefined ? { requestId } : {}) }, name === "hub_invoke" ? INVOCATION_CONTROL_TIMEOUT_MS : name === "hub_ask" ? QUESTION_CONTROL_TIMEOUT_MS : undefined, name === "hub_invoke" || name === "hub_ask" ? signal : undefined);
496
539
  if (res.outcome === "unknown") return text(typeof res.error === "string" ? res.error : "Write outcome unknown. Inspect the task before another write; call again only if it is absent, with the same request_id and arguments.");
497
540
  return { ...text(res.ok ? res.text : `error: ${res.error}`), ...(res.ok ? {} : { isError: true }) };
498
541
  }
@@ -3,13 +3,14 @@ import { lstatSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { BasePeer, type OwnedToolsLaunch } from "../hub/peers.ts";
6
+ import type { DeliveryFailureCause } from "../hub/delivery-failure.ts";
6
7
  import { frame, type Envelope } from "../hub/envelope.ts";
7
8
  import { realPath } from "../hub/project.ts";
8
9
  import { IMPLEMENTER_HUB_TOOL_NAMES } from "../hub/hub-tools.ts";
9
10
  import { claudeStateFile } from "../hub/claude-state.ts";
10
11
  import { sessionSettings } from "../cli/launch.ts";
11
12
  import { assessVersion, COMPAT } from "./compat.ts";
12
- import { OwnedPrintChild, PrintChildCleanupError, type OwnedPrintChildOptions } from "./claude-print-child.ts";
13
+ import { OwnedPrintChild, PrintChildCleanupError, type OwnedInputContext, type OwnedInputObserver, type OwnedPrintChildOptions } from "./claude-print-child.ts";
13
14
  import { ClaudeTaskSessions } from "./claude-task-sessions.ts";
14
15
  import { formatClaudeFailure, type ClaudeFailureTrace } from "../hub/claude-failure-stage.ts";
15
16
  import type { PermissionMode } from "../hub/permission-mode.ts";
@@ -52,6 +53,9 @@ export interface ClaudeImplementerOptions {
52
53
  prove?(): Promise<() => boolean>;
53
54
  /** Host-observed failure evidence bound to one admitted native launch. */
54
55
  failureTrace?(launch: Readonly<OwnedToolsLaunch>, nativeVersion?: string): ClaudeFailureTrace;
56
+ /** Actual task-session input provenance only; never a peer/native authority port. */
57
+ beforeInput?: OwnedInputObserver;
58
+ inputReleased?: OwnedInputObserver;
55
59
  /** Resolve current privacy at final rendering; unknown or a throw is private. */
56
60
  failurePrivate?(task?: number): boolean;
57
61
  /** Optional host observer of the fixed, already privacy-screened failure message. */
@@ -108,6 +112,8 @@ export class ClaudeImplementerPeer extends BasePeer {
108
112
  private sessions: ClaudeTaskSessions;
109
113
  private stopping: Promise<void> | undefined;
110
114
  private stoppingTarget: "idle" | "offline" | undefined;
115
+ private observerSessionIdValue: string | undefined;
116
+ private currentInputContext: OwnedInputContext | undefined;
111
117
  private mode: "ask" | "ask-when-needed";
112
118
  constructor(id: string, private readonly options: ClaudeImplementerOptions) {
113
119
  super(id, options.watchdogMs);
@@ -121,6 +127,8 @@ export class ClaudeImplementerPeer extends BasePeer {
121
127
  this.mode = mode; // Native remains default: the host facts gate applies this choice to the current child.
122
128
  }
123
129
  get sessionGeneration(): string | undefined { return this.active?.generation; }
130
+ /** Privacy-only observer identity; never exposed as PeerAdapter.nativeSession or used for budget/resume authority. */
131
+ get observerSessionId(): string | undefined { return this.observerSessionIdValue; }
124
132
  ownedTools(launchId: unknown): OwnedToolsLaunch | undefined { return typeof launchId === "string" && this.active?.launchId === launchId && !this.abort?.signal.aborted ? this.active : undefined; }
125
133
  keepApprovalAlive(): void { if (this.state === "busy" && this.active) this.touch(); }
126
134
  recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "claude-implementer", peer: this.id }, ...(this.active ? { sessionId: this.active.sessionId } : {}) }; }
@@ -135,12 +143,12 @@ export class ClaudeImplementerPeer extends BasePeer {
135
143
  this.command = command; this.binding = binding; this.setState("idle");
136
144
  }
137
145
  async stop(reason = "Claude implementer stopped before settlement"): Promise<void> { return this.cancel(reason, "offline"); }
138
- protected override onWatchdog(): void { void this.cancel("Claude implementer watchdog timeout", "idle").catch(() => {}); }
139
- private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string): void {
146
+ protected override onWatchdog(): void { void this.cancel("Claude implementer watchdog timeout", "idle", "timeout").catch(() => {}); }
147
+ private settle(state: "completed" | "failed_safe" | "needs_review", reason?: string, failureCause?: DeliveryFailureCause): void {
140
148
  const id = this.deliveryId; this.deliveryId = undefined;
141
- if (id) this.delivery({ id, state, ...(reason ? { reason } : {}) });
149
+ if (id) this.delivery({ id, state, ...(reason ? { reason } : {}), ...(failureCause ? { failureCause } : {}) });
142
150
  }
143
- private cancel(reason: string, next: "idle" | "offline"): Promise<void> {
151
+ private cancel(reason: string, next: "idle" | "offline", failureCause?: DeliveryFailureCause): Promise<void> {
144
152
  if (this.stopping) {
145
153
  if (next === "offline") this.stoppingTarget = "offline";
146
154
  return this.stopping;
@@ -148,7 +156,7 @@ export class ClaudeImplementerPeer extends BasePeer {
148
156
  this.stoppingTarget = next;
149
157
  ++this.generation; this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; this.abort?.abort(); this.clearWatchdog();
150
158
  discardFailureTrace(this.failureTrace); this.failureTrace = undefined;
151
- this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason);
159
+ this.settle(this.inputReleased ? "needs_review" : "failed_safe", reason, failureCause);
152
160
  if (this.owned || this.running) this.setState("busy");
153
161
  const work: Promise<void> = Promise.resolve().then(async () => {
154
162
  if (this.owned) await this.owned.stop(); // Retain handle/busy if group cleanup fails.
@@ -204,6 +212,10 @@ export class ClaudeImplementerPeer extends BasePeer {
204
212
  this.options.notify?.(`${this.id}: task #${group.task} session fresh; recorded transcript is missing`);
205
213
  }
206
214
  }
215
+ const inputContext: OwnedInputContext = Object.freeze({ sessionId: turn.sessionId, inputId: randomUUID() });
216
+ this.observerSessionIdValue = turn.sessionId;
217
+ this.currentInputContext = inputContext;
218
+ this.options.beforeInput?.(inputContext, group.envs);
207
219
  const launchId = randomUUID(), directory = mkdtempSync(join(this.options.stateDir, "claude-child-")), settings = join(directory, "settings.json");
208
220
  let launchFailureTrace: ClaudeFailureTrace | undefined;
209
221
  this.cleanup = () => rmSync(directory, { recursive: true, force: true });
@@ -228,7 +240,13 @@ export class ClaudeImplementerPeer extends BasePeer {
228
240
  try { launchFailureTrace = this.options.failureTrace?.(Object.freeze({ ...launch }), provenVersion(this.binding!)); }
229
241
  catch { launchFailureTrace = undefined; }
230
242
  this.failureTrace = launchFailureTrace;
231
- this.inputReleased = true; // OwnedPrintChild releases stdin immediately after this synchronous admission fence.
243
+ // Admission is synchronous and fenced; settlement must treat the child as having received input
244
+ // even if cancellation interrupts a later stdin flush callback.
245
+ this.inputReleased = true;
246
+ },
247
+ onInputReleased: () => {
248
+ if (!current() || this.currentInputContext !== inputContext) return;
249
+ this.options.inputReleased?.(inputContext, group.envs);
232
250
  },
233
251
  onLeaderExit: () => { sealFailureTrace(launchFailureTrace); this.active = undefined; this.childAbort?.abort(); this.childAbort = undefined; },
234
252
  onActivity: () => this.touch(),
@@ -297,3 +315,7 @@ export class ClaudeImplementerPeer extends BasePeer {
297
315
  return message;
298
316
  }
299
317
  }
318
+
319
+ export function isClaudeImplementerPeer(value: unknown): value is ClaudeImplementerPeer {
320
+ return value instanceof ClaudeImplementerPeer;
321
+ }
@@ -1,5 +1,9 @@
1
1
  import { spawn, type ChildProcess } from "node:child_process";
2
2
  import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
3
+ import type { Envelope } from "../hub/envelope.ts";
4
+
5
+ export interface OwnedInputContext { readonly sessionId: string; readonly inputId: string }
6
+ export type OwnedInputObserver = (context: OwnedInputContext, envs: readonly Envelope[]) => void;
3
7
 
4
8
  export class PrintChildCleanupError extends Error {}
5
9
  export interface OwnedPrintChildOptions {
@@ -10,6 +14,8 @@ export interface OwnedPrintChildOptions {
10
14
  env?: NodeJS.ProcessEnv;
11
15
  envMode?: "replace";
12
16
  input: string;
17
+ /** Metadata only: fires after stdin has flushed successfully while the owning generation is current. */
18
+ onInputReleased?: () => void;
13
19
  /** The peer owns generation and authority; this helper never mints either. */
14
20
  current?: () => boolean;
15
21
  stop?: (child: ChildProcess) => Promise<void>;
@@ -44,16 +50,20 @@ export class OwnedPrintChild {
44
50
  // ponytail: 256 KiB per NDJSON frame and 4 MiB stdout by default; raise only with native receipts.
45
51
  const frameLimit = options.maxFrameBytes ?? 256 * 1024, outputLimit = options.maxOutputBytes ?? 4 * 1024 * 1024;
46
52
  let buffer = "", outputBytes = 0;
53
+ let inputFailed = false;
47
54
  const current = () => !this.limited && options.current?.() !== false;
48
55
  const limit = () => { if (!this.limited) { this.limited = true; options.onLimit?.(); } };
49
56
  this.done = new Promise((resolve, reject) => {
50
57
  child.once("error", error => { options.onError?.(error); reject(error); });
51
- child.stdin!.on("error", () => { /* exit, rather than a prompt write, settles the owned child */ });
58
+ child.stdin!.on("error", () => { inputFailed = true; /* exit, rather than a prompt write, settles the owned child */ });
52
59
  child.once("spawn", () => {
53
60
  trackGroup(child);
54
61
  if (!current()) { child.stdin!.destroy(); return; }
55
62
  options.onSpawn?.();
56
- if (current()) child.stdin!.end(options.input);
63
+ if (current()) child.stdin!.end(options.input, () => {
64
+ if (inputFailed || !current()) return;
65
+ try { options.onInputReleased?.(); } catch { /* observer failures never change owned process settlement */ }
66
+ });
57
67
  else child.stdin!.destroy();
58
68
  });
59
69
  child.stdout!.on("data", (chunk: Buffer) => {