@inixiative/agent-session 0.8.0 → 0.9.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/muse-session.ts +15 -10
- 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.9.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.176",
|
|
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/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
|
}
|
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: {
|