@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.
- package/CHANGELOG.md +39 -0
- package/README.md +13 -7
- package/docs/agent-notes/README.md +1 -0
- package/docs/agent-notes/adapters.md +5 -2
- package/docs/agent-notes/budget.md +1 -0
- package/docs/agent-notes/bus.md +9 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +7 -3
- package/docs/agent-notes/tests.md +6 -4
- package/docs/design/adapters.md +52 -0
- package/docs/design/agent-hub-architecture.md +46 -0
- package/docs/design/bus.md +139 -1
- package/docs/design/task-board.md +82 -0
- package/docs/dogfood.md +22 -4
- package/docs/events.md +3 -0
- package/docs/memory.md +17 -1
- package/docs/operations.md +307 -78
- package/docs/profiles.md +35 -0
- package/docs/quickstart.md +18 -5
- package/docs/security.md +154 -60
- package/docs/specs/2026-09-19-agent-hub-design.md +84 -14
- package/docs/specs/2026-09-19-multi-project-design.md +7 -2
- package/docs/verification/2026-11-04-1171-channel-client-evidence.md +31 -0
- package/docs/verification/2026-11-05-0.31.0.md +182 -0
- package/docs/verification/2026-11-05-claude-implementer-2.1.318.md +70 -0
- package/docs/verification/2026-11-05-hardcoded-values-classification-rules.md +257 -0
- package/docs/verification/2026-11-05-hardcoded-values-ratchet.md +55 -0
- package/docs/verification/2026-11-06-rooms-presence-cost.md +152 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +14 -14
- package/package.json +3 -4
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +4393 -3609
- package/src/adapters/acp.ts +5 -4
- package/src/adapters/channel-peer.ts +72 -3
- package/src/adapters/claude-channel-evidence.ts +52 -0
- package/src/adapters/claude-channel.ts +61 -18
- package/src/adapters/claude-implementer.ts +29 -7
- package/src/adapters/claude-print-child.ts +12 -2
- package/src/adapters/codex-appserver.ts +13 -12
- package/src/adapters/compat.ts +3 -3
- package/src/adapters/definitions/builtin-metadata.ts +13 -13
- package/src/adapters/definitions/qwen.ts +1 -0
- package/src/adapters/local-worker.ts +61 -18
- package/src/adapters/pi.ts +48 -6
- package/src/adapters/registry-types.ts +12 -0
- package/src/adapters/registry.ts +2 -0
- package/src/adapters/reviewer-print.ts +50 -11
- package/src/broker/server.ts +44 -13
- package/src/broker/store.ts +38 -8
- package/src/cli/answer-args.ts +50 -0
- package/src/cli/attention.ts +17 -2
- package/src/cli/backlog-return-args.ts +37 -0
- package/src/cli/console-question.ts +110 -0
- package/src/cli/console-state.ts +77 -16
- package/src/cli/console.ts +54 -1
- package/src/cli/handoff-args.ts +15 -5
- package/src/cli/handoff-output.ts +15 -2
- package/src/cli/help.ts +8 -5
- package/src/cli/identity.ts +13 -3
- package/src/cli/main.ts +128 -16
- package/src/cli/memory.ts +107 -3
- package/src/cli/output.ts +29 -6
- package/src/cli/pet.ts +4 -4
- package/src/cli/results-args.ts +30 -0
- package/src/cli/runtime-version-warning.ts +50 -0
- package/src/cli/status-lines.ts +41 -1
- package/src/cli/tail-render.ts +1 -0
- package/src/cli/upgrade-interactive.ts +14 -2
- package/src/hub/action-class.ts +9 -1
- package/src/hub/board.ts +369 -18
- package/src/hub/bus.ts +493 -53
- package/src/hub/completion-detail.ts +50 -0
- package/src/hub/completions-store.ts +168 -0
- package/src/hub/completions.ts +120 -0
- package/src/hub/config-apply.ts +7 -5
- package/src/hub/config-reload.ts +10 -1
- package/src/hub/control-client.ts +40 -4
- package/src/hub/daemon.ts +1569 -134
- package/src/hub/delivery-failure.ts +14 -0
- package/src/hub/delivery-hints.ts +14 -4
- package/src/hub/delivery-journal.ts +751 -42
- package/src/hub/delivery-superseded.ts +4 -1
- package/src/hub/envelope.ts +3 -3
- package/src/hub/events.ts +6 -0
- package/src/hub/handoff.ts +145 -6
- package/src/hub/hub-tools.ts +56 -7
- package/src/hub/manager.ts +59 -3
- package/src/hub/memory-policy.ts +44 -1
- package/src/hub/memory-sync-policy.ts +109 -0
- package/src/hub/memory-sync-state.ts +145 -0
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/operational-settings.ts +11 -2
- package/src/hub/operational-view.ts +12 -1
- package/src/hub/peers.ts +25 -2
- package/src/hub/planning.ts +6 -1
- package/src/hub/question-desk.ts +201 -0
- package/src/hub/question-detach.ts +6 -0
- package/src/hub/questions.ts +260 -0
- package/src/hub/research-evidence.ts +106 -0
- package/src/hub/restart.ts +14 -0
- package/src/hub/review-anchor.ts +181 -0
- package/src/hub/review-brief.ts +13 -0
- package/src/hub/review-comments.ts +115 -0
- package/src/hub/review-findings.ts +7 -2
- package/src/hub/review-material.ts +76 -0
- package/src/hub/routing.ts +3 -1
- package/src/hub/task-sweep.ts +2 -0
- package/src/hub/tasks.ts +577 -63
- package/src/memory/capture.ts +5 -2
- package/src/pet/config.ts +25 -13
- package/src/rooms/link.ts +209 -21
- package/src/rooms/remote-peer.ts +186 -19
- package/src/rooms/service.ts +338 -22
- package/src/rooms/store.ts +437 -65
- package/src/rooms/wire.ts +24 -6
- package/src/ui/index.html +138 -7
- package/src/ui/needs.ts +10 -4
- package/src/ui/plugin-questions.ts +56 -0
- package/src/ui/plugin-results.ts +81 -0
- package/src/ui/plugin.ts +207 -27
- package/src/pet/window.html +0 -114
- package/src/pet/window.jxa.js +0 -122
- package/src/pet/window.ts +0 -154
package/src/adapters/acp.ts
CHANGED
|
@@ -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 #
|
|
60
|
-
*
|
|
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
|
|
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
|
|
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 =
|
|
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`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) => {
|