@inixiative/agent-session 0.8.0 → 0.10.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/README.md +1 -1
- package/package.json +2 -2
- package/src/claude-agent-sdk-session.ts +11 -0
- package/src/claude-code-session.ts +197 -10
- package/src/codex-session.ts +16 -11
- package/src/harness-session.ts +12 -7
- package/src/index.ts +18 -0
- package/src/muse-session.ts +15 -10
- package/src/secret-scratch.ts +595 -0
- package/src/transport.ts +4 -2
package/README.md
CHANGED
|
@@ -84,7 +84,7 @@ const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOM
|
|
|
84
84
|
| Status | ✅ live | ✅ live | ✅ live | ✅ live | stub | stub |
|
|
85
85
|
| Resume / fork | native / native | native / – | native / native | native / native | – | – |
|
|
86
86
|
| Interrupt | acknowledged | acknowledged | acknowledged | acknowledged | – | – |
|
|
87
|
-
| Push |
|
|
87
|
+
| Push | steer | steer | steer | – | – | – |
|
|
88
88
|
| Approvals | callback | callback | callback | callback | – | – |
|
|
89
89
|
| Limits stream / poll | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ | ✅ / – | – | – |
|
|
90
90
|
| Primed reset | fork | fork | – | – | – | – |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@biomejs/biome": "2.5.2",
|
|
44
|
-
"@inixiative/config": "^0.5.
|
|
44
|
+
"@inixiative/config": "^0.5.183",
|
|
45
45
|
"@types/bun": "1.4.4",
|
|
46
46
|
"lefthook": "2.1.9",
|
|
47
47
|
"typescript": "6.0.3"
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type ClaudeCodeSessionConfig,
|
|
24
24
|
type PipedSubprocess,
|
|
25
25
|
} from './claude-code-session';
|
|
26
|
+
import type { PushOutcome } from './harness-session';
|
|
26
27
|
import { TRANSPORTS } from './transport';
|
|
27
28
|
|
|
28
29
|
/** The subset of the SDK's Query this transport uses. */
|
|
@@ -429,6 +430,16 @@ export class ClaudeAgentSdkSession extends ClaudeCodeSession {
|
|
|
429
430
|
this._sdk = config;
|
|
430
431
|
}
|
|
431
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Not wired here: the bridge forwards a steer's `priority` to the SDK's input, but whether the SDK
|
|
435
|
+
* stream surfaces the `command_lifecycle` frames a steer is tracked by has not been verified.
|
|
436
|
+
*/
|
|
437
|
+
override async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
438
|
+
const reason = 'the Agent SDK transport does not steer (not verified)';
|
|
439
|
+
this._emitPushIgnored(payload.kind, reason);
|
|
440
|
+
return { outcome: 'ignored', reason };
|
|
441
|
+
}
|
|
442
|
+
|
|
432
443
|
/** get_usage is not reachable through the SDK Query API. */
|
|
433
444
|
override async readLimits(): Promise<never> {
|
|
434
445
|
throw Error(
|
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
BeforeSendHook,
|
|
52
52
|
HarnessSession,
|
|
53
53
|
NativeInterruptOutcome,
|
|
54
|
+
PushOutcome,
|
|
54
55
|
SessionArtifact,
|
|
55
56
|
SessionEvent,
|
|
56
57
|
SessionEventHandler,
|
|
@@ -218,8 +219,40 @@ interface QueuedTurn {
|
|
|
218
219
|
timeout: number;
|
|
219
220
|
resolve: (result: SessionResult) => void;
|
|
220
221
|
reject: (error: Error) => void;
|
|
222
|
+
/**
|
|
223
|
+
* Messages push() wrote into this turn, by uuid, with their `command_lifecycle` state. One not yet
|
|
224
|
+
* `started` when a result arrives is answered after it, in a follow-on result of the same turn.
|
|
225
|
+
*/
|
|
226
|
+
steers?: Map<string, SteerState>;
|
|
227
|
+
/** The turn reached a result while a steer was still waiting: it settles on the next result. */
|
|
228
|
+
held?: { nativeOutcome: 'completed'; tokens?: SessionTokens };
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
/** `written` until the CLI's `command_lifecycle` names it; `unconfirmed` after push() gave up waiting. */
|
|
232
|
+
type SteerState = 'written' | 'queued' | 'started' | 'completed' | 'cancelled' | 'unconfirmed';
|
|
233
|
+
const STEER_ACK_MS = 10_000;
|
|
234
|
+
|
|
235
|
+
const TOKEN_KEYS = [
|
|
236
|
+
'input',
|
|
237
|
+
'output',
|
|
238
|
+
'cacheRead',
|
|
239
|
+
'cacheWrite',
|
|
240
|
+
'cacheWrite5m',
|
|
241
|
+
'cacheWrite1h',
|
|
242
|
+
'thinking',
|
|
243
|
+
] as const;
|
|
244
|
+
/** One turn's usage over several results (a steer answered after a result). Provider usage is the last result's. */
|
|
245
|
+
const addTokens = (
|
|
246
|
+
a: SessionTokens | undefined,
|
|
247
|
+
b: SessionTokens | undefined,
|
|
248
|
+
): SessionTokens | undefined => {
|
|
249
|
+
if (!a || !b) return b ?? a;
|
|
250
|
+
const sum: SessionTokens = { ...b };
|
|
251
|
+
for (const key of TOKEN_KEYS)
|
|
252
|
+
if (a[key] !== undefined || b[key] !== undefined) sum[key] = (a[key] ?? 0) + (b[key] ?? 0);
|
|
253
|
+
return sum;
|
|
254
|
+
};
|
|
255
|
+
|
|
223
256
|
// ---------------------------------------------------------------------------
|
|
224
257
|
// Session
|
|
225
258
|
// ---------------------------------------------------------------------------
|
|
@@ -342,6 +375,10 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
342
375
|
private _agentsDir?: string;
|
|
343
376
|
/** Inbound control requests awaiting a gate decision, by request id. */
|
|
344
377
|
private _inbound = new Map<string, AbortController>();
|
|
378
|
+
/** push() calls waiting for the CLI to name their message, by uuid. */
|
|
379
|
+
private _steerAcks = new Map<string, (state: SteerState) => void>();
|
|
380
|
+
/** Steers written and not yet ended natively: a late `completed` after their turn settled is theirs. */
|
|
381
|
+
private _steerUuids = new Set<string>();
|
|
345
382
|
private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
|
|
346
383
|
private _limits?: LimitSnapshot;
|
|
347
384
|
private _controlSeq = 0;
|
|
@@ -501,23 +538,128 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
501
538
|
}
|
|
502
539
|
|
|
503
540
|
/**
|
|
504
|
-
* Mid-turn
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* signal reaches the in-flight turn as a tool result the model must read.
|
|
541
|
+
* Mid-turn steer: a stream-json user message with `priority: "next"` and its own `uuid`, written
|
|
542
|
+
* while the owned turn runs. The CLI answers `command_lifecycle` queued → started → completed for
|
|
543
|
+
* that uuid. It takes the message in at the turn's next model request (after a tool result), or,
|
|
544
|
+
* when the turn's reply was already final, runs it right after that result as a follow-on result:
|
|
545
|
+
* either way it is this turn's, and send() settles on the last result (validated against claude
|
|
546
|
+
* 2.1.316, fixtures/s0/claude-steer-*.jsonl).
|
|
511
547
|
*/
|
|
512
|
-
async push(payload: { kind: string; text: string }): Promise<
|
|
548
|
+
async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
549
|
+
const ignored = (reason: string): PushOutcome => {
|
|
550
|
+
this._emitPushIgnored(payload.kind, reason);
|
|
551
|
+
return { outcome: 'ignored', reason };
|
|
552
|
+
};
|
|
553
|
+
const turn = this._inflight;
|
|
554
|
+
const a = turn?.evidence;
|
|
555
|
+
if (!turn || !a || a.dispatch !== 'attempted' || a.nativeOutcome !== 'unknown')
|
|
556
|
+
return ignored('no owned in-flight turn');
|
|
557
|
+
if (!this._proc || !this._alive) return ignored('session not running');
|
|
558
|
+
const uuid = crypto.randomUUID();
|
|
559
|
+
turn.steers ??= new Map();
|
|
560
|
+
const steers = turn.steers;
|
|
561
|
+
steers.set(uuid, 'written');
|
|
562
|
+
this._steerUuids.add(uuid);
|
|
563
|
+
const acknowledged = new Promise<SteerState>((resolve) => this._steerAcks.set(uuid, resolve));
|
|
564
|
+
try {
|
|
565
|
+
this._proc.stdin.write(
|
|
566
|
+
`${JSON.stringify({
|
|
567
|
+
type: 'user',
|
|
568
|
+
message: { role: 'user', content: [{ type: 'text', text: payload.text }] },
|
|
569
|
+
uuid,
|
|
570
|
+
parent_tool_use_id: null,
|
|
571
|
+
priority: 'next',
|
|
572
|
+
})}\n`,
|
|
573
|
+
);
|
|
574
|
+
this._proc.stdin.flush();
|
|
575
|
+
} catch (error) {
|
|
576
|
+
steers.delete(uuid);
|
|
577
|
+
this._steerAcks.delete(uuid);
|
|
578
|
+
return ignored(`write failed: ${(error as Error).message.slice(0, 200)}`);
|
|
579
|
+
}
|
|
580
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
581
|
+
const state = await Promise.race([
|
|
582
|
+
acknowledged,
|
|
583
|
+
new Promise<'unconfirmed'>((resolve) => {
|
|
584
|
+
timer = setTimeout(() => resolve('unconfirmed'), STEER_ACK_MS);
|
|
585
|
+
}),
|
|
586
|
+
]).finally(() => clearTimeout(timer));
|
|
587
|
+
this._steerAcks.delete(uuid);
|
|
588
|
+
const identity = { ...a.identity, admissionId: a.admissionId };
|
|
589
|
+
if (state === 'cancelled')
|
|
590
|
+
return ignored('the runtime cancelled it before the turn took it in');
|
|
591
|
+
if (state === 'unconfirmed') {
|
|
592
|
+
// Never named by the CLI: it no longer holds the turn open.
|
|
593
|
+
if (steers.get(uuid) === 'written') steers.set(uuid, 'unconfirmed');
|
|
594
|
+
this._releaseHeld(turn);
|
|
595
|
+
this._emit({
|
|
596
|
+
...identity,
|
|
597
|
+
kind: 'native_status',
|
|
598
|
+
timestamp: Date.now(),
|
|
599
|
+
raw: { type: 'push-unknown', kind: payload.kind },
|
|
600
|
+
});
|
|
601
|
+
return { outcome: 'unknown' };
|
|
602
|
+
}
|
|
603
|
+
this._emit({
|
|
604
|
+
...identity,
|
|
605
|
+
kind: 'native_status',
|
|
606
|
+
timestamp: Date.now(),
|
|
607
|
+
raw: { type: 'push-steered', kind: payload.kind, uuid },
|
|
608
|
+
});
|
|
609
|
+
return { outcome: 'steered' };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
protected _emitPushIgnored(kind: string, reason: string): void {
|
|
513
613
|
this._emit({
|
|
514
614
|
kind: 'error',
|
|
515
615
|
timestamp: Date.now(),
|
|
516
|
-
text: `push_ignored: kind=${
|
|
517
|
-
raw:
|
|
616
|
+
text: `push_ignored: kind=${kind} — ${reason}`,
|
|
617
|
+
raw: { kind },
|
|
518
618
|
});
|
|
519
619
|
}
|
|
520
620
|
|
|
621
|
+
/** A steer of `turn` the CLI has not started yet (or not named yet). */
|
|
622
|
+
private _steerWaiting(turn: QueuedTurn): boolean {
|
|
623
|
+
for (const state of turn.steers?.values() ?? [])
|
|
624
|
+
if (state === 'written' || state === 'queued') return true;
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* A held turn settles with the result it already had once no steer can still produce a result:
|
|
630
|
+
* none waiting, and none started (a started steer of a held turn is its follow-on run).
|
|
631
|
+
*/
|
|
632
|
+
private _releaseHeld(turn: QueuedTurn): void {
|
|
633
|
+
if (!turn.held || this._inflight !== turn || this._steerWaiting(turn)) return;
|
|
634
|
+
for (const state of turn.steers?.values() ?? []) if (state === 'started') return;
|
|
635
|
+
const a = turn.evidence;
|
|
636
|
+
a.tokens = turn.held.tokens;
|
|
637
|
+
a.nativeOutcome = turn.held.nativeOutcome;
|
|
638
|
+
turn.held = undefined;
|
|
639
|
+
this._resolveTurn();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Withdraw `turn`'s steers the CLI has not started, so none outlives it as an unowned turn. */
|
|
643
|
+
private async _withdrawSteers(turn: QueuedTurn, timeoutMs: number): Promise<void> {
|
|
644
|
+
const waiting = [...(turn.steers ?? [])].filter(
|
|
645
|
+
([, state]) => state === 'queued' || state === 'written',
|
|
646
|
+
);
|
|
647
|
+
await Promise.all(
|
|
648
|
+
waiting.map(([uuid]) =>
|
|
649
|
+
this._controlRequest(
|
|
650
|
+
{ subtype: 'cancel_async_message', message_uuid: uuid },
|
|
651
|
+
timeoutMs,
|
|
652
|
+
).then(
|
|
653
|
+
(response) => {
|
|
654
|
+
if ((response.response as { cancelled?: unknown } | undefined)?.cancelled === true)
|
|
655
|
+
turn.steers?.set(uuid, 'cancelled');
|
|
656
|
+
},
|
|
657
|
+
() => {},
|
|
658
|
+
),
|
|
659
|
+
),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
521
663
|
// ---------------------------------------------------------------------------
|
|
522
664
|
// start() — spawn the persistent process
|
|
523
665
|
// ---------------------------------------------------------------------------
|
|
@@ -822,6 +964,10 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
822
964
|
if (turn?.evidence.dispatch !== 'attempted' || turn.evidence.nativeOutcome !== 'unknown')
|
|
823
965
|
return 'no-turn';
|
|
824
966
|
const deadline = Date.now() + (opts?.timeoutMs ?? 5_000);
|
|
967
|
+
// A stop is for the whole turn: a steer still waiting dies with it, never runs after it.
|
|
968
|
+
await this._withdrawSteers(turn, Math.max(1, Math.min(2_000, deadline - Date.now())));
|
|
969
|
+
this._releaseHeld(turn);
|
|
970
|
+
if (turn.evidence.nativeOutcome !== 'unknown') return 'acknowledged';
|
|
825
971
|
try {
|
|
826
972
|
await this._controlRequest({ subtype: 'interrupt' }, Math.max(1, deadline - Date.now()));
|
|
827
973
|
} catch {
|
|
@@ -1278,6 +1424,30 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1278
1424
|
return;
|
|
1279
1425
|
}
|
|
1280
1426
|
|
|
1427
|
+
// A steer's lifecycle: the CLI naming a message push() wrote into the owned turn.
|
|
1428
|
+
if (raw.type === 'command_lifecycle' && typeof raw.command_uuid === 'string') {
|
|
1429
|
+
const turn = this._inflight;
|
|
1430
|
+
const state = raw.state;
|
|
1431
|
+
const ours = this._steerUuids.has(raw.command_uuid);
|
|
1432
|
+
if (ours && (state === 'completed' || state === 'cancelled'))
|
|
1433
|
+
this._steerUuids.delete(raw.command_uuid);
|
|
1434
|
+
if (
|
|
1435
|
+
turn?.steers?.has(raw.command_uuid) &&
|
|
1436
|
+
(state === 'queued' ||
|
|
1437
|
+
state === 'started' ||
|
|
1438
|
+
state === 'completed' ||
|
|
1439
|
+
state === 'cancelled')
|
|
1440
|
+
) {
|
|
1441
|
+
turn.steers.set(raw.command_uuid, state);
|
|
1442
|
+
this._steerAcks.get(raw.command_uuid)?.(state);
|
|
1443
|
+
if (state === 'cancelled') this._releaseHeld(turn);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
// A steer's own end after its turn settled (its follow-on result settled the turn first).
|
|
1447
|
+
if (!ours) this._unattributed(raw, 'no-admission');
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1281
1451
|
// Account limits are account-level evidence, never part of an admission's output.
|
|
1282
1452
|
if (raw.type === 'rate_limit_event') {
|
|
1283
1453
|
const snapshot = claudeRateLimitSnapshot(raw.rate_limit_info);
|
|
@@ -1353,6 +1523,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1353
1523
|
correlation: 'ordered-stream',
|
|
1354
1524
|
};
|
|
1355
1525
|
const terminal = raw.type === 'result';
|
|
1526
|
+
let holding = false;
|
|
1356
1527
|
if (terminal) {
|
|
1357
1528
|
if (terminalKey) this._seenTerminals.add(terminalKey);
|
|
1358
1529
|
const subtype = typeof raw.subtype === 'string' ? raw.subtype : undefined;
|
|
@@ -1369,6 +1540,12 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1369
1540
|
(status !== undefined && status >= 400) ||
|
|
1370
1541
|
reason === 'api_error';
|
|
1371
1542
|
a.nativeOutcome = failed ? 'failed' : subtype === 'success' ? 'completed' : 'unknown';
|
|
1543
|
+
// A steer the CLI has not started yet runs after this result, still in this turn: the turn
|
|
1544
|
+
// stays open (and this result reads as an intermediate one) until the steer's own result.
|
|
1545
|
+
if (a.nativeOutcome === 'completed' && this._steerWaiting(turn)) {
|
|
1546
|
+
holding = true;
|
|
1547
|
+
a.nativeOutcome = 'unknown';
|
|
1548
|
+
}
|
|
1372
1549
|
a.terminal = {
|
|
1373
1550
|
type: 'result',
|
|
1374
1551
|
eventId: terminalKey,
|
|
@@ -1405,6 +1582,16 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1405
1582
|
this._emit(e);
|
|
1406
1583
|
}
|
|
1407
1584
|
if (terminal) {
|
|
1585
|
+
// Each result reports its own run's usage: the turn's is their sum.
|
|
1586
|
+
const tokens = addTokens(turn.held?.tokens, a.tokens);
|
|
1587
|
+
if (holding) {
|
|
1588
|
+
turn.held = { nativeOutcome: 'completed', ...(tokens ? { tokens } : {}) };
|
|
1589
|
+
a.tokens = tokens;
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
turn.held = undefined;
|
|
1593
|
+
a.tokens = tokens;
|
|
1594
|
+
if (a.nativeOutcome === 'failed') void this._withdrawSteers(turn, 2_000);
|
|
1408
1595
|
if (a.nativeOutcome !== 'unknown') this._resolveTurn();
|
|
1409
1596
|
else
|
|
1410
1597
|
this._rejectInflight(
|
package/src/codex-session.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
BeforeSendHook,
|
|
21
21
|
HarnessSession,
|
|
22
22
|
NativeInterruptOutcome,
|
|
23
|
+
PushOutcome,
|
|
23
24
|
SessionArtifact,
|
|
24
25
|
SessionEvent,
|
|
25
26
|
SessionEventHandler,
|
|
@@ -886,15 +887,17 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
886
887
|
}
|
|
887
888
|
|
|
888
889
|
/** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
|
|
889
|
-
async push(payload: { kind: string; text: string }): Promise<
|
|
890
|
+
async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
890
891
|
const a = this._inflight?.evidence;
|
|
891
|
-
const ignored = (reason: string) =>
|
|
892
|
+
const ignored = (reason: string): PushOutcome => {
|
|
892
893
|
this._emit({
|
|
893
894
|
kind: 'error',
|
|
894
895
|
timestamp: Date.now(),
|
|
895
896
|
text: `push_ignored: kind=${payload.kind} — ${reason}`,
|
|
896
897
|
raw: { kind: payload.kind },
|
|
897
898
|
});
|
|
899
|
+
return { outcome: 'ignored', reason };
|
|
900
|
+
};
|
|
898
901
|
if (
|
|
899
902
|
a?.dispatch !== 'attempted' ||
|
|
900
903
|
a.nativeOutcome !== 'unknown' ||
|
|
@@ -925,17 +928,19 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
925
928
|
timestamp: Date.now(),
|
|
926
929
|
raw: { type: 'push-steered', kind: payload.kind },
|
|
927
930
|
});
|
|
931
|
+
return { outcome: 'steered' };
|
|
928
932
|
} catch (error) {
|
|
929
933
|
// A refusal is the runtime's answer; no answer leaves delivery unknown, never "ignored".
|
|
930
|
-
if (timedOut
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}
|
|
938
|
-
|
|
934
|
+
if (!timedOut && this._alive)
|
|
935
|
+
return ignored(`turn/steer refused: ${(error as Error).message.slice(0, 200)}`);
|
|
936
|
+
this._emit({
|
|
937
|
+
...a.identity,
|
|
938
|
+
admissionId: a.admissionId,
|
|
939
|
+
kind: 'native_status',
|
|
940
|
+
timestamp: Date.now(),
|
|
941
|
+
raw: { type: 'push-unknown', kind: payload.kind },
|
|
942
|
+
});
|
|
943
|
+
return { outcome: 'unknown' };
|
|
939
944
|
} finally {
|
|
940
945
|
clearTimeout(timer);
|
|
941
946
|
}
|
package/src/harness-session.ts
CHANGED
|
@@ -411,6 +411,11 @@ export type ToolGate = (request: ToolRequest) => ToolDecision | Promise<ToolDeci
|
|
|
411
411
|
/** `acknowledged`: the runtime confirmed the stop and the turn reached a native terminal. */
|
|
412
412
|
export type NativeInterruptOutcome = 'acknowledged' | 'no-turn' | 'unacknowledged';
|
|
413
413
|
|
|
414
|
+
/** What became of a push(): `steered` (the runtime took it into the owned turn), `unknown` (sent, never confirmed), `ignored` (not delivered; `reason` says why). */
|
|
415
|
+
export type PushOutcome =
|
|
416
|
+
| { readonly outcome: 'steered' | 'unknown' }
|
|
417
|
+
| { readonly outcome: 'ignored'; readonly reason: string };
|
|
418
|
+
|
|
414
419
|
export interface HarnessSession {
|
|
415
420
|
/** How this session reaches its model, with the capabilities that transport declares. */
|
|
416
421
|
readonly transport?: TransportDescriptor;
|
|
@@ -484,15 +489,15 @@ export interface HarnessSession {
|
|
|
484
489
|
onToolRequest?(gate: ToolGate): () => void;
|
|
485
490
|
|
|
486
491
|
/**
|
|
487
|
-
* Mid-turn push:
|
|
488
|
-
*
|
|
489
|
-
*
|
|
492
|
+
* Mid-turn push (steer): add input to the owned in-flight turn, where the transport declares
|
|
493
|
+
* `push: "steer"`. It never opens a turn of its own: the pushed text is answered inside the turn
|
|
494
|
+
* it was pushed into, whose send() settles once that turn (and everything pushed into it) ends.
|
|
490
495
|
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
496
|
+
* Never throws. `ignored` (with an emitted "push_ignored" error event) means nothing was
|
|
497
|
+
* delivered: no owned in-flight turn, the runtime refused, or the transport cannot steer.
|
|
498
|
+
* `unknown` means it was written but the runtime never confirmed it.
|
|
494
499
|
*/
|
|
495
|
-
push(payload: { kind: string; text: string }): Promise<
|
|
500
|
+
push(payload: { kind: string; text: string }): Promise<PushOutcome>;
|
|
496
501
|
|
|
497
502
|
/** Full session record for Oracle evaluation. */
|
|
498
503
|
artifact(): SessionArtifact;
|
package/src/index.ts
CHANGED
|
@@ -141,6 +141,24 @@ export {
|
|
|
141
141
|
type SubscriptionCandidate,
|
|
142
142
|
type UnknownUtilizationPolicy,
|
|
143
143
|
} from './routing';
|
|
144
|
+
// The secret scratch pad (AS-009): `$$word` secrets used by reference, never read.
|
|
145
|
+
export {
|
|
146
|
+
formatScratchRef,
|
|
147
|
+
isPrivatePath,
|
|
148
|
+
type PrivatePathOptions,
|
|
149
|
+
parseScratchRef,
|
|
150
|
+
privatePathDenial,
|
|
151
|
+
privatePathGate,
|
|
152
|
+
type ScratchDagger,
|
|
153
|
+
type ScratchPadSummary,
|
|
154
|
+
type ScratchRef,
|
|
155
|
+
type ScratchSource,
|
|
156
|
+
type ScratchUse,
|
|
157
|
+
SECRET_MARKER,
|
|
158
|
+
SECRET_SCRATCH_GUIDANCE,
|
|
159
|
+
SecretScratchError,
|
|
160
|
+
SecretScratchPad,
|
|
161
|
+
} from './secret-scratch';
|
|
144
162
|
// Transports: how a session reaches its model, with declared capabilities.
|
|
145
163
|
export {
|
|
146
164
|
TRANSPORTS,
|
package/src/muse-session.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type HarnessSession,
|
|
19
19
|
NativeBindingMissingError,
|
|
20
20
|
type NativeInterruptOutcome,
|
|
21
|
+
type PushOutcome,
|
|
21
22
|
type SessionArtifact,
|
|
22
23
|
type SessionAttempt,
|
|
23
24
|
type SessionEvent,
|
|
@@ -1859,16 +1860,18 @@ export class MuseSession implements HarnessSession {
|
|
|
1859
1860
|
}
|
|
1860
1861
|
|
|
1861
1862
|
/** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
|
|
1862
|
-
async push(payload: { kind: string; text: string }): Promise<
|
|
1863
|
+
async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
1863
1864
|
const a = this._inflight?.evidence;
|
|
1864
1865
|
const sessionId = this._externalSessionId;
|
|
1865
|
-
const ignored = (reason: string) =>
|
|
1866
|
+
const ignored = (reason: string): PushOutcome => {
|
|
1866
1867
|
this._emit({
|
|
1867
1868
|
kind: 'error',
|
|
1868
1869
|
timestamp: Date.now(),
|
|
1869
1870
|
text: `push_ignored: kind=${payload.kind} — ${reason}`,
|
|
1870
1871
|
raw: { kind: payload.kind },
|
|
1871
1872
|
});
|
|
1873
|
+
return { outcome: 'ignored', reason };
|
|
1874
|
+
};
|
|
1872
1875
|
if (
|
|
1873
1876
|
a?.dispatch !== 'attempted' ||
|
|
1874
1877
|
a.nativeOutcome !== 'unknown' ||
|
|
@@ -1894,16 +1897,18 @@ export class MuseSession implements HarnessSession {
|
|
|
1894
1897
|
timestamp: Date.now(),
|
|
1895
1898
|
raw: { type: 'push-steered', kind: payload.kind },
|
|
1896
1899
|
});
|
|
1900
|
+
return { outcome: 'steered' };
|
|
1897
1901
|
} catch (error) {
|
|
1898
1902
|
// A refusal is the runtime's answer; no answer leaves delivery unknown, never "ignored".
|
|
1899
|
-
if (error instanceof MspError)
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1903
|
+
if (error instanceof MspError)
|
|
1904
|
+
return ignored(`turn/steer refused: ${error.message.slice(0, 200)}`);
|
|
1905
|
+
this._emit({
|
|
1906
|
+
...identity,
|
|
1907
|
+
kind: 'native_status',
|
|
1908
|
+
timestamp: Date.now(),
|
|
1909
|
+
raw: { type: 'push-unknown', kind: payload.kind },
|
|
1910
|
+
});
|
|
1911
|
+
return { outcome: 'unknown' };
|
|
1907
1912
|
}
|
|
1908
1913
|
}
|
|
1909
1914
|
}
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { basename, isAbsolute, join, resolve, sep } from 'node:path';
|
|
13
|
+
import type { ToolGate, ToolRequest } from './harness-session';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The secret scratch pad (AS-009): secrets a person hands an agent as `$$word` are lifted out of the
|
|
17
|
+
* message into a private file per thread, and the agent uses them by reference without reading them.
|
|
18
|
+
*
|
|
19
|
+
* One pad per thread accumulates: a dagger per message that carried secrets, a numbered block per
|
|
20
|
+
* secret, a blank line, the next message. A reference is thread, message and position; inside its
|
|
21
|
+
* own thread it is written `†<message>.<n>`, anywhere else `†<thread>/<message>.<n>`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The marker: a word starting with it is a secret, up to the next whitespace. */
|
|
25
|
+
export const SECRET_MARKER = '$$';
|
|
26
|
+
|
|
27
|
+
/** What the agent is told, wherever the tool is offered: use the values, never read them. */
|
|
28
|
+
export const SECRET_SCRATCH_GUIDANCE =
|
|
29
|
+
"A reference like †<message>.<n> in a message is a secret the person handed you, held in the thread's scratch pad. You never see the value and must not try to read it: use it. The normal path is env (set KEY in an env file) and then pipe from that env file (a command gets the value on its stdin; its output is thrown away and only the exit code comes back). write puts a value in a file. Pass consume: true on the last use to remove it from the pad. list shows what is pending, here or, given a thread, in another thread whose references you have. A file the pad has written to is then off limits to read, search or print, like files named .private, *.private or *.private.*: use it through pipe. A secret is one word after $$; a multi-line secret cannot be lifted.";
|
|
30
|
+
|
|
31
|
+
const REF = /^†?(?:([A-Za-z0-9_.:-]+)\/)?([A-Za-z0-9_.:-]+)\.(\d+)$/;
|
|
32
|
+
const ID = /^[A-Za-z0-9_:-][A-Za-z0-9_.:-]*$/;
|
|
33
|
+
const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
34
|
+
const MARKED = /(^|\s)\$\$(\S+)/g;
|
|
35
|
+
const PAD_SUFFIX = '.pad';
|
|
36
|
+
/** A held value shorter than this is not scrubbed from text: it would match ordinary words. */
|
|
37
|
+
const SCRUB_MIN = 6;
|
|
38
|
+
|
|
39
|
+
export interface ScratchRef {
|
|
40
|
+
readonly thread: string;
|
|
41
|
+
readonly message: string;
|
|
42
|
+
readonly position: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A dagger as the agent may see it: where it came from and what is pending, never a value. */
|
|
46
|
+
export interface ScratchDagger {
|
|
47
|
+
readonly message: string;
|
|
48
|
+
readonly at: string;
|
|
49
|
+
/** The start of the message, with its secrets already replaced by references. */
|
|
50
|
+
readonly excerpt: string;
|
|
51
|
+
readonly positions: readonly number[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ScratchPadSummary {
|
|
55
|
+
readonly thread: string;
|
|
56
|
+
readonly daggers: readonly ScratchDagger[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where `pipe` reads its value: a held block, or a key already set in an env file. */
|
|
60
|
+
export type ScratchSource =
|
|
61
|
+
| { readonly ref: string }
|
|
62
|
+
| { readonly envFile: string; readonly key: string };
|
|
63
|
+
|
|
64
|
+
export interface ScratchUse {
|
|
65
|
+
/** Remove the block from the pad once this use succeeds. */
|
|
66
|
+
readonly consume?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A failure that names references and paths only, never a value. */
|
|
70
|
+
export class SecretScratchError extends Error {
|
|
71
|
+
override readonly name = 'SecretScratchError';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Dagger {
|
|
75
|
+
message: string;
|
|
76
|
+
at: string;
|
|
77
|
+
excerpt: string;
|
|
78
|
+
blocks: Map<number, string>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `†<message>.<n>` inside `thread`, `†<thread>/<message>.<n>` from anywhere. */
|
|
82
|
+
export function formatScratchRef(ref: ScratchRef, from?: string): string {
|
|
83
|
+
return from === ref.thread
|
|
84
|
+
? `†${ref.message}.${ref.position}`
|
|
85
|
+
: `†${ref.thread}/${ref.message}.${ref.position}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A reference as written by the agent; a short one belongs to `thread`. */
|
|
89
|
+
export function parseScratchRef(text: string, thread: string): ScratchRef {
|
|
90
|
+
const match = REF.exec(text.trim());
|
|
91
|
+
if (!match) throw new SecretScratchError(`Not a scratch pad reference: ${clip(text)}`);
|
|
92
|
+
const position = Number(match[3]);
|
|
93
|
+
const ref = { thread: match[1] ?? thread, message: match[2]!, position };
|
|
94
|
+
if (!ID.test(ref.thread) || !ID.test(ref.message) || position < 1)
|
|
95
|
+
throw new SecretScratchError(`Not a scratch pad reference: ${clip(text)}`);
|
|
96
|
+
return ref;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A path no agent reads: a `.private` segment, a name ending in `.private` or holding `.private.`, or an extra glob. */
|
|
100
|
+
export function isPrivatePath(path: string, extra: readonly string[] = []): boolean {
|
|
101
|
+
const segments = path.split(/[\\/]+/).filter(Boolean);
|
|
102
|
+
if (
|
|
103
|
+
segments.some(
|
|
104
|
+
(segment) =>
|
|
105
|
+
segment === '.private' || segment.endsWith('.private') || segment.includes('.private.'),
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return true;
|
|
109
|
+
return extra.some((pattern) => {
|
|
110
|
+
const glob = new Bun.Glob(pattern);
|
|
111
|
+
return glob.match(path) || glob.match(basename(path));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const PATH_KEYS = [
|
|
116
|
+
'file_path',
|
|
117
|
+
'path',
|
|
118
|
+
'paths',
|
|
119
|
+
'notebook_path',
|
|
120
|
+
'pattern',
|
|
121
|
+
'glob',
|
|
122
|
+
'include',
|
|
123
|
+
'cwd',
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
/** Every path and command word a tool call names. */
|
|
127
|
+
function namedTargets(request: Pick<ToolRequest, 'toolInput' | 'blockedPath'>): string[] {
|
|
128
|
+
const input = request.toolInput;
|
|
129
|
+
const changes = Array.isArray(input.changes) ? input.changes : [];
|
|
130
|
+
const command = Array.isArray(input.command)
|
|
131
|
+
? input.command.filter((part): part is string => typeof part === 'string').join(' ')
|
|
132
|
+
: input.command;
|
|
133
|
+
return [
|
|
134
|
+
request.blockedPath,
|
|
135
|
+
...PATH_KEYS.flatMap((key) => (Array.isArray(input[key]) ? input[key] : [input[key]])),
|
|
136
|
+
...changes.flatMap((change) => {
|
|
137
|
+
const c = change as { path?: unknown; movePath?: unknown } | null;
|
|
138
|
+
return [c?.path, c?.movePath];
|
|
139
|
+
}),
|
|
140
|
+
...(typeof command === 'string' ? command.replace(/\\/g, '').split(/[\s'"`;|&<>()=]+/) : []),
|
|
141
|
+
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface PrivatePathOptions {
|
|
145
|
+
/** The scratch pad directory, private whatever its name. */
|
|
146
|
+
readonly scratchDir?: string;
|
|
147
|
+
/** Extra globs that are private too (`*.pem`). */
|
|
148
|
+
readonly privatePaths?: readonly string[];
|
|
149
|
+
/** Files the tool has written secrets into (`SecretScratchPad.heldFiles()`): used through pipe, never read. */
|
|
150
|
+
readonly heldFiles?: readonly string[];
|
|
151
|
+
/** Whether a call is the scratch tool's own (it names paths it writes, never reads). Default: none is. */
|
|
152
|
+
readonly isOwnTool?: (
|
|
153
|
+
request: Pick<ToolRequest, 'toolName' | 'toolServer' | 'toolMethod'>,
|
|
154
|
+
) => boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A command word or path that names a held file: the same absolute path, or a relative path ending one. */
|
|
158
|
+
function namesHeldFile(target: string, held: readonly string[]): boolean {
|
|
159
|
+
if (isAbsolute(target)) return held.includes(resolve(target));
|
|
160
|
+
const tail = target.replace(/^(\.\/)+/, '');
|
|
161
|
+
if (!tail || tail === '.' || tail.startsWith('..')) return false;
|
|
162
|
+
return held.some((file) => file.endsWith(`${sep}${tail}`));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Why a tool call is refused for naming a private path or the scratch pad; undefined when it names none. */
|
|
166
|
+
export function privatePathDenial(
|
|
167
|
+
request: Pick<
|
|
168
|
+
ToolRequest,
|
|
169
|
+
'toolInput' | 'blockedPath' | 'toolMethod' | 'toolName' | 'toolServer'
|
|
170
|
+
>,
|
|
171
|
+
options: PrivatePathOptions = {},
|
|
172
|
+
): string | undefined {
|
|
173
|
+
if (options.isOwnTool?.(request)) return undefined;
|
|
174
|
+
const scratchDir = options.scratchDir ? resolve(options.scratchDir) : undefined;
|
|
175
|
+
const hit = namedTargets(request).find(
|
|
176
|
+
(target) =>
|
|
177
|
+
isPrivatePath(target, options.privatePaths) ||
|
|
178
|
+
namesHeldFile(target, options.heldFiles ?? []) ||
|
|
179
|
+
(scratchDir !== undefined &&
|
|
180
|
+
isAbsolute(target) &&
|
|
181
|
+
(resolve(target) === scratchDir || resolve(target).startsWith(scratchDir + sep))),
|
|
182
|
+
);
|
|
183
|
+
if (!hit) return undefined;
|
|
184
|
+
return `${clip(hit)} is private: it is never read, searched or printed. Use the scratch pad's tools instead: pipe from an env file, or env, pipe or write a held reference. They never show a value.`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A tool gate that denies every call naming a private path or the scratch pad. */
|
|
188
|
+
export function privatePathGate(options: PrivatePathOptions = {}): ToolGate {
|
|
189
|
+
return (request) => {
|
|
190
|
+
const message = privatePathDenial(request, options);
|
|
191
|
+
return message ? { behavior: 'deny', message } : { behavior: 'allow' };
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The pads of one directory: one file per thread, 0600 in a 0700 directory. Every pad change is a
|
|
197
|
+
* synchronous read, edit and rewrite (a temporary file and a rename), so in one process no two
|
|
198
|
+
* changes interleave and a turn path that cannot wait can lift.
|
|
199
|
+
*/
|
|
200
|
+
export class SecretScratchPad {
|
|
201
|
+
private readonly _dir: string;
|
|
202
|
+
|
|
203
|
+
constructor(dir: string) {
|
|
204
|
+
this._dir = resolve(dir);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get dir(): string {
|
|
208
|
+
return this._dir;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Lift every `$$word` out of `text` into `thread`'s pad, under the dagger for `message`. */
|
|
212
|
+
lift(
|
|
213
|
+
text: string,
|
|
214
|
+
at: { thread: string; message: string; now?: Date },
|
|
215
|
+
): { text: string; refs: string[] } {
|
|
216
|
+
const values = [...text.matchAll(MARKED)].map((match) => match[2] ?? '');
|
|
217
|
+
if (!values.length) return { text, refs: [] };
|
|
218
|
+
checkId(at.thread, 'thread');
|
|
219
|
+
checkId(at.message, 'message');
|
|
220
|
+
const daggers = this._read(at.thread);
|
|
221
|
+
let dagger = daggers.find((d) => d.message === at.message);
|
|
222
|
+
const first = dagger ? Math.max(0, ...dagger.blocks.keys()) + 1 : 1;
|
|
223
|
+
const refs: string[] = [];
|
|
224
|
+
const lifted = text.replace(MARKED, (_whole, lead: string) => {
|
|
225
|
+
const ref = formatScratchRef(
|
|
226
|
+
{ thread: at.thread, message: at.message, position: first + refs.length },
|
|
227
|
+
at.thread,
|
|
228
|
+
);
|
|
229
|
+
refs.push(ref);
|
|
230
|
+
return `${lead}${ref}`;
|
|
231
|
+
});
|
|
232
|
+
if (!dagger) {
|
|
233
|
+
dagger = {
|
|
234
|
+
message: at.message,
|
|
235
|
+
at: (at.now ?? new Date()).toISOString(),
|
|
236
|
+
excerpt: lifted.replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
237
|
+
blocks: new Map(),
|
|
238
|
+
};
|
|
239
|
+
daggers.push(dagger);
|
|
240
|
+
}
|
|
241
|
+
for (const [i, value] of values.entries()) dagger.blocks.set(first + i, value);
|
|
242
|
+
this._write(at.thread, daggers);
|
|
243
|
+
return { text: lifted, refs };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Every pad here (or one thread's) with what it holds pending, never the values. */
|
|
247
|
+
list(thread?: string): ScratchPadSummary[] {
|
|
248
|
+
const threads = thread ? [checkId(thread, 'thread')] : this.threads();
|
|
249
|
+
return threads
|
|
250
|
+
.map((id) => ({ thread: id, daggers: this._read(id).map(summary) }))
|
|
251
|
+
.filter((pad) => pad.daggers.length > 0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The threads that have a pad here. */
|
|
255
|
+
threads(): string[] {
|
|
256
|
+
let names: string[];
|
|
257
|
+
try {
|
|
258
|
+
names = readdirSync(this._dir);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
return names
|
|
263
|
+
.filter((name) => name.endsWith(PAD_SUFFIX))
|
|
264
|
+
.map((name) => name.slice(0, -PAD_SUFFIX.length))
|
|
265
|
+
.filter((id) => ID.test(id))
|
|
266
|
+
.sort();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Files the tool has written secrets into, absolute: the gate keeps them from being read. */
|
|
270
|
+
heldFiles(): string[] {
|
|
271
|
+
const text = readText(this._heldPath());
|
|
272
|
+
if (!text) return [];
|
|
273
|
+
try {
|
|
274
|
+
const files = JSON.parse(text) as unknown;
|
|
275
|
+
return Array.isArray(files) ? files.filter((f): f is string => typeof f === 'string') : [];
|
|
276
|
+
} catch {
|
|
277
|
+
throw new SecretScratchError('The held-files list is unreadable.');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Write the value into a file: `replace` writes it exactly, `append` adds it on its own line. New files are 0600. */
|
|
282
|
+
write(
|
|
283
|
+
ref: string,
|
|
284
|
+
from: string,
|
|
285
|
+
path: string,
|
|
286
|
+
options: ScratchUse & { mode?: 'replace' | 'append'; cwd?: string } = {},
|
|
287
|
+
): void {
|
|
288
|
+
const target = parseScratchRef(ref, from);
|
|
289
|
+
const value = this._held(target);
|
|
290
|
+
const file = this._target(options.cwd, path);
|
|
291
|
+
const before = options.mode === 'append' ? readText(file) : '';
|
|
292
|
+
const lead = before && !before.endsWith('\n') ? '\n' : '';
|
|
293
|
+
writeTarget(file, options.mode === 'append' ? `${before}${lead}${value}\n` : value);
|
|
294
|
+
this._hold(file);
|
|
295
|
+
if (options.consume) this._drop(target);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Set `KEY=value` in an env file: the line for KEY is replaced, or added at the end. */
|
|
299
|
+
env(
|
|
300
|
+
ref: string,
|
|
301
|
+
from: string,
|
|
302
|
+
path: string,
|
|
303
|
+
key: string,
|
|
304
|
+
options: ScratchUse & { cwd?: string } = {},
|
|
305
|
+
): void {
|
|
306
|
+
if (!ENV_KEY.test(key)) throw new SecretScratchError(`Not an env key: ${clip(key)}`);
|
|
307
|
+
const target = parseScratchRef(ref, from);
|
|
308
|
+
const value = this._held(target);
|
|
309
|
+
const file = this._target(options.cwd, path);
|
|
310
|
+
const text = readText(file);
|
|
311
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
|
312
|
+
const lines = text.split(/\r?\n/);
|
|
313
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
314
|
+
const line = `${key}=${envValue(value)}`;
|
|
315
|
+
// Loaders take the last line for a key, so that is the one replaced.
|
|
316
|
+
const at = lines.findLastIndex((l) => envLineKey(l) === key);
|
|
317
|
+
if (at >= 0) lines[at] = line;
|
|
318
|
+
else lines.push(line);
|
|
319
|
+
writeTarget(file, `${lines.join(newline)}${newline}`);
|
|
320
|
+
this._hold(file);
|
|
321
|
+
if (options.consume) this._drop(target);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Run `command` in a shell with the value on its stdin. Its output is thrown away; only the exit
|
|
326
|
+
* code comes back. The value comes from a held block or a key already in an env file. A failed
|
|
327
|
+
* command keeps its block, so it can be tried again.
|
|
328
|
+
*/
|
|
329
|
+
async pipe(
|
|
330
|
+
source: ScratchSource,
|
|
331
|
+
from: string,
|
|
332
|
+
command: string,
|
|
333
|
+
options: ScratchUse & { cwd?: string; timeoutMs?: number } = {},
|
|
334
|
+
): Promise<{ exitCode: number }> {
|
|
335
|
+
if ('ref' in source) {
|
|
336
|
+
const target = parseScratchRef(source.ref, from);
|
|
337
|
+
const result = await runWithStdin(command, this._held(target), options);
|
|
338
|
+
if (options.consume && result.exitCode === 0) this._drop(target);
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
if (!ENV_KEY.test(source.key))
|
|
342
|
+
throw new SecretScratchError(`Not an env key: ${clip(source.key)}`);
|
|
343
|
+
const file = this._target(options.cwd, source.envFile);
|
|
344
|
+
if (!existsSync(file)) throw new SecretScratchError(`No env file at ${clip(source.envFile)}`);
|
|
345
|
+
const line = readText(file)
|
|
346
|
+
.split(/\r?\n/)
|
|
347
|
+
.findLast((l) => envLineKey(l) === source.key);
|
|
348
|
+
if (line === undefined)
|
|
349
|
+
throw new SecretScratchError(`${source.key} is not set in ${clip(source.envFile)}`);
|
|
350
|
+
return runWithStdin(command, envLineValue(line), options);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Remove a block; a dagger with no blocks left goes too, and an empty pad is deleted. */
|
|
354
|
+
consume(ref: string, from: string): void {
|
|
355
|
+
const target = parseScratchRef(ref, from);
|
|
356
|
+
this._held(target);
|
|
357
|
+
this._drop(target);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Delete a thread's pad. */
|
|
361
|
+
clear(thread: string): void {
|
|
362
|
+
rmSync(this._path(checkId(thread, 'thread')), { force: true });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Replace every held value of `thread` found in `text` with its reference. */
|
|
366
|
+
scrub(thread: string, text: string): string {
|
|
367
|
+
const pairs = this._read(checkId(thread, 'thread'))
|
|
368
|
+
.flatMap((d) =>
|
|
369
|
+
[...d.blocks].map(([position, value]) => ({
|
|
370
|
+
value,
|
|
371
|
+
ref: formatScratchRef({ thread, message: d.message, position }, thread),
|
|
372
|
+
})),
|
|
373
|
+
)
|
|
374
|
+
.filter((pair) => pair.value.length >= SCRUB_MIN)
|
|
375
|
+
.sort((a, b) => b.value.length - a.value.length);
|
|
376
|
+
if (!pairs.length) return text;
|
|
377
|
+
const refs = new Map(pairs.map((pair) => [pair.value, pair.ref]));
|
|
378
|
+
const pattern = new RegExp(pairs.map((pair) => RegExp.escape(pair.value)).join('|'), 'g');
|
|
379
|
+
return text.replace(pattern, (value) => refs.get(value) ?? value);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private _held(ref: ScratchRef): string {
|
|
383
|
+
const value = this._read(ref.thread)
|
|
384
|
+
.find((d) => d.message === ref.message)
|
|
385
|
+
?.blocks.get(ref.position);
|
|
386
|
+
if (value === undefined)
|
|
387
|
+
throw new SecretScratchError(
|
|
388
|
+
`Nothing is held at ${formatScratchRef(ref)}: it was consumed, or the reference is wrong. Call list to see what is pending.`,
|
|
389
|
+
);
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Re-read and drop the block, so a lift made while a pipe ran is kept. */
|
|
394
|
+
private _drop(ref: ScratchRef): void {
|
|
395
|
+
const daggers = this._read(ref.thread);
|
|
396
|
+
daggers.find((d) => d.message === ref.message)?.blocks.delete(ref.position);
|
|
397
|
+
this._write(ref.thread, daggers);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** A file the agent names, never inside the pad directory. */
|
|
401
|
+
private _target(cwd: string | undefined, path: string): string {
|
|
402
|
+
const file = resolve(cwd ?? '.', path);
|
|
403
|
+
if (file === this._dir || file.startsWith(this._dir + sep))
|
|
404
|
+
throw new SecretScratchError('The scratch pad directory is not a target.');
|
|
405
|
+
return file;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private _heldPath(): string {
|
|
409
|
+
return join(this._dir, 'held-files.json');
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private _hold(file: string): void {
|
|
413
|
+
const files = this.heldFiles();
|
|
414
|
+
if (files.includes(file)) return;
|
|
415
|
+
mkdirSync(this._dir, { recursive: true, mode: 0o700 });
|
|
416
|
+
writePrivate(this._heldPath(), JSON.stringify([...files, file].sort(), null, 2));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private _path(thread: string): string {
|
|
420
|
+
return join(this._dir, `${thread}${PAD_SUFFIX}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private _read(thread: string): Dagger[] {
|
|
424
|
+
try {
|
|
425
|
+
return parsePad(readText(this._path(thread)));
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof SecretScratchError) throw error;
|
|
428
|
+
throw new SecretScratchError(`The pad for ${thread} is unreadable.`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private _write(thread: string, daggers: Dagger[]): void {
|
|
433
|
+
const live = daggers.filter((d) => d.blocks.size > 0);
|
|
434
|
+
if (!live.length) {
|
|
435
|
+
rmSync(this._path(thread), { force: true });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
mkdirSync(this._dir, { recursive: true, mode: 0o700 });
|
|
439
|
+
chmodSync(this._dir, 0o700);
|
|
440
|
+
writePrivate(this._path(thread), formatPad(thread, live));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function checkId(id: string, what: string): string {
|
|
445
|
+
if (!ID.test(id)) throw new SecretScratchError(`Not a ${what} id: ${clip(id)}`);
|
|
446
|
+
return id;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function clip(text: string): string {
|
|
450
|
+
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function summary(d: Dagger): ScratchDagger {
|
|
454
|
+
return {
|
|
455
|
+
message: d.message,
|
|
456
|
+
at: d.at,
|
|
457
|
+
excerpt: d.excerpt,
|
|
458
|
+
positions: [...d.blocks.keys()].sort((a, b) => a - b),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The pad as written: a line naming the thread, then per dagger its line, its blocks and a blank line. */
|
|
463
|
+
function formatPad(thread: string, daggers: Dagger[]): string {
|
|
464
|
+
const body = daggers.map((d) =>
|
|
465
|
+
[
|
|
466
|
+
`†${d.message} (${d.at}, ${JSON.stringify(d.excerpt)})`,
|
|
467
|
+
...[...d.blocks]
|
|
468
|
+
.sort(([a], [b]) => a - b)
|
|
469
|
+
.map(([position, value]) => `[${position}] ${JSON.stringify(value)}`),
|
|
470
|
+
].join('\n'),
|
|
471
|
+
);
|
|
472
|
+
return `# secret scratch pad: thread ${thread}\n\n${body.join('\n\n')}\n`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parsePad(text: string): Dagger[] {
|
|
476
|
+
const daggers: Dagger[] = [];
|
|
477
|
+
let current: Dagger | undefined;
|
|
478
|
+
for (const line of text.split('\n')) {
|
|
479
|
+
const dagger = /^†(\S+) \((\S+), (".*")\)$/.exec(line);
|
|
480
|
+
if (dagger) {
|
|
481
|
+
current = {
|
|
482
|
+
message: dagger[1]!,
|
|
483
|
+
at: dagger[2]!,
|
|
484
|
+
excerpt: JSON.parse(dagger[3]!) as string,
|
|
485
|
+
blocks: new Map(),
|
|
486
|
+
};
|
|
487
|
+
daggers.push(current);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const block = /^\[(\d+)\] (".*")$/.exec(line);
|
|
491
|
+
if (block && current) {
|
|
492
|
+
current.blocks.set(Number(block[1]), JSON.parse(block[2]!) as string);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
// Anything else but the heading and blank lines is damage: stop rather than rewrite without it.
|
|
496
|
+
if (line !== '' && !line.startsWith('# '))
|
|
497
|
+
throw new SecretScratchError('The pad is unreadable.');
|
|
498
|
+
}
|
|
499
|
+
return daggers;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function writePrivate(path: string, text: string): void {
|
|
503
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
504
|
+
try {
|
|
505
|
+
writeFileSync(temp, text, { mode: 0o600, flag: 'wx' });
|
|
506
|
+
renameSync(temp, path);
|
|
507
|
+
chmodSync(path, 0o600);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
rmSync(temp, { force: true });
|
|
510
|
+
throw new SecretScratchError(`Could not write ${clip(path)}: ${errorCode(error)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** A file the agent named: written in place (a symlinked env file stays a link); a new one is 0600. */
|
|
515
|
+
function writeTarget(path: string, text: string): void {
|
|
516
|
+
try {
|
|
517
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
518
|
+
} catch (error) {
|
|
519
|
+
throw new SecretScratchError(`Could not write ${clip(path)}: ${errorCode(error)}`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** A file's text; a missing file is empty, any other failure stops the operation. */
|
|
524
|
+
function readText(path: string): string {
|
|
525
|
+
try {
|
|
526
|
+
return readFileSync(path, 'utf8');
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (errorCode(error) === 'ENOENT') return '';
|
|
529
|
+
throw new SecretScratchError(`Could not read ${clip(path)}: ${errorCode(error)}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function errorCode(error: unknown): string {
|
|
534
|
+
return (error as NodeJS.ErrnoException | undefined)?.code ?? 'error';
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** A dotenv value: bare when plain, otherwise double-quoted with escapes. */
|
|
538
|
+
function envValue(value: string): string {
|
|
539
|
+
if (/^[A-Za-z0-9_./:@+,=-]*$/.test(value)) return value;
|
|
540
|
+
// Single quotes are literal in dotenv loaders and shells: no `$` expansion.
|
|
541
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
542
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function envLineKey(line: string): string | undefined {
|
|
546
|
+
return /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function envLineValue(line: string): string {
|
|
550
|
+
const raw = line.slice(line.indexOf('=') + 1).trim();
|
|
551
|
+
const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
|
|
552
|
+
if (double)
|
|
553
|
+
return (double[1] ?? '').replace(/\\(.)/g, (_m, c: string) =>
|
|
554
|
+
c === 'n' ? '\n' : c === 'r' ? '\r' : c,
|
|
555
|
+
);
|
|
556
|
+
const single = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
|
|
557
|
+
if (single) return single[1] ?? '';
|
|
558
|
+
if (raw.startsWith('"') || raw.startsWith("'"))
|
|
559
|
+
throw new SecretScratchError(
|
|
560
|
+
'The env value spans lines or is unterminated; it cannot be piped.',
|
|
561
|
+
);
|
|
562
|
+
return raw.replace(/\s+#.*$/, '');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function runWithStdin(
|
|
566
|
+
command: string,
|
|
567
|
+
value: string,
|
|
568
|
+
options: { cwd?: string; timeoutMs?: number },
|
|
569
|
+
): Promise<{ exitCode: number }> {
|
|
570
|
+
return new Promise((done, fail) => {
|
|
571
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
572
|
+
cwd: options.cwd,
|
|
573
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
574
|
+
detached: true,
|
|
575
|
+
});
|
|
576
|
+
// The whole group dies on a timeout, so nothing the command started keeps the value.
|
|
577
|
+
const timer = setTimeout(() => {
|
|
578
|
+
try {
|
|
579
|
+
if (child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
580
|
+
} catch {
|
|
581
|
+
child.kill('SIGKILL');
|
|
582
|
+
}
|
|
583
|
+
}, options.timeoutMs ?? 120_000);
|
|
584
|
+
child.on('error', () => {
|
|
585
|
+
clearTimeout(timer);
|
|
586
|
+
fail(new SecretScratchError('The command could not start.'));
|
|
587
|
+
});
|
|
588
|
+
child.on('close', (code, signal) => {
|
|
589
|
+
clearTimeout(timer);
|
|
590
|
+
done({ exitCode: code ?? (signal ? 128 : 1) });
|
|
591
|
+
});
|
|
592
|
+
child.stdin.on('error', () => undefined);
|
|
593
|
+
child.stdin.end(value);
|
|
594
|
+
});
|
|
595
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -73,7 +73,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
73
73
|
fork: 'native',
|
|
74
74
|
rollback: 'none',
|
|
75
75
|
interrupt: 'acknowledged',
|
|
76
|
-
push: '
|
|
76
|
+
push: 'steer',
|
|
77
77
|
approvals: 'callback',
|
|
78
78
|
usage: true,
|
|
79
79
|
limits: { stream: true, poll: true },
|
|
@@ -87,6 +87,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
87
87
|
'textOnly launches with no tools, MCP servers, slash commands or user settings.',
|
|
88
88
|
'Primed decisions (ClaudePrimedSessions) fork a persisted primed session per cycle with a pre-spawned spare.',
|
|
89
89
|
"interruptNative() and readLimits() use the CLI's stdio control protocol (`interrupt`, `get_usage`).",
|
|
90
|
+
'push() steers: a stream-json user message with `priority: "next"` and a `uuid`, tracked by its `command_lifecycle` frames; one the turn had not taken in when its result came runs as a follow-on result of the same turn, and a stop withdraws it (`cancel_async_message`). Live-verified on claude 2.1.316.',
|
|
90
91
|
'toolGate: true launches with `--permission-prompt-tool stdio` and registers a PreToolUse callback over the control protocol: onToolRequest() gates see every call (`pre-use`, any mode, bypass included; only a deny counts) and the asks the permission mode raises (`permission`, final).',
|
|
91
92
|
'Rollback (`--resume-session-at`) exists natively but is not wired here.',
|
|
92
93
|
'Images ride in the same stream-json user message as base64 `image` content blocks, before the text.',
|
|
@@ -147,7 +148,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
147
148
|
"Work Muse starts itself (cron firings, monitor and background-terminal wakes, goals, reminder agents, workflows, another client's messages) is folded like a turn's and emitted with `unattributedReason: 'task'`, plus a `task` lifecycle typed `muse-cron`, `muse-monitor`, `muse-goal`, `muse-reminder`, `muse-workflow` or `muse-turn`; it is seen only while this host has the session loaded.",
|
|
148
149
|
"interruptNative() sends turn/interrupt; push() sends turn/steer; fork() runs session/fork on the new session's own host; readLimits() sends usage/read.",
|
|
149
150
|
'userInput/request questions go to onUserInput(); without an answer they are cancelled with a reason, so a turn never waits on them.',
|
|
150
|
-
'Live-verified (muse 1.4.14): start, profile read-back, multi-stage shell and network approvals, text and tool events, token usage, usage/changed and usage/read (recorded in fixtures/muse); fork, resume with setApprovalMode and sessionNotFound were checked live without a model turn (not recorded).
|
|
151
|
+
'Live-verified (muse 1.4.14): start, profile read-back, multi-stage shell and network approvals, text and tool events, token usage, usage/changed and usage/read (recorded in fixtures/muse); fork, resume with setApprovalMode and sessionNotFound were checked live without a model turn (not recorded). A steer (`turn/steer` into a running shell turn, answered in that turn) was checked live 2026-11-02 (not recorded). Images, interrupt, questions and Muse-initiated turns follow the SDK wire types without a live run.',
|
|
151
152
|
],
|
|
152
153
|
},
|
|
153
154
|
'claude-agent-sdk': {
|
|
@@ -172,6 +173,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
172
173
|
notes: [
|
|
173
174
|
'Drives `@anthropic-ai/claude-agent-sdk` query() supplied by the caller; this package keeps zero dependencies.',
|
|
174
175
|
"Reuses ClaudeCodeSession's event classification and admission evidence over the SDK message stream.",
|
|
176
|
+
'push() is not wired: whether the SDK stream surfaces `command_lifecycle` (which a steer is tracked by) is unverified.',
|
|
175
177
|
],
|
|
176
178
|
},
|
|
177
179
|
acp: {
|