specrails-core 6.4.0 → 6.5.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.
Files changed (90) hide show
  1. package/dist/agent-runtime/capabilities.js +22 -5
  2. package/dist/agent-runtime/cli-executor.js +8 -3
  3. package/dist/agent-runtime/cli.d.ts +1 -1
  4. package/dist/agent-runtime/cli.js +50 -3
  5. package/dist/agent-runtime/compact/environment.d.ts +30 -2
  6. package/dist/agent-runtime/compact/environment.js +120 -2
  7. package/dist/agent-runtime/engine/pieces/role-turn.d.ts +5 -0
  8. package/dist/agent-runtime/engine/pieces/role-turn.js +54 -21
  9. package/dist/agent-runtime/engine/pieces/verify.js +3 -5
  10. package/dist/agent-runtime/session/application/active-session.d.ts +176 -0
  11. package/dist/agent-runtime/session/application/active-session.js +701 -0
  12. package/dist/agent-runtime/session/application/delegation.d.ts +73 -0
  13. package/dist/agent-runtime/session/application/delegation.js +171 -0
  14. package/dist/agent-runtime/session/application/output-buffer.d.ts +43 -0
  15. package/dist/agent-runtime/session/application/output-buffer.js +65 -0
  16. package/dist/agent-runtime/session/application/session-service.d.ts +121 -0
  17. package/dist/agent-runtime/session/application/session-service.js +256 -0
  18. package/dist/agent-runtime/session/application/timers.d.ts +15 -0
  19. package/dist/agent-runtime/session/application/timers.js +35 -0
  20. package/dist/agent-runtime/session/domain/descriptor.d.ts +6 -0
  21. package/dist/agent-runtime/session/domain/descriptor.js +32 -0
  22. package/dist/agent-runtime/session/domain/errors.d.ts +9 -0
  23. package/dist/agent-runtime/session/domain/errors.js +16 -0
  24. package/dist/agent-runtime/session/domain/events.d.ts +147 -0
  25. package/dist/agent-runtime/session/domain/events.js +24 -0
  26. package/dist/agent-runtime/session/domain/fingerprint.d.ts +2 -0
  27. package/dist/agent-runtime/session/domain/fingerprint.js +10 -0
  28. package/dist/agent-runtime/session/domain/interruption.d.ts +13 -0
  29. package/dist/agent-runtime/session/domain/interruption.js +39 -0
  30. package/dist/agent-runtime/session/domain/policy.d.ts +42 -0
  31. package/dist/agent-runtime/session/domain/policy.js +104 -0
  32. package/dist/agent-runtime/session/domain/snapshot.d.ts +100 -0
  33. package/dist/agent-runtime/session/domain/snapshot.js +234 -0
  34. package/dist/agent-runtime/session/domain/state-machines.d.ts +21 -0
  35. package/dist/agent-runtime/session/domain/state-machines.js +41 -0
  36. package/dist/agent-runtime/session/domain/types.d.ts +130 -0
  37. package/dist/agent-runtime/session/domain/types.js +15 -0
  38. package/dist/agent-runtime/session/domain/usage.d.ts +54 -0
  39. package/dist/agent-runtime/session/domain/usage.js +86 -0
  40. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +19 -0
  41. package/dist/agent-runtime/session/drivers/claude/argv.js +67 -0
  42. package/dist/agent-runtime/session/drivers/claude/driver.d.ts +19 -0
  43. package/dist/agent-runtime/session/drivers/claude/driver.js +134 -0
  44. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +40 -0
  45. package/dist/agent-runtime/session/drivers/claude/translator.js +247 -0
  46. package/dist/agent-runtime/session/drivers/codex/argv.d.ts +12 -0
  47. package/dist/agent-runtime/session/drivers/codex/argv.js +76 -0
  48. package/dist/agent-runtime/session/drivers/codex/driver.d.ts +19 -0
  49. package/dist/agent-runtime/session/drivers/codex/driver.js +193 -0
  50. package/dist/agent-runtime/session/drivers/codex/rpc.d.ts +28 -0
  51. package/dist/agent-runtime/session/drivers/codex/rpc.js +81 -0
  52. package/dist/agent-runtime/session/drivers/codex/translator.d.ts +53 -0
  53. package/dist/agent-runtime/session/drivers/codex/translator.js +267 -0
  54. package/dist/agent-runtime/session/drivers/common/process.d.ts +37 -0
  55. package/dist/agent-runtime/session/drivers/common/process.js +116 -0
  56. package/dist/agent-runtime/session/drivers/executor/driver.d.ts +22 -0
  57. package/dist/agent-runtime/session/drivers/executor/driver.js +163 -0
  58. package/dist/agent-runtime/session/drivers/registry.d.ts +32 -0
  59. package/dist/agent-runtime/session/drivers/registry.js +38 -0
  60. package/dist/agent-runtime/session/host/protocol.d.ts +18 -0
  61. package/dist/agent-runtime/session/host/protocol.js +88 -0
  62. package/dist/agent-runtime/session/host/server.d.ts +57 -0
  63. package/dist/agent-runtime/session/host/server.js +229 -0
  64. package/dist/agent-runtime/session/index.d.ts +45 -0
  65. package/dist/agent-runtime/session/index.js +74 -0
  66. package/dist/agent-runtime/session/journal/schema.d.ts +7 -0
  67. package/dist/agent-runtime/session/journal/schema.js +44 -0
  68. package/dist/agent-runtime/session/journal/sqlite-journal.d.ts +70 -0
  69. package/dist/agent-runtime/session/journal/sqlite-journal.js +279 -0
  70. package/dist/agent-runtime/session/ports.d.ts +192 -0
  71. package/dist/agent-runtime/session/ports.js +1 -0
  72. package/dist/agent-runtime/session/testing/driver-conformance.d.ts +21 -0
  73. package/dist/agent-runtime/session/testing/driver-conformance.js +97 -0
  74. package/dist/agent-runtime/session/testing/fake-clock.d.ts +20 -0
  75. package/dist/agent-runtime/session/testing/fake-clock.js +34 -0
  76. package/dist/agent-runtime/session/testing/fixture-replayer.d.ts +41 -0
  77. package/dist/agent-runtime/session/testing/fixture-replayer.js +172 -0
  78. package/dist/agent-runtime/session/testing/index.d.ts +9 -0
  79. package/dist/agent-runtime/session/testing/index.js +9 -0
  80. package/dist/agent-runtime/session/testing/memory-journal.d.ts +27 -0
  81. package/dist/agent-runtime/session/testing/memory-journal.js +70 -0
  82. package/dist/agent-runtime/session/testing/scripted-driver.d.ts +30 -0
  83. package/dist/agent-runtime/session/testing/scripted-driver.js +77 -0
  84. package/dist/agent-runtime/verification-proposals.d.ts +9 -0
  85. package/dist/agent-runtime/verification-proposals.js +69 -0
  86. package/dist/installer/util/registry.js +3 -3
  87. package/dist/shared/specrails-home.d.ts +21 -0
  88. package/dist/shared/specrails-home.js +36 -0
  89. package/integration-contract.json +42 -1
  90. package/package.json +11 -1
@@ -0,0 +1,104 @@
1
+ import { SessionError } from './errors.js';
2
+ /** Defaults chosen from the provider spike and Desktop's previous mission limits. */
3
+ export const DEFAULT_LIMITS = Object.freeze({
4
+ idleMs: 10 * 60_000,
5
+ stallMs: 15 * 60_000,
6
+ backgroundMaxMs: 2 * 60 * 60_000,
7
+ turnInactivityMs: 5 * 60_000,
8
+ maxSettleHandoffs: 3,
9
+ settleDebounceMs: 1_500,
10
+ });
11
+ const LIMIT_BOUNDS = Object.freeze({
12
+ idleMs: [1_000, 24 * 60 * 60_000],
13
+ stallMs: [1_000, 24 * 60 * 60_000],
14
+ backgroundMaxMs: [1_000, 7 * 24 * 60 * 60_000],
15
+ turnInactivityMs: [1_000, 24 * 60 * 60_000],
16
+ maxSettleHandoffs: [0, 20],
17
+ settleDebounceMs: [0, 60_000],
18
+ });
19
+ export const DEFAULT_MAX_DELEGATED = 4;
20
+ const MAX_DELEGATED_BOUNDS = [1, 16];
21
+ /** Validate the sub-agent runtime against what the parent driver can enforce. */
22
+ function resolveRuntime(input, driver) {
23
+ const runtime = input.subagentRuntime ?? { mode: 'native' };
24
+ const caps = driver.capabilities;
25
+ if (runtime.mode === 'native') {
26
+ if (runtime.model && !caps.subagentModel)
27
+ throw unenforceable(`Driver "${driver.id}" cannot choose a model for its own sub-agents`, { field: 'subagentRuntime.model', driver: driver.id });
28
+ if (runtime.effort && !caps.subagentEffort)
29
+ throw unenforceable(`Driver "${driver.id}" cannot choose an effort for its own sub-agents`, { field: 'subagentRuntime.effort', driver: driver.id });
30
+ return { mode: 'native', ...(runtime.model ? { model: runtime.model } : {}), ...(runtime.effort ? { effort: runtime.effort } : {}) };
31
+ }
32
+ // Delegated: the parent's own tool must be switchable off, or the setting would not hold.
33
+ if (caps.subagents === 'supported' && !caps.subagentDisable) {
34
+ throw unenforceable(`Driver "${driver.id}" cannot disable its own sub-agents, so Core cannot launch them instead`, { field: 'subagentRuntime.mode', driver: driver.id });
35
+ }
36
+ const maxConcurrent = runtime.maxConcurrent ?? DEFAULT_MAX_DELEGATED;
37
+ if (!Number.isInteger(maxConcurrent) || maxConcurrent < MAX_DELEGATED_BOUNDS[0] || maxConcurrent > MAX_DELEGATED_BOUNDS[1]) {
38
+ throw new SessionError('invalid_params', `policy.subagentRuntime.maxConcurrent must be an integer in [${MAX_DELEGATED_BOUNDS[0]}, ${MAX_DELEGATED_BOUNDS[1]}]`, { path: 'policy.subagentRuntime.maxConcurrent' });
39
+ }
40
+ if (!runtime.model)
41
+ throw new SessionError('invalid_params', 'Delegated sub-agents need a model', { path: 'policy.subagentRuntime.model' });
42
+ return { mode: 'delegated', driver: runtime.driver, model: runtime.model, ...(runtime.effort ? { effort: runtime.effort } : {}), maxConcurrent };
43
+ }
44
+ function unenforceable(message, detail) {
45
+ return new SessionError('policy_unenforceable', message, detail);
46
+ }
47
+ function resolveLimits(input) {
48
+ const limits = { ...DEFAULT_LIMITS };
49
+ for (const [key, value] of Object.entries(input ?? {})) {
50
+ if (value === undefined)
51
+ continue;
52
+ const bounds = LIMIT_BOUNDS[key];
53
+ if (!bounds)
54
+ throw new SessionError('invalid_params', `Unknown limit "${key}"`, { path: `policy.limits.${key}` });
55
+ if (!Number.isInteger(value) || value < bounds[0] || value > bounds[1]) {
56
+ throw new SessionError('invalid_params', `policy.limits.${key} must be an integer in [${bounds[0]}, ${bounds[1]}]`, { path: `policy.limits.${key}` });
57
+ }
58
+ limits[key] = value;
59
+ }
60
+ return limits;
61
+ }
62
+ /**
63
+ * Resolve a host policy against a driver's declared capabilities. The result is
64
+ * fully specified; anything the driver cannot enforce is refused, never ignored.
65
+ */
66
+ export function resolvePolicy(input, driver) {
67
+ const caps = driver.capabilities;
68
+ const supportsSubagents = caps.subagents === 'supported';
69
+ if (input.subagents === 'disabled' && supportsSubagents && !caps.subagentDisable) {
70
+ throw unenforceable(`Driver "${driver.id}" cannot disable sub-agents`, { field: 'subagents', driver: driver.id });
71
+ }
72
+ const onSubagentsSettled = input.onSubagentsSettled ?? (caps.autonomousContinuation ? 'provider-native' : 'resume-agent');
73
+ if (onSubagentsSettled === 'provider-native' && supportsSubagents && input.subagents === 'enabled' && !caps.autonomousContinuation) {
74
+ throw unenforceable(`Driver "${driver.id}" does not continue on its own after sub-agents finish`, { field: 'onSubagentsSettled', driver: driver.id });
75
+ }
76
+ const tools = input.tools ?? { mode: 'default' };
77
+ if (!caps.toolFiltering && (tools.mode !== 'default' || tools.allow?.length || tools.deny?.length)) {
78
+ throw unenforceable(`Driver "${driver.id}" cannot restrict its tool set`, { field: 'tools', driver: driver.id });
79
+ }
80
+ if (tools.mode === 'none' && caps.resident && caps.subagents === 'supported' && input.subagents === 'enabled') {
81
+ throw unenforceable('A session without tools cannot launch sub-agents; disable sub-agents or allow tools', { field: 'tools.mode' });
82
+ }
83
+ return {
84
+ subagents: input.subagents,
85
+ subagentRuntime: resolveRuntime(input, driver),
86
+ onSubagentsSettled,
87
+ tools: { mode: tools.mode, ...(tools.allow ? { allow: [...tools.allow] } : {}), ...(tools.deny ? { deny: [...tools.deny] } : {}) },
88
+ permissions: input.permissions ?? 'workspace-write',
89
+ mcp: { servers: [...(input.mcp?.servers ?? [])], inheritUserScope: input.mcp?.inheritUserScope ?? false },
90
+ limits: resolveLimits(input.limits),
91
+ };
92
+ }
93
+ /** True when the provider's own sub-agents may run (enabled and not delegated to Core). */
94
+ export function nativeSubagentsAllowed(policy) {
95
+ return policy.subagents === 'enabled' && policy.subagentRuntime.mode === 'native';
96
+ }
97
+ /** True when Core launches this session's sub-agents itself. */
98
+ export function delegatedSubagents(policy) {
99
+ return policy.subagents === 'enabled' && policy.subagentRuntime.mode === 'delegated';
100
+ }
101
+ /** True when the effective native sub-agent behaviour of the session is "enabled". */
102
+ export function subagentsActive(policy, driver) {
103
+ return nativeSubagentsAllowed(policy) && driver.capabilities.subagents === 'supported';
104
+ }
@@ -0,0 +1,100 @@
1
+ import type { SessionEvent, SessionEventEnvelope } from './events.js';
2
+ import type { InputDelivery, InputState, SessionPhase, SessionPolicy, SessionStatus, SubagentKind, SubagentPhase, TurnOrigin, TurnStatus, Usage } from './types.js';
3
+ export interface InputRecord {
4
+ inputId: string;
5
+ delivery: InputDelivery;
6
+ state: InputState;
7
+ turnId: string | null;
8
+ textLength: number;
9
+ /** Detects a retried inputId carrying different content. */
10
+ fingerprint: string;
11
+ }
12
+ export interface OpenTurn {
13
+ turnId: string;
14
+ origin: TurnOrigin;
15
+ inputIds: string[];
16
+ startedAt: string;
17
+ text: string;
18
+ }
19
+ export interface TurnRecord {
20
+ turnId: string;
21
+ origin: TurnOrigin;
22
+ status: TurnStatus;
23
+ startedAt: string;
24
+ endedAt: string | null;
25
+ usage: Usage | null;
26
+ }
27
+ export interface SubagentNode {
28
+ subagentId: string;
29
+ parentId: string | null;
30
+ kind: SubagentKind;
31
+ agentType: string | null;
32
+ description: string;
33
+ phase: SubagentPhase;
34
+ reason: string | null;
35
+ /** Times the provider restarted it after an idle phase. */
36
+ restarts: number;
37
+ startedAt: string;
38
+ endedAt: string | null;
39
+ usage: Usage | null;
40
+ toolUses: number | null;
41
+ durationMs: number | null;
42
+ resultSummary: string | null;
43
+ /** Set when Core launched it as a child session (delegated runtime); its spend is billed separately. */
44
+ delegated: {
45
+ driver: string;
46
+ model: string;
47
+ } | null;
48
+ }
49
+ /** Bounded history kept in the snapshot; the journal keeps every event. */
50
+ export declare const SNAPSHOT_TURN_HISTORY = 100;
51
+ export declare const SNAPSHOT_TERMINAL_INPUTS = 500;
52
+ export interface SessionSnapshot {
53
+ sessionId: string;
54
+ lastSeq: number;
55
+ status: SessionStatus;
56
+ driver: string;
57
+ model: string;
58
+ effort: string | null;
59
+ systemPrompt: string | null;
60
+ policy: SessionPolicy | null;
61
+ providerSessionRef: string | null;
62
+ phase: SessionPhase;
63
+ process: {
64
+ generation: number;
65
+ alive: boolean;
66
+ };
67
+ openTurn: OpenTurn | null;
68
+ /** Most recent turns (bounded); `turnCount` counts all of them. */
69
+ turns: TurnRecord[];
70
+ turnCount: number;
71
+ inputs: Record<string, InputRecord>;
72
+ subagents: Record<string, SubagentNode>;
73
+ settled: {
74
+ settled: boolean;
75
+ live: number;
76
+ };
77
+ /** Interrupted work the agent has not been told about yet. */
78
+ pendingInterruptions: {
79
+ subagentIds: string[];
80
+ inputIds: string[];
81
+ };
82
+ closedReason: string | null;
83
+ }
84
+ export declare function emptySnapshot(sessionId: string): SessionSnapshot;
85
+ /**
86
+ * Apply one event. Pure: returns a new snapshot and throws `illegal_transition`
87
+ * for events that would break an invariant. The application validates every
88
+ * event with this function before committing it, so a journal only ever
89
+ * contains foldable events and replay equals the live state.
90
+ */
91
+ export declare function applyEvent(state: SessionSnapshot, event: SessionEvent): SessionSnapshot;
92
+ /** Fold committed envelopes; sequence numbers must be gap-free and increasing. */
93
+ export declare function foldEvents(initial: SessionSnapshot, envelopes: readonly SessionEventEnvelope[]): SessionSnapshot;
94
+ /** Sub-agents currently occupying the provider. */
95
+ export declare function liveSubagents(state: SessionSnapshot): SubagentNode[];
96
+ /**
97
+ * Journals written before `subagentRuntime` existed carry policies without it:
98
+ * those sessions ran native sub-agents, so replay reads them as such.
99
+ */
100
+ export declare function normalizePolicy(policy: SessionPolicy): SessionPolicy;
@@ -0,0 +1,234 @@
1
+ import { SessionError } from './errors.js';
2
+ import { fingerprint } from './fingerprint.js';
3
+ import { INPUT_TRANSITIONS, SESSION_PHASE_TRANSITIONS, SUBAGENT_TRANSITIONS, TURN_TRANSITIONS, assertTransition, isLiveSubagentPhase, } from './state-machines.js';
4
+ /** Bounded history kept in the snapshot; the journal keeps every event. */
5
+ export const SNAPSHOT_TURN_HISTORY = 100;
6
+ export const SNAPSHOT_TERMINAL_INPUTS = 500;
7
+ export function emptySnapshot(sessionId) {
8
+ return {
9
+ sessionId,
10
+ lastSeq: 0,
11
+ status: 'open',
12
+ driver: '',
13
+ model: '',
14
+ effort: null,
15
+ systemPrompt: null,
16
+ policy: null,
17
+ providerSessionRef: null,
18
+ phase: 'idle',
19
+ process: { generation: 0, alive: false },
20
+ openTurn: null,
21
+ turns: [],
22
+ turnCount: 0,
23
+ inputs: {},
24
+ subagents: {},
25
+ settled: { settled: true, live: 0 },
26
+ pendingInterruptions: { subagentIds: [], inputIds: [] },
27
+ closedReason: null,
28
+ };
29
+ }
30
+ /** Sub-agent end states that the agent must be told about before it continues. */
31
+ const NOTIFIABLE = new Set(['interrupted', 'stopped', 'killed']);
32
+ function addUnique(list, id) {
33
+ return list.includes(id) ? list : [...list, id];
34
+ }
35
+ function requireSubagent(state, subagentId) {
36
+ const node = state.subagents[subagentId];
37
+ if (!node)
38
+ throw new SessionError('illegal_transition', `Unknown sub-agent ${subagentId}`, { subagentId });
39
+ return node;
40
+ }
41
+ function requireOpenTurn(state, turnId) {
42
+ if (state.openTurn?.turnId !== turnId)
43
+ throw new SessionError('illegal_transition', `Turn ${turnId} is not running`, { turnId });
44
+ return state.openTurn;
45
+ }
46
+ /**
47
+ * Apply one event. Pure: returns a new snapshot and throws `illegal_transition`
48
+ * for events that would break an invariant. The application validates every
49
+ * event with this function before committing it, so a journal only ever
50
+ * contains foldable events and replay equals the live state.
51
+ */
52
+ export function applyEvent(state, event) {
53
+ if (state.status === 'closed' && event.type !== 'provider.diagnostic') {
54
+ throw new SessionError('session_closed', `Session ${state.sessionId} is closed`);
55
+ }
56
+ switch (event.type) {
57
+ case 'session.opened':
58
+ return {
59
+ ...state,
60
+ driver: event.driver,
61
+ model: event.model,
62
+ effort: event.effort ?? null,
63
+ systemPrompt: event.systemPrompt ?? null,
64
+ policy: normalizePolicy(event.policy),
65
+ providerSessionRef: event.providerSessionRef ?? state.providerSessionRef,
66
+ };
67
+ case 'session.phase':
68
+ assertTransition('session phase', SESSION_PHASE_TRANSITIONS, state.phase, event.phase);
69
+ return { ...state, phase: event.phase };
70
+ case 'session.process':
71
+ return { ...state, process: { generation: event.generation, alive: event.state === 'started' } };
72
+ case 'session.provider-ref':
73
+ return { ...state, providerSessionRef: event.providerSessionRef };
74
+ case 'session.updated': {
75
+ if (event.outcome === 'deferred')
76
+ return state;
77
+ const changes = event.changes;
78
+ return {
79
+ ...state,
80
+ model: changes.model ?? state.model,
81
+ effort: changes.effort ?? state.effort,
82
+ systemPrompt: changes.systemPrompt ?? state.systemPrompt,
83
+ policy: changes.policy ? normalizePolicy(changes.policy) : state.policy,
84
+ };
85
+ }
86
+ case 'session.closed':
87
+ return { ...state, status: 'closed', closedReason: event.reason, process: { ...state.process, alive: false } };
88
+ case 'input.accepted':
89
+ if (state.inputs[event.inputId])
90
+ throw new SessionError('input_conflict', `Input ${event.inputId} already exists`, { inputId: event.inputId });
91
+ return {
92
+ ...state,
93
+ inputs: { ...state.inputs, [event.inputId]: { inputId: event.inputId, delivery: event.delivery, state: 'accepted', turnId: null, textLength: event.text.length, fingerprint: fingerprint(event.text) } },
94
+ };
95
+ case 'input.state': {
96
+ const input = state.inputs[event.inputId];
97
+ if (!input)
98
+ throw new SessionError('illegal_transition', `Unknown input ${event.inputId}`, { inputId: event.inputId });
99
+ assertTransition('input', INPUT_TRANSITIONS, input.state, event.state);
100
+ const pending = event.state === 'interrupted'
101
+ ? { ...state.pendingInterruptions, inputIds: addUnique(state.pendingInterruptions.inputIds, event.inputId) }
102
+ : state.pendingInterruptions;
103
+ return {
104
+ ...state,
105
+ inputs: pruneInputs({ ...state.inputs, [event.inputId]: { ...input, state: event.state, turnId: event.turnId ?? input.turnId } }),
106
+ pendingInterruptions: pending,
107
+ };
108
+ }
109
+ case 'turn.started':
110
+ if (state.openTurn)
111
+ throw new SessionError('illegal_transition', `Turn ${state.openTurn.turnId} is still running`, { turnId: event.turnId });
112
+ return {
113
+ ...state,
114
+ openTurn: { turnId: event.turnId, origin: event.origin, inputIds: [...event.inputIds], startedAt: event.at, text: '' },
115
+ turns: [...state.turns, { turnId: event.turnId, origin: event.origin, status: 'running', startedAt: event.at, endedAt: null, usage: null }].slice(-SNAPSHOT_TURN_HISTORY),
116
+ turnCount: state.turnCount + 1,
117
+ };
118
+ case 'turn.output': {
119
+ const turn = requireOpenTurn(state, event.turnId);
120
+ return event.channel === 'text' ? { ...state, openTurn: { ...turn, text: turn.text + event.delta } } : state;
121
+ }
122
+ case 'turn.tool':
123
+ requireOpenTurn(state, event.turnId);
124
+ return state;
125
+ case 'turn.completed': {
126
+ requireOpenTurn(state, event.turnId);
127
+ const turns = state.turns.map((turn) => {
128
+ if (turn.turnId !== event.turnId)
129
+ return turn;
130
+ assertTransition('turn', TURN_TRANSITIONS, turn.status, event.status);
131
+ return { ...turn, status: event.status, endedAt: event.at, usage: event.usage };
132
+ });
133
+ return { ...state, openTurn: null, turns };
134
+ }
135
+ case 'subagent.started': {
136
+ const existing = state.subagents[event.subagentId];
137
+ if (existing) {
138
+ // Providers restart a sub-agent under the same id after an idle phase.
139
+ assertTransition('sub-agent', SUBAGENT_TRANSITIONS, existing.phase, 'running');
140
+ const restarted = existing.phase === 'idle';
141
+ return withSubagent(state, { ...existing, phase: 'running', reason: null, endedAt: null, restarts: existing.restarts + (restarted ? 1 : 0) });
142
+ }
143
+ if (event.parentId !== null && !state.subagents[event.parentId]) {
144
+ throw new SessionError('illegal_transition', `Unknown parent sub-agent ${event.parentId}`, { subagentId: event.subagentId });
145
+ }
146
+ return withSubagent(state, {
147
+ subagentId: event.subagentId,
148
+ parentId: event.parentId,
149
+ kind: event.kind,
150
+ agentType: event.agentType ?? null,
151
+ delegated: event.delegated ?? null,
152
+ description: event.description,
153
+ phase: 'running',
154
+ reason: null,
155
+ restarts: 0,
156
+ startedAt: event.at,
157
+ endedAt: null,
158
+ usage: null,
159
+ toolUses: null,
160
+ durationMs: null,
161
+ resultSummary: null,
162
+ });
163
+ }
164
+ case 'subagent.phase': {
165
+ const node = requireSubagent(state, event.subagentId);
166
+ assertTransition('sub-agent', SUBAGENT_TRANSITIONS, node.phase, event.phase);
167
+ const live = isLiveSubagentPhase(event.phase);
168
+ // A refinement (stopped → killed) keeps the original reason unless a new one is given.
169
+ const next = withSubagent(state, { ...node, phase: event.phase, reason: event.reason ?? (live ? null : node.reason), endedAt: live ? null : event.at });
170
+ return NOTIFIABLE.has(event.phase)
171
+ ? { ...next, pendingInterruptions: { ...next.pendingInterruptions, subagentIds: addUnique(next.pendingInterruptions.subagentIds, event.subagentId) } }
172
+ : next;
173
+ }
174
+ case 'subagent.output':
175
+ requireSubagent(state, event.subagentId);
176
+ return state;
177
+ case 'subagent.usage': {
178
+ const node = requireSubagent(state, event.subagentId);
179
+ return withSubagent(state, { ...node, usage: event.usage, toolUses: event.toolUses ?? node.toolUses, durationMs: event.durationMs ?? node.durationMs });
180
+ }
181
+ case 'subagent.result': {
182
+ const node = requireSubagent(state, event.subagentId);
183
+ return withSubagent(state, { ...node, resultSummary: event.summary });
184
+ }
185
+ case 'subagents.settled':
186
+ return { ...state, settled: { settled: event.settled, live: event.live } };
187
+ case 'notice.interruption':
188
+ return {
189
+ ...state,
190
+ pendingInterruptions: {
191
+ subagentIds: state.pendingInterruptions.subagentIds.filter((id) => !event.subagentIds.includes(id)),
192
+ inputIds: state.pendingInterruptions.inputIds.filter((id) => !event.inputIds.includes(id)),
193
+ },
194
+ };
195
+ case 'output.truncated':
196
+ case 'provider.diagnostic':
197
+ return state;
198
+ }
199
+ }
200
+ const TERMINAL_INPUT = new Set(['completed', 'rejected', 'interrupted']);
201
+ /** Keep every live input and only the most recent terminal ones (insertion order). */
202
+ function pruneInputs(inputs) {
203
+ const terminal = Object.values(inputs).filter((input) => TERMINAL_INPUT.has(input.state));
204
+ if (terminal.length <= SNAPSHOT_TERMINAL_INPUTS)
205
+ return inputs;
206
+ const drop = new Set(terminal.slice(0, terminal.length - SNAPSHOT_TERMINAL_INPUTS).map((input) => input.inputId));
207
+ return Object.fromEntries(Object.entries(inputs).filter(([id]) => !drop.has(id)));
208
+ }
209
+ function withSubagent(state, node) {
210
+ return { ...state, subagents: { ...state.subagents, [node.subagentId]: node } };
211
+ }
212
+ /** Fold committed envelopes; sequence numbers must be gap-free and increasing. */
213
+ export function foldEvents(initial, envelopes) {
214
+ let state = initial;
215
+ for (const envelope of envelopes) {
216
+ if (envelope.sessionId !== state.sessionId)
217
+ throw new SessionError('internal', `Event for ${envelope.sessionId} folded into ${state.sessionId}`);
218
+ if (envelope.seq !== state.lastSeq + 1)
219
+ throw new SessionError('internal', `Sequence gap: expected ${state.lastSeq + 1}, got ${envelope.seq}`);
220
+ state = { ...applyEvent(state, envelope.event), lastSeq: envelope.seq };
221
+ }
222
+ return state;
223
+ }
224
+ /** Sub-agents currently occupying the provider. */
225
+ export function liveSubagents(state) {
226
+ return Object.values(state.subagents).filter((node) => isLiveSubagentPhase(node.phase));
227
+ }
228
+ /**
229
+ * Journals written before `subagentRuntime` existed carry policies without it:
230
+ * those sessions ran native sub-agents, so replay reads them as such.
231
+ */
232
+ export function normalizePolicy(policy) {
233
+ return policy.subagentRuntime ? policy : { ...policy, subagentRuntime: { mode: 'native' } };
234
+ }
@@ -0,0 +1,21 @@
1
+ import type { InputState, SessionPhase, SubagentPhase, TurnStatus } from './types.js';
2
+ /**
3
+ * Transition tables. A state absent from a table's keys is terminal.
4
+ * Re-applying the current state is always legal (providers repeat notifications).
5
+ */
6
+ type Table<S extends string> = Readonly<Partial<Record<S, readonly S[]>>>;
7
+ export declare const SESSION_PHASE_TRANSITIONS: Table<SessionPhase>;
8
+ export declare const TURN_TRANSITIONS: Table<TurnStatus>;
9
+ export declare const INPUT_TRANSITIONS: Table<InputState>;
10
+ /**
11
+ * Sub-agents are re-entrant: providers report a sub-agent `idle` (its own turn
12
+ * ended) and later restart it (e.g. when a shell it backgrounded finishes).
13
+ * `stopped → killed` refines a stop into a forced kill.
14
+ */
15
+ export declare const SUBAGENT_TRANSITIONS: Table<SubagentPhase>;
16
+ export declare function canTransition<S extends string>(table: Table<S>, from: S, to: S): boolean;
17
+ export declare function assertTransition<S extends string>(machine: string, table: Table<S>, from: S, to: S): void;
18
+ export declare function isTerminal<S extends string>(table: Table<S>, state: S): boolean;
19
+ /** Phases in which a sub-agent still occupies the provider. */
20
+ export declare function isLiveSubagentPhase(phase: SubagentPhase): boolean;
21
+ export {};
@@ -0,0 +1,41 @@
1
+ import { SessionError } from './errors.js';
2
+ export const SESSION_PHASE_TRANSITIONS = Object.freeze({
3
+ // idle → background: a provider restarts a finished sub-agent after the session went idle.
4
+ idle: ['turn', 'background'],
5
+ turn: ['idle', 'background'],
6
+ // A user or continuation turn can start while sub-agents run; work can settle while idle.
7
+ background: ['turn', 'idle'],
8
+ });
9
+ export const TURN_TRANSITIONS = Object.freeze({
10
+ running: ['completed', 'failed', 'stopped', 'interrupted'],
11
+ });
12
+ export const INPUT_TRANSITIONS = Object.freeze({
13
+ accepted: ['queued', 'started', 'rejected', 'interrupted'],
14
+ queued: ['started', 'rejected', 'interrupted'],
15
+ started: ['completed', 'interrupted'],
16
+ });
17
+ /**
18
+ * Sub-agents are re-entrant: providers report a sub-agent `idle` (its own turn
19
+ * ended) and later restart it (e.g. when a shell it backgrounded finishes).
20
+ * `stopped → killed` refines a stop into a forced kill.
21
+ */
22
+ export const SUBAGENT_TRANSITIONS = Object.freeze({
23
+ running: ['idle', 'failed', 'stopped', 'killed', 'interrupted'],
24
+ idle: ['running', 'failed', 'stopped', 'killed', 'interrupted'],
25
+ stopped: ['killed'],
26
+ });
27
+ export function canTransition(table, from, to) {
28
+ return from === to || (table[from]?.includes(to) ?? false);
29
+ }
30
+ export function assertTransition(machine, table, from, to) {
31
+ if (!canTransition(table, from, to)) {
32
+ throw new SessionError('illegal_transition', `${machine}: ${from} → ${to} is not allowed`, { machine, from, to });
33
+ }
34
+ }
35
+ export function isTerminal(table, state) {
36
+ return table[state] === undefined;
37
+ }
38
+ /** Phases in which a sub-agent still occupies the provider. */
39
+ export function isLiveSubagentPhase(phase) {
40
+ return phase === 'running';
41
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Provider-neutral session vocabulary. Every type here is plain data so it can
3
+ * cross the protocol boundary, be journaled and be folded by pure reducers.
4
+ * Provider wire formats never appear in this module (see drivers/).
5
+ */
6
+ export type SessionPhase = 'idle' | 'turn' | 'background';
7
+ export type SessionStatus = 'open' | 'closed';
8
+ export type TurnOrigin = 'user' | 'subagent' | 'system';
9
+ export type TurnStatus = 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
10
+ export type InputDelivery = 'queue' | 'steer';
11
+ export type InputState = 'accepted' | 'queued' | 'started' | 'completed' | 'rejected' | 'interrupted';
12
+ export type SubagentKind = 'foreground' | 'background';
13
+ export type SubagentPhase = 'running' | 'idle' | 'failed' | 'stopped' | 'killed' | 'interrupted';
14
+ export type OutputChannel = 'text' | 'thinking';
15
+ /** Usage for one turn or one sub-agent. `null` means "not reported", never zero. */
16
+ export interface Usage {
17
+ inputTokens: number | null;
18
+ outputTokens: number | null;
19
+ cacheReadTokens: number | null;
20
+ cacheWriteTokens: number | null;
21
+ /** Provider total when it reports no breakdown (e.g. Claude sub-agent progress). */
22
+ totalTokens: number | null;
23
+ /** Per-turn delta in USD. Sub-agent usage is a breakdown and is never added on top. */
24
+ costUsd: number | null;
25
+ /** True when the cost was derived from a rate card instead of billed by the provider. */
26
+ costEstimated: boolean;
27
+ model: string | null;
28
+ }
29
+ export declare const EMPTY_USAGE: Usage;
30
+ export interface McpServerSpec {
31
+ name: string;
32
+ /** stdio server */
33
+ command?: string;
34
+ args?: string[];
35
+ env?: Record<string, string>;
36
+ /** http server */
37
+ url?: string;
38
+ headers?: Record<string, string>;
39
+ /**
40
+ * The host authorizes every call of this server itself (e.g. a capability-bound
41
+ * bridge), so the provider must not ask for per-call approval. Without it,
42
+ * providers that gate MCP tools refuse them under a non-interactive policy.
43
+ */
44
+ autoApprove?: boolean;
45
+ }
46
+ export interface SessionLimits {
47
+ /** Retire a resident process after this long in `idle`. */
48
+ idleMs: number;
49
+ /** In `background`, retire when no provider frame arrives for this long. */
50
+ stallMs: number;
51
+ /** In `background`, retire this long after the last user turn. */
52
+ backgroundMaxMs: number;
53
+ /** Fail a running turn when no provider frame (including sub-agent frames) arrives for this long. */
54
+ turnInactivityMs: number;
55
+ /** Maximum policy-driven `resume-agent` turns per settle cycle. */
56
+ maxSettleHandoffs: number;
57
+ /** Debounce before an empty sub-agent roster counts as settled. */
58
+ settleDebounceMs: number;
59
+ }
60
+ /**
61
+ * Who launches sub-agents. `native`: the provider's own tool, optionally with a
62
+ * model/effort override. `delegated`: Core launches each one as a child session
63
+ * on `driver` (the provider's own tool is then disabled).
64
+ */
65
+ export type SubagentRuntime = {
66
+ mode: 'native';
67
+ model?: string;
68
+ effort?: string;
69
+ } | {
70
+ mode: 'delegated';
71
+ driver: string;
72
+ model: string;
73
+ effort?: string;
74
+ maxConcurrent: number;
75
+ };
76
+ export interface SessionPolicy {
77
+ subagents: 'enabled' | 'disabled';
78
+ subagentRuntime: SubagentRuntime;
79
+ onSubagentsSettled: 'provider-native' | 'resume-agent' | 'notify-only';
80
+ tools: {
81
+ mode: 'default' | 'read-only' | 'none';
82
+ allow?: string[];
83
+ deny?: string[];
84
+ };
85
+ permissions: 'bypass' | 'workspace-write' | 'read-only';
86
+ mcp: {
87
+ servers: McpServerSpec[];
88
+ inheritUserScope: boolean;
89
+ };
90
+ limits: SessionLimits;
91
+ }
92
+ /** Usage semantics a driver declares; the domain computes deltas from them. */
93
+ export interface UsageSemantics {
94
+ /** How the provider reports USD. */
95
+ costUsd: 'session-cumulative' | 'per-turn' | 'none';
96
+ /** How the provider reports tokens for a turn. */
97
+ tokens: 'per-turn' | 'cumulative' | 'none';
98
+ }
99
+ export interface DriverCapabilities {
100
+ /** Multi-turn on one long-lived provider process. */
101
+ resident: boolean;
102
+ /** The provider serializes turns itself and reports per-input receipts. */
103
+ nativeInputQueue: boolean;
104
+ subagents: 'unsupported' | 'supported';
105
+ /** `policy.subagents = 'disabled'` can be enforced natively. */
106
+ subagentDisable: boolean;
107
+ /** Native sub-agents can run on a model other than the parent's. */
108
+ subagentModel: boolean;
109
+ /** Native sub-agents can run at a reasoning effort other than the parent's. */
110
+ subagentEffort: boolean;
111
+ /** The provider starts a turn by itself when a background sub-agent finishes. */
112
+ autonomousContinuation: boolean;
113
+ /** Input can be delivered into a running turn. */
114
+ steer: boolean;
115
+ /** The tool set can be restricted (`policy.tools` other than the default). */
116
+ toolFiltering: boolean;
117
+ usage: UsageSemantics;
118
+ }
119
+ export interface DriverDescriptor {
120
+ id: string;
121
+ displayName: string;
122
+ capabilities: DriverCapabilities;
123
+ /** CLI versions the recorded fixtures cover, informative. */
124
+ testedVersions?: string[];
125
+ }
126
+ export interface Attachment {
127
+ kind: 'image' | 'file';
128
+ path: string;
129
+ mimeType?: string;
130
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Provider-neutral session vocabulary. Every type here is plain data so it can
3
+ * cross the protocol boundary, be journaled and be folded by pure reducers.
4
+ * Provider wire formats never appear in this module (see drivers/).
5
+ */
6
+ export const EMPTY_USAGE = Object.freeze({
7
+ inputTokens: null,
8
+ outputTokens: null,
9
+ cacheReadTokens: null,
10
+ cacheWriteTokens: null,
11
+ totalTokens: null,
12
+ costUsd: null,
13
+ costEstimated: false,
14
+ model: null,
15
+ });