@openparachute/agent 0.2.2 → 0.2.3-rc.10
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/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/registry.ts +65 -27
- package/src/daemon.ts +311 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +40 -22
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
package/.parachute/module.json
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"module": "vault",
|
|
53
53
|
"event": "note.created",
|
|
54
54
|
"filter": {
|
|
55
|
-
"tags": ["
|
|
55
|
+
"tags": ["agent/message/inbound"],
|
|
56
56
|
"has_metadata": ["channel"],
|
|
57
57
|
"missing_metadata": ["channel_inbound_rendered_at"]
|
|
58
58
|
}
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"module": "vault",
|
|
86
86
|
"event": "note.created",
|
|
87
87
|
"filter": {
|
|
88
|
-
"tags": ["
|
|
88
|
+
"tags": ["agent/definition"]
|
|
89
89
|
}
|
|
90
90
|
},
|
|
91
91
|
"sink": {
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"module": "vault",
|
|
111
111
|
"event": "note.updated",
|
|
112
112
|
"filter": {
|
|
113
|
-
"tags": ["
|
|
113
|
+
"tags": ["agent/definition"]
|
|
114
114
|
}
|
|
115
115
|
},
|
|
116
116
|
"sink": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3-rc.10",
|
|
4
4
|
"description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"test:spa": "cd web/ui && bun run test",
|
|
25
25
|
"test:all": "bun run test && bun run test:spa",
|
|
26
26
|
"test:e2e": "bun e2e/llm/run.ts",
|
|
27
|
+
"lint": "biome check .",
|
|
28
|
+
"lint:fix": "biome check --write .",
|
|
27
29
|
"typecheck": "tsc --noEmit",
|
|
28
30
|
"build:spa": "cd web/ui && bun install --frozen-lockfile && bun run build",
|
|
29
31
|
"prepack": "bun run build:spa"
|
|
@@ -39,6 +41,7 @@
|
|
|
39
41
|
"typescript": "^5"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "^1.9.4",
|
|
42
45
|
"@types/bun": "latest"
|
|
43
46
|
},
|
|
44
47
|
"repository": {
|
package/src/agent-defs.ts
CHANGED
|
@@ -389,6 +389,15 @@ export function parseAgentDef(note: {
|
|
|
389
389
|
}
|
|
390
390
|
|
|
391
391
|
// Filesystem read scope.
|
|
392
|
+
//
|
|
393
|
+
// NOTE (step-up, agent#80): `filesystem: "full"` is the dangerous, full-disk
|
|
394
|
+
// case. The step-up PIN gate is enforced on the HTTP spawn path only
|
|
395
|
+
// (`POST /api/agents` in daemon.ts). This VAULT-NATIVE path (a #agent/definition
|
|
396
|
+
// note with `filesystem: full`) is NOT step-up-gated — registering it requires
|
|
397
|
+
// `vault:write` to author the note, which is itself separately scope-gated, so a
|
|
398
|
+
// step-up challenge here would gate a capability the caller already had to hold a
|
|
399
|
+
// write credential to reach. If the threat model is ever revisited (e.g. less-
|
|
400
|
+
// trusted note authors), this is the gap to close.
|
|
392
401
|
const filesystem = metaStr(meta.filesystem);
|
|
393
402
|
if (filesystem !== undefined) {
|
|
394
403
|
if (filesystem !== "workspace" && filesystem !== "full") {
|
package/src/auth.ts
CHANGED
|
@@ -7,13 +7,26 @@
|
|
|
7
7
|
* - Layer 1 (bridge / session↔channel): the bridge presents the token as an
|
|
8
8
|
* `Authorization: Bearer` header on `/events` + `/api/*`.
|
|
9
9
|
* - Layer 2 (human / chat UI): the page fetches a short-lived token from the
|
|
10
|
-
* hub (`/admin/agent-token`) and attaches it
|
|
11
|
-
* `send` POST
|
|
12
|
-
*
|
|
10
|
+
* hub (`/admin/agent-token`) and attaches it as a Bearer header on the
|
|
11
|
+
* `send` POST. For the browser SSE streams (`/ui/events`,
|
|
12
|
+
* `/api/channels/<ch>/turn-events`) — which an `EventSource` can't set a
|
|
13
|
+
* header on — the page does NOT put the JWT in the URL. Instead it mints a
|
|
14
|
+
* one-time SSE TICKET (`POST /api/ui/sse-ticket`, Bearer-authenticated) and
|
|
15
|
+
* opens `…?ticket=<nonce>`. See `requireSseTicket` below + `ui-ticket.ts`.
|
|
13
16
|
*
|
|
14
|
-
* `requireScope` accepts the token from
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* `requireScope` accepts the token from a Bearer header (and, for the
|
|
18
|
+
* agent:admin terminal WebSocket only, a `?token=` query param). The no-token
|
|
19
|
+
* path short-circuits before any JWKS fetch, keeping it unit-testable without a
|
|
20
|
+
* live hub (same approach Layer 1 used).
|
|
21
|
+
*
|
|
22
|
+
* WHY THE TICKET (agent#25). A full hub JWT in a `?token=` URL lands in any
|
|
23
|
+
* access/proxy log, browser history, or network trace — a credential leak
|
|
24
|
+
* mitigated before only by the token's short TTL. The browser SSE endpoints now
|
|
25
|
+
* trade the JWT for an opaque, single-use, ≤60s ticket (`requireSseTicket`); the
|
|
26
|
+
* JWT only ever travels in a `fetch` Bearer header. The legacy `?token=` SSE
|
|
27
|
+
* path was REMOVED (pre-1.0, no deprecation window). The terminal WebSocket
|
|
28
|
+
* (`agent:admin`) still uses `?token=` — a separate, operator-gated mechanism
|
|
29
|
+
* out of this change's scope.
|
|
17
30
|
*
|
|
18
31
|
* DUAL-ACCEPT (channel→agent rename transition,
|
|
19
32
|
* `parachute-patterns/migrations/2026-06-17-channel-to-agent.md` rule 1). New
|
|
@@ -26,6 +39,8 @@
|
|
|
26
39
|
|
|
27
40
|
import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
|
|
28
41
|
import { extractBearer } from "@openparachute/scope-guard";
|
|
42
|
+
import { consumeTicket } from "./ui-ticket.ts";
|
|
43
|
+
import { isStepUpTokenValid, isStepUpConfigured } from "./step-up.ts";
|
|
29
44
|
|
|
30
45
|
/** Agent scopes, declared here so callers share one spelling. */
|
|
31
46
|
export const SCOPE_READ = "agent:read" as const;
|
|
@@ -79,15 +94,18 @@ export function json(data: unknown, status = 200): Response {
|
|
|
79
94
|
|
|
80
95
|
/**
|
|
81
96
|
* Extract a presented token from a request: the `Authorization: Bearer` header
|
|
82
|
-
* first (the bridge
|
|
83
|
-
*
|
|
84
|
-
*
|
|
97
|
+
* first (the bridge, the UI's POST, the SSE-ticket mint), falling back to a
|
|
98
|
+
* `?token=` query param only when `allowQueryParam` is set. The ONLY caller that
|
|
99
|
+
* opts into the query param is the agent:admin terminal WebSocket
|
|
100
|
+
* (`new WebSocket()` can't set headers); the browser SSE streams moved to the
|
|
101
|
+
* one-time-ticket path (`requireSseTicket`) so a JWT never rides in a URL. Returns
|
|
102
|
+
* null if neither source is present.
|
|
85
103
|
*/
|
|
86
104
|
export function extractToken(req: Request, url: URL, allowQueryParam = false): string | null {
|
|
87
105
|
const bearer = extractBearer(req.headers.get("authorization"));
|
|
88
106
|
if (bearer) return bearer;
|
|
89
|
-
// `?token=` is opt-in (the
|
|
90
|
-
// Bearer header, so they
|
|
107
|
+
// `?token=` is opt-in (the terminal WebSocket only). Every other caller presents
|
|
108
|
+
// a Bearer header, so they leave it false — keeping query-param JWTs off every
|
|
91
109
|
// endpoint that doesn't strictly need them (and out of those access logs).
|
|
92
110
|
if (allowQueryParam) {
|
|
93
111
|
const q = url.searchParams.get("token");
|
|
@@ -99,9 +117,10 @@ export function extractToken(req: Request, url: URL, allowQueryParam = false): s
|
|
|
99
117
|
/**
|
|
100
118
|
* Guard an HTTP endpoint on a hub-issued JWT carrying `scope`. The token arrives
|
|
101
119
|
* as an `Authorization: Bearer` header; pass `allowQueryParam: true` to also
|
|
102
|
-
* accept a `?token=` query param (the
|
|
103
|
-
* headers).
|
|
104
|
-
* confined to
|
|
120
|
+
* accept a `?token=` query param (the agent:admin terminal WebSocket only —
|
|
121
|
+
* `new WebSocket()` can't set headers). All other callers leave it false, so
|
|
122
|
+
* query-param JWTs are confined to that one endpoint. Browser SSE streams use
|
|
123
|
+
* `requireSseTicket` (the one-time ticket), not this.
|
|
105
124
|
*
|
|
106
125
|
* Returns `null` when the request is authorized (caller proceeds), or a
|
|
107
126
|
* `Response` (401/403) the caller must return as-is.
|
|
@@ -138,3 +157,152 @@ export async function requireScope(
|
|
|
138
157
|
);
|
|
139
158
|
}
|
|
140
159
|
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mint endpoint for a one-time SSE ticket (agent#25). Authenticate the presented
|
|
163
|
+
* Bearer JWT for `scope` (the SAME validation `requireScope` runs — no-token →
|
|
164
|
+
* 401 pre-JWKS, bad/insufficient → 401/403), then issue a single-use, ≤60s
|
|
165
|
+
* opaque ticket carrying ONLY the token's validated scopes. The ticket — never
|
|
166
|
+
* the JWT — goes in the SSE URL. Returns the mint `Response` (200 `{ ticket,
|
|
167
|
+
* expires_at }`, or the gate's 401/403) for the caller to return as-is.
|
|
168
|
+
*
|
|
169
|
+
* `mintTicket` is injected (defaults to the real `ui-ticket.ts` store) so unit
|
|
170
|
+
* tests can assert what scopes get carried without reaching into the singleton.
|
|
171
|
+
* Critically, an UNAUTHENTICATED mint is impossible: the scope gate runs first
|
|
172
|
+
* and short-circuits before any ticket is created — minting without a valid
|
|
173
|
+
* bearer would be an auth bypass.
|
|
174
|
+
*/
|
|
175
|
+
export async function mintSseTicket(
|
|
176
|
+
req: Request,
|
|
177
|
+
url: URL,
|
|
178
|
+
scope: string,
|
|
179
|
+
mint: (scopes: readonly string[]) => { ticket: string; expiresAt: number },
|
|
180
|
+
): Promise<Response> {
|
|
181
|
+
const token = extractToken(req, url); // Bearer header ONLY — never a query param.
|
|
182
|
+
if (!token) {
|
|
183
|
+
return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
184
|
+
}
|
|
185
|
+
let scopes: string[];
|
|
186
|
+
try {
|
|
187
|
+
const claims = await validateHubJwt(token);
|
|
188
|
+
if (!grantsScope(claims.scopes, scope)) {
|
|
189
|
+
return json(
|
|
190
|
+
{ error: "insufficient_scope", message: `requires ${scope}`, granted: claims.scopes },
|
|
191
|
+
403,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
// Carry the token's OWN validated scopes — never widen beyond what it holds.
|
|
195
|
+
scopes = claims.scopes;
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return json(
|
|
198
|
+
{ error: "unauthorized", message: err instanceof HubJwtError ? err.message : "invalid token" },
|
|
199
|
+
401,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const { ticket, expiresAt } = mint(scopes);
|
|
203
|
+
return json({ ticket, expires_at: new Date(expiresAt).toISOString() });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Guard a browser SSE endpoint on a one-time `?ticket=<nonce>` (agent#25 — the
|
|
208
|
+
* EventSource auth path that replaced the leaky `?token=<JWT>`). Look up + CONSUME
|
|
209
|
+
* the ticket (single-use: a second connect 401s), then assert the ticket's carried
|
|
210
|
+
* scopes include `scope` (the ticket can never authorize more than the JWT that
|
|
211
|
+
* minted it — `mintSseTicket` stored exactly that JWT's scopes). Returns `null`
|
|
212
|
+
* when authorized (caller opens the stream) or a 401 `Response` to return as-is.
|
|
213
|
+
*
|
|
214
|
+
* No JWKS fetch on this path — the JWT was validated at MINT time and its scopes
|
|
215
|
+
* captured in the ticket; consume is a pure in-memory lookup. So an absent /
|
|
216
|
+
* expired / already-used / under-scoped ticket all map to 401 with no network I/O.
|
|
217
|
+
*/
|
|
218
|
+
export function requireSseTicket(url: URL, scope: string): Response | null {
|
|
219
|
+
const consumed = consumeTicket(url.searchParams.get("ticket"));
|
|
220
|
+
if (!consumed) {
|
|
221
|
+
return json({ error: "unauthorized", message: "valid one-time SSE ticket required" }, 401);
|
|
222
|
+
}
|
|
223
|
+
if (!grantsScope(consumed.scopes, scope)) {
|
|
224
|
+
return json(
|
|
225
|
+
{ error: "insufficient_scope", message: `ticket lacks ${scope}`, granted: consumed.scopes },
|
|
226
|
+
403,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The header a request carries the step-up token on (agent#80). The terminal
|
|
234
|
+
* WebSocket — which `new WebSocket()` can't set a header on — uses the
|
|
235
|
+
* `?step_up=` query param instead (mirroring the `?token=` exception).
|
|
236
|
+
*/
|
|
237
|
+
export const STEP_UP_TOKEN_HEADER = "x-step-up-token";
|
|
238
|
+
|
|
239
|
+
/** Extract a presented step-up token: the header first, then `?step_up=` when allowed. */
|
|
240
|
+
export function extractStepUpToken(req: Request, url: URL, allowQueryParam = false): string | null {
|
|
241
|
+
const header = req.headers.get(STEP_UP_TOKEN_HEADER);
|
|
242
|
+
if (header && header.length > 0) return header;
|
|
243
|
+
if (allowQueryParam) {
|
|
244
|
+
const q = url.searchParams.get("step_up");
|
|
245
|
+
if (q && q.length > 0) return q;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* SECOND-FACTOR gate (agent#80) for the genuinely dangerous `agent:admin` actions:
|
|
252
|
+
* set/rotate credentials, open a terminal, spawn a `filesystem: full` agent. The
|
|
253
|
+
* caller runs {@link requireScope}(`agent:admin`) FIRST; this asserts — IN ADDITION —
|
|
254
|
+
* a valid step-up token (the operator entered their PIN recently).
|
|
255
|
+
*
|
|
256
|
+
* - Step-up NOT configured (no PIN set) → returns `{ ok: false, reason: "setup" }`.
|
|
257
|
+
* The caller maps it to `403 { error: "step_up_required", reason: "setup" }` so
|
|
258
|
+
* the UI runs its FIRST-TIME PIN-setup flow before the action.
|
|
259
|
+
* - PIN configured + valid token → `{ ok: true }` (the action proceeds).
|
|
260
|
+
* - PIN configured + missing/expired token → `{ ok: false, reason: "token" }` →
|
|
261
|
+
* `403 { error: "step_up_required" }` so the UI PROMPTS for the PIN.
|
|
262
|
+
*
|
|
263
|
+
* The 403 is deliberately DISTINCT from `requireScope`'s 401 (no/invalid Bearer):
|
|
264
|
+
* a 401 means "re-authenticate", a 403 `step_up_required` means "enter your PIN".
|
|
265
|
+
* The step-up token NEVER widens scope — the request already passed `agent:admin`;
|
|
266
|
+
* this is purely a recency re-confirm on top.
|
|
267
|
+
*
|
|
268
|
+
* `allowQueryParam: true` accepts `?step_up=` for the terminal WebSocket only.
|
|
269
|
+
* Pure in-memory token check — no I/O on the gated request path, no secret logged.
|
|
270
|
+
*/
|
|
271
|
+
export function requireStepUp(
|
|
272
|
+
req: Request,
|
|
273
|
+
url: URL,
|
|
274
|
+
allowQueryParam = false,
|
|
275
|
+
opts?: { configured?: () => boolean; valid?: (token: string | null) => boolean },
|
|
276
|
+
): { ok: true } | { ok: false; response: Response } {
|
|
277
|
+
const isConfigured = opts?.configured ?? (() => isStepUpConfigured());
|
|
278
|
+
const isValid = opts?.valid ?? ((t: string | null) => isStepUpTokenValid(t));
|
|
279
|
+
if (!isConfigured()) {
|
|
280
|
+
// No PIN yet — the UI must set one before this action can proceed.
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
response: json(
|
|
284
|
+
{
|
|
285
|
+
error: "step_up_required",
|
|
286
|
+
reason: "setup",
|
|
287
|
+
message: "set a step-up PIN before performing this action",
|
|
288
|
+
},
|
|
289
|
+
403,
|
|
290
|
+
),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const token = extractStepUpToken(req, url, allowQueryParam);
|
|
294
|
+
if (!isValid(token)) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
response: json(
|
|
298
|
+
{
|
|
299
|
+
error: "step_up_required",
|
|
300
|
+
reason: "token",
|
|
301
|
+
message: "enter your step-up PIN to confirm this action",
|
|
302
|
+
},
|
|
303
|
+
403,
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { ok: true };
|
|
308
|
+
}
|
package/src/backends/registry.ts
CHANGED
|
@@ -174,8 +174,16 @@ export interface ThreadNote {
|
|
|
174
174
|
* per channel, multi-threaded writes one per fire. A write failure is the implementation's
|
|
175
175
|
* to surface (the registry logs whatever it throws); it never re-runs the turn. Optional on
|
|
176
176
|
* the registry — when unwired (no vault-backed channel), a turn still runs, just no note.
|
|
177
|
+
*
|
|
178
|
+
* RETURNS the WRITTEN thread-note's id (`{ id }`) so the drain can use it as a RESOLVABLE
|
|
179
|
+
* `source_thread` on the agent-to-agent callback (agent#124) — for BOTH modes, this is the
|
|
180
|
+
* actual note an orchestrator can pull with `query-notes { id }` (single-threaded: the
|
|
181
|
+
* deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
|
|
182
|
+
* `Threads/<safeChannel>/<uuid>` note). `void` is in the union (back-compat) — a transport
|
|
183
|
+
* with no durable store, or one that can't surface an id, returns it and the drain falls
|
|
184
|
+
* back to the per-turn id.
|
|
177
185
|
*/
|
|
178
|
-
export type WriteThread = (thread: ThreadNote) => Promise<void>;
|
|
186
|
+
export type WriteThread = (thread: ThreadNote) => Promise<{ id?: string } | void>;
|
|
179
187
|
|
|
180
188
|
/**
|
|
181
189
|
* A callback delivered back to a SENDER's channel when a turn it requested finishes —
|
|
@@ -216,15 +224,16 @@ export interface CallbackMeta {
|
|
|
216
224
|
/** The channel/def whose turn just finished (the recipient) — provenance for the sender. */
|
|
217
225
|
source_channel: string;
|
|
218
226
|
/**
|
|
219
|
-
* The
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
227
|
+
* The WRITTEN thread-note id — RESOLVABLE for BOTH modes (agent#124): an orchestrator can
|
|
228
|
+
* always pull the recipient's full thread record with `query-notes { id: source_thread }`,
|
|
229
|
+
* even on an error/empty/tool-only turn (the thread note is written BEFORE the outbound
|
|
230
|
+
* reply, so its id exists when there's no `source_message`).
|
|
231
|
+
* - multi-threaded: the per-fire note id (`Threads/<safeChannel>/<uuid>`).
|
|
232
|
+
* - single-threaded: the deterministic note id (`Threads/<safeChannel>/<safeName>`) — NOT
|
|
233
|
+
* the per-turn correlation id (the pre-#124 bug: that correlation id wasn't the note leaf
|
|
234
|
+
* for single-threaded, so it couldn't be resolved).
|
|
235
|
+
* The drain sources this from {@link WriteThread}'s returned id; if the seam can't surface
|
|
236
|
+
* one (no durable store) it falls back to the per-turn id (still a stable provenance token).
|
|
228
237
|
*/
|
|
229
238
|
source_thread: string;
|
|
230
239
|
/**
|
|
@@ -835,7 +844,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
835
844
|
// thread note captures the turn outcome, so a failed turn is still a queryable
|
|
836
845
|
// `status:error` (single-threaded upserts the rolling thread; multi-threaded writes
|
|
837
846
|
// a per-fire note).
|
|
838
|
-
await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
|
|
847
|
+
const threadNoteId = await this.recordThread(handle, msg, "error", reason, startedAt, undefined, {
|
|
839
848
|
threadId: turnThreadId,
|
|
840
849
|
phase: "end",
|
|
841
850
|
// No `result` (the backend threw) → NO session to persist. We never write a
|
|
@@ -848,8 +857,10 @@ export class ProgrammaticAgentRegistry {
|
|
|
848
857
|
// no-reply) — best-effort.
|
|
849
858
|
await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, reason);
|
|
850
859
|
// CALLBACK on the failure too — an orchestrator MUST learn its sub-task failed, not
|
|
851
|
-
// hang waiting forever. No outbound note was produced, so no `source_message
|
|
852
|
-
|
|
860
|
+
// hang waiting forever. No outbound note was produced, so no `source_message`; the
|
|
861
|
+
// RESOLVABLE thread-note id (written above) is `source_thread` so the orchestrator can
|
|
862
|
+
// still pull the recipient's thread on a no-reply turn (agent#124).
|
|
863
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
|
|
853
864
|
continue;
|
|
854
865
|
}
|
|
855
866
|
|
|
@@ -863,7 +874,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
863
874
|
// BOTH modes record the failed turn (status:error) on the thread note so a failure
|
|
864
875
|
// always leaves a queryable trace (single-threaded upserts the rolling thread,
|
|
865
876
|
// marking it errored; multi-threaded writes a per-fire status:error note).
|
|
866
|
-
await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
|
|
877
|
+
const threadNoteId = await this.recordThread(handle, msg, "error", result.error, startedAt, undefined, {
|
|
867
878
|
threadId: turnThreadId,
|
|
868
879
|
phase: "end",
|
|
869
880
|
// Persist ONLY the session claude ECHOED (FIX 2). A turn can fail AFTER
|
|
@@ -878,8 +889,9 @@ export class ProgrammaticAgentRegistry {
|
|
|
878
889
|
// no-reply) — best-effort.
|
|
879
890
|
await this.postFailureNote(channel, msg.inReplyTo, turnThreadId, result.error);
|
|
880
891
|
// CALLBACK on the failure-as-value too (status:error) — the orchestrator learns the
|
|
881
|
-
// sub-task failed and can react. No delivered reply, so no `source_message
|
|
882
|
-
|
|
892
|
+
// sub-task failed and can react. No delivered reply, so no `source_message`; the
|
|
893
|
+
// RESOLVABLE thread-note id (written above) is `source_thread` (agent#124).
|
|
894
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
|
|
883
895
|
continue;
|
|
884
896
|
}
|
|
885
897
|
|
|
@@ -891,7 +903,11 @@ export class ProgrammaticAgentRegistry {
|
|
|
891
903
|
// multi-threaded writes the per-fire note. Best-effort: a thread-note failure is
|
|
892
904
|
// logged + the turn still resolves (we never re-run a `claude -p` turn — that would
|
|
893
905
|
// burn quota for a duplicate).
|
|
894
|
-
|
|
906
|
+
// Capture the WRITTEN thread-note id — the RESOLVABLE `source_thread` for the callback
|
|
907
|
+
// (agent#124). The same note id is reused for the outbound-failure re-record below
|
|
908
|
+
// (sameTurn → same note), so a callback on either terminal path points at a pullable
|
|
909
|
+
// thread record.
|
|
910
|
+
let threadNoteId = await this.recordThread(handle, msg, "ok", result.reply ?? "", startedAt, result.usage, {
|
|
895
911
|
threadId: turnThreadId,
|
|
896
912
|
phase: "end",
|
|
897
913
|
// Persist the session claude ECHOED (FIX 2) so the next turn `--resume`s this
|
|
@@ -938,7 +954,9 @@ export class ProgrammaticAgentRegistry {
|
|
|
938
954
|
// `sameTurn` so this updates the note the `ok` record above just wrote (one
|
|
939
955
|
// note, no turn_count double-count) rather than minting a duplicate / advancing
|
|
940
956
|
// the count (the FIX-1 re-record bug the reviewer caught).
|
|
941
|
-
|
|
957
|
+
// Re-record returns the SAME note's id (sameTurn upsert / same per-fire note) — use
|
|
958
|
+
// it as the callback `source_thread` (agent#124), falling back to the ok-record id.
|
|
959
|
+
threadNoteId = (await this.recordThread(
|
|
942
960
|
handle,
|
|
943
961
|
msg,
|
|
944
962
|
"error",
|
|
@@ -955,7 +973,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
955
973
|
// write failed. Only claude's echoed id (FIX 2), never the passed uuid.
|
|
956
974
|
...(result.sessionId ? { session: result.sessionId } : {}),
|
|
957
975
|
},
|
|
958
|
-
);
|
|
976
|
+
)) ?? threadNoteId;
|
|
959
977
|
this.emitTurnEvent(channel, {
|
|
960
978
|
kind: "error",
|
|
961
979
|
error: `reply produced but not saved: ${delivered.error}`,
|
|
@@ -963,8 +981,8 @@ export class ProgrammaticAgentRegistry {
|
|
|
963
981
|
// CALLBACK as status:error — the reply was produced but NOT delivered, so the
|
|
964
982
|
// turn did not truly succeed; the orchestrator must learn that. No `source_message`
|
|
965
983
|
// (the outbound note never landed); the undelivered text lives in the error thread
|
|
966
|
-
// note for an operator to recover
|
|
967
|
-
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error");
|
|
984
|
+
// note for an operator to recover — pull it via the RESOLVABLE `source_thread`.
|
|
985
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "error", undefined, threadNoteId);
|
|
968
986
|
continue;
|
|
969
987
|
}
|
|
970
988
|
}
|
|
@@ -976,8 +994,10 @@ export class ProgrammaticAgentRegistry {
|
|
|
976
994
|
// (empty/tool-only turn → clean resolve, no note expected).
|
|
977
995
|
this.emitTurnEvent(channel, { kind: "done", reply: result.reply ?? "" });
|
|
978
996
|
// CALLBACK on success — the turn finished cleanly (status:ok). `sourceMessage` is the
|
|
979
|
-
// delivered reply note (when there was one) the orchestrator pulls the full result from
|
|
980
|
-
|
|
997
|
+
// delivered reply note (when there was one) the orchestrator pulls the full result from;
|
|
998
|
+
// `source_thread` (the WRITTEN thread-note id, agent#124) is the RESOLVABLE pull-link in
|
|
999
|
+
// both modes, including an empty/tool-only turn where there's no `sourceMessage`.
|
|
1000
|
+
await this.maybeDeliverCallback(handle, msg, turnThreadId, "ok", sourceMessage, threadNoteId);
|
|
981
1001
|
}
|
|
982
1002
|
}
|
|
983
1003
|
|
|
@@ -1013,6 +1033,7 @@ export class ProgrammaticAgentRegistry {
|
|
|
1013
1033
|
turnThreadId: string,
|
|
1014
1034
|
status: "ok" | "error",
|
|
1015
1035
|
sourceMessage?: string,
|
|
1036
|
+
sourceThreadId?: string,
|
|
1016
1037
|
): Promise<void> {
|
|
1017
1038
|
// Guard 1 + 2: no sink, or this wasn't a delegated request → nothing to call back.
|
|
1018
1039
|
if (!this.writeCallback) return;
|
|
@@ -1041,11 +1062,17 @@ export class ProgrammaticAgentRegistry {
|
|
|
1041
1062
|
// source_message are echoed/included only when present. The daemon's WriteCallback
|
|
1042
1063
|
// wiring writes this as a `#agent/message/inbound` note to `msg.replyTo` and — CRUCIALLY
|
|
1043
1064
|
// — does NOT stamp a `reply_to` on it (the terminal-callback loop guard).
|
|
1065
|
+
//
|
|
1066
|
+
// `source_thread` is the WRITTEN thread-note id (agent#124) — RESOLVABLE for BOTH modes
|
|
1067
|
+
// (`query-notes { id: source_thread }`), available even on an error/empty/tool-only turn
|
|
1068
|
+
// (the thread note is written before the outbound). Fall back to the per-turn id only when
|
|
1069
|
+
// the thread seam surfaced none (no durable store / a write failure) — still a stable
|
|
1070
|
+
// provenance token, just not a pullable note.
|
|
1044
1071
|
const meta: CallbackMeta = {
|
|
1045
1072
|
callback: "true",
|
|
1046
1073
|
status,
|
|
1047
1074
|
source_channel: handle.channel,
|
|
1048
|
-
source_thread: turnThreadId,
|
|
1075
|
+
source_thread: sourceThreadId ?? turnThreadId,
|
|
1049
1076
|
...(sourceMessage ? { source_message: sourceMessage } : {}),
|
|
1050
1077
|
...(msg.correlationId ? { correlation_id: msg.correlationId } : {}),
|
|
1051
1078
|
delegation_depth: String(incomingDepth + 1),
|
|
@@ -1071,6 +1098,11 @@ export class ProgrammaticAgentRegistry {
|
|
|
1071
1098
|
* agent name (single-threaded's thread is "named after the definition"). Best-effort: a
|
|
1072
1099
|
* write failure is LOGGED, never thrown out — a missing thread note must not strand the
|
|
1073
1100
|
* queue, and the turn is never re-run (it would burn quota for a duplicate `claude -p`).
|
|
1101
|
+
*
|
|
1102
|
+
* RETURNS the WRITTEN thread-note id so the drain can use it as a RESOLVABLE
|
|
1103
|
+
* `source_thread` on the agent-to-agent callback (agent#124), for BOTH modes. `undefined`
|
|
1104
|
+
* when no sink is wired, the write failed, or the seam surfaced no id — the drain then
|
|
1105
|
+
* falls back to the per-turn id.
|
|
1074
1106
|
*/
|
|
1075
1107
|
private async recordThread(
|
|
1076
1108
|
handle: ProgrammaticAgentHandle,
|
|
@@ -1080,8 +1112,8 @@ export class ProgrammaticAgentRegistry {
|
|
|
1080
1112
|
startedAt: string,
|
|
1081
1113
|
usage: ThreadNote["usage"],
|
|
1082
1114
|
opts: { threadId?: string; sameTurn?: boolean; phase?: "start" | "end"; session?: string } = {},
|
|
1083
|
-
): Promise<
|
|
1084
|
-
if (!this.writeThread) return;
|
|
1115
|
+
): Promise<string | undefined> {
|
|
1116
|
+
if (!this.writeThread) return undefined;
|
|
1085
1117
|
const thread: ThreadNote = {
|
|
1086
1118
|
channel: handle.channel,
|
|
1087
1119
|
name: handle.spec.name,
|
|
@@ -1107,12 +1139,18 @@ export class ProgrammaticAgentRegistry {
|
|
|
1107
1139
|
...(opts.phase ? { phase: opts.phase } : {}),
|
|
1108
1140
|
};
|
|
1109
1141
|
try {
|
|
1110
|
-
|
|
1142
|
+
// The seam returns the WRITTEN note id (`{ id }`) for a durable transport; `void` for
|
|
1143
|
+
// one with no store. Surface it so the drain can set a RESOLVABLE callback
|
|
1144
|
+
// `source_thread` (agent#124). A missing id → undefined → the drain falls back to the
|
|
1145
|
+
// per-turn id.
|
|
1146
|
+
const written = await this.writeThread(thread);
|
|
1147
|
+
return written?.id;
|
|
1111
1148
|
} catch (err) {
|
|
1112
1149
|
console.error(
|
|
1113
1150
|
`parachute-agent: writing #agent/thread note for channel "${handle.channel}" failed ` +
|
|
1114
1151
|
`(continuing): ${(err as Error).message}`,
|
|
1115
1152
|
);
|
|
1153
|
+
return undefined;
|
|
1116
1154
|
}
|
|
1117
1155
|
}
|
|
1118
1156
|
|