@openparachute/agent 0.2.2 → 0.2.3-rc.11
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/programmatic.ts +35 -2
- package/src/backends/registry.ts +159 -40
- package/src/backends/types.ts +44 -0
- package/src/daemon.ts +317 -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 +48 -27
- 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.11",
|
|
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
|
+
}
|
|
@@ -85,6 +85,7 @@ import type {
|
|
|
85
85
|
DeliverResult,
|
|
86
86
|
DeliverUsage,
|
|
87
87
|
InterimSink,
|
|
88
|
+
RunContext,
|
|
88
89
|
TurnSession,
|
|
89
90
|
} from "./types.ts";
|
|
90
91
|
|
|
@@ -327,6 +328,32 @@ export function buildProgrammaticClaudeArgs(opts: {
|
|
|
327
328
|
return argv;
|
|
328
329
|
}
|
|
329
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Render the {@link RunContext} as a concise, clearly-LABELED preamble to PREPEND to a turn's
|
|
333
|
+
* message (agent#162). A headless `claude -p` turn has no clock + no notion of which run it is,
|
|
334
|
+
* so the daemon hands it these facts (the real wall-clock, new-vs-resumed, why it fired, the
|
|
335
|
+
* prior turn count) — the agent then stamps ACCURATE times instead of fabricating them.
|
|
336
|
+
*
|
|
337
|
+
* It is a single fenced block clearly marked as daemon-injected runtime context (NOT the
|
|
338
|
+
* agent's own system prompt — that's untouched), then a blank line, then the real message. The
|
|
339
|
+
* `now` is always present; the rest are appended only when known. Returns the message UNCHANGED
|
|
340
|
+
* when `rc` is absent (additive — no behavior change for a caller that doesn't pass one).
|
|
341
|
+
*/
|
|
342
|
+
export function renderRunContext(message: string, rc: RunContext | undefined): string {
|
|
343
|
+
if (!rc) return message;
|
|
344
|
+
const parts: string[] = [`now=${rc.now}`, `session=${rc.session}`];
|
|
345
|
+
if (typeof rc.priorTurnCount === "number" && rc.priorTurnCount >= 0) {
|
|
346
|
+
// The NUMBER of this turn (1-based) = completed turns + 1 — what an agent stamps as "turn N".
|
|
347
|
+
parts.push(`turn=${rc.priorTurnCount + 1}`);
|
|
348
|
+
}
|
|
349
|
+
if (rc.firedBy) parts.push(`fired-by=${rc.firedBy}`);
|
|
350
|
+
const preamble =
|
|
351
|
+
`[Run context — injected by the agent daemon (this is the real runtime state, NOT your ` +
|
|
352
|
+
`system prompt). Use these for any timestamp/clock or "which run is this" reasoning instead ` +
|
|
353
|
+
`of guessing: ${parts.join(", ")}]`;
|
|
354
|
+
return `${preamble}\n\n${message}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
330
357
|
/** Read the full text of a (possibly null) byte stream; null/error → "". */
|
|
331
358
|
async function drainStream(stream: ReadableStream<Uint8Array> | null): Promise<string> {
|
|
332
359
|
if (!stream) return "";
|
|
@@ -414,6 +441,7 @@ export class ProgrammaticBackend implements AgentBackend {
|
|
|
414
441
|
session: TurnSession,
|
|
415
442
|
onInterim?: InterimSink,
|
|
416
443
|
attachments?: InboundAttachment[],
|
|
444
|
+
runContext?: RunContext,
|
|
417
445
|
): Promise<DeliverResult> {
|
|
418
446
|
const spec = handle.spec;
|
|
419
447
|
if (!spec) {
|
|
@@ -537,13 +565,18 @@ export class ProgrammaticBackend implements AgentBackend {
|
|
|
537
565
|
// per-agent even when the working dir is shared. Best-effort + isolated: a single
|
|
538
566
|
// attachment's fetch/stage failure logs + is SKIPPED (the turn still runs with the rest
|
|
539
567
|
// + the text). Absent/empty → no staging, no prompt change (today's behavior exactly).
|
|
540
|
-
|
|
568
|
+
// RUN CONTEXT (agent#162): prepend the daemon-injected runtime preamble (the real
|
|
569
|
+
// wall-clock + new/resumed + why-it-fired) so the headless turn reads ACCURATE facts
|
|
570
|
+
// instead of fabricating a clock. Done FIRST so the preamble sits at the very top of the
|
|
571
|
+
// prompt; attachments append after the (already-prefixed) message. Absent runContext →
|
|
572
|
+
// the message is unchanged (additive).
|
|
573
|
+
let turnMessage = renderRunContext(message, runContext);
|
|
541
574
|
if (attachments && attachments.length > 0) {
|
|
542
575
|
const staged = await this.stageAttachments(workspace, attachments, vaultArg);
|
|
543
576
|
if (staged.length > 0) {
|
|
544
577
|
const lines = staged.map((s) => `- ${s.absPath} (${s.mimeType})`);
|
|
545
578
|
turnMessage =
|
|
546
|
-
`${
|
|
579
|
+
`${turnMessage}\n\n[Attached files — read them as needed:\n${lines.join("\n")}\n]`;
|
|
547
580
|
}
|
|
548
581
|
}
|
|
549
582
|
|