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,279 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { sessionsRoot } from '../../../shared/specrails-home.js';
5
+ import { PersistenceError, privateSqlitePath } from '../../engine/storage/private-path.js';
6
+ import { SessionError } from '../domain/errors.js';
7
+ import { emptySnapshot, foldEvents } from '../domain/snapshot.js';
8
+ import { JOURNAL_MIGRATIONS, JOURNAL_SCHEMA_VERSION } from './schema.js';
9
+ export const JOURNAL_LEASE_MS = 60_000;
10
+ export const JOURNAL_HEARTBEAT_MS = 15_000;
11
+ export const JOURNAL_FILENAME = 'sessions.sqlite';
12
+ /**
13
+ * Durable per-scope session journal (`~/.specrails/sessions/<scope>/sessions.sqlite`).
14
+ * One host owns it through an epoch-fenced lease that is asserted inside every
15
+ * write transaction. Events and the folded snapshot commit together; provider
16
+ * I/O never runs inside a transaction.
17
+ */
18
+ export class SqliteSessionJournal {
19
+ filename;
20
+ db;
21
+ lease;
22
+ now;
23
+ leaseMs;
24
+ cache = new Map();
25
+ heartbeatTimer = null;
26
+ closed = false;
27
+ constructor(filename, db, lease, now, leaseMs) {
28
+ this.filename = filename;
29
+ this.db = db;
30
+ this.lease = lease;
31
+ this.now = now;
32
+ this.leaseMs = leaseMs;
33
+ }
34
+ static async open(options) {
35
+ const filename = options.filename ?? path.join(sessionsRoot(options.scope, options.home), JOURNAL_FILENAME);
36
+ let target;
37
+ try {
38
+ target = await privateSqlitePath(filename, { create: !existsSync(filename) });
39
+ }
40
+ catch (error) {
41
+ if (error instanceof PersistenceError)
42
+ throw new SessionError('internal', `Session journal storage is unsafe or unavailable: ${error.message}`);
43
+ throw error;
44
+ }
45
+ const db = new DatabaseSync(target);
46
+ const now = options.now ?? Date.now;
47
+ const leaseMs = options.leaseMs ?? JOURNAL_LEASE_MS;
48
+ try {
49
+ db.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;');
50
+ migrate(db);
51
+ const lease = acquireLease(db, options.owner, now, leaseMs);
52
+ const journal = new SqliteSessionJournal(target, db, lease, now, leaseMs);
53
+ if (options.heartbeat !== false)
54
+ journal.startHeartbeat(options.onLeaseLost);
55
+ return journal;
56
+ }
57
+ catch (error) {
58
+ db.close();
59
+ throw error;
60
+ }
61
+ }
62
+ // ── SessionJournal ────────────────────────────────────────────────────────
63
+ createSession(record) {
64
+ this.write(() => {
65
+ if (this.db.prepare('SELECT 1 FROM sessions WHERE session_id=?').get(record.sessionId)) {
66
+ throw new SessionError('invalid_params', `Session ${record.sessionId} already exists`, { path: 'sessionId' });
67
+ }
68
+ const snapshot = emptySnapshot(record.sessionId);
69
+ this.db.prepare('INSERT INTO sessions(session_id,driver,cwd,metadata_json,status,last_seq,snapshot_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)')
70
+ .run(record.sessionId, record.driver, record.cwd, JSON.stringify(record.metadata ?? {}), 'open', 0, JSON.stringify(snapshot), record.createdAt, record.createdAt);
71
+ this.cache.set(record.sessionId, snapshot);
72
+ });
73
+ }
74
+ getSession(sessionId) {
75
+ const row = this.db.prepare('SELECT session_id,driver,cwd,metadata_json,created_at FROM sessions WHERE session_id=?').get(sessionId);
76
+ return row ? toRecord(row) : null;
77
+ }
78
+ append(sessionId, events, options) {
79
+ if (events.length === 0)
80
+ return [];
81
+ return this.write(() => {
82
+ const current = this.loadSnapshot(sessionId);
83
+ if (!current)
84
+ throw new SessionError('session_not_found', `Unknown session ${sessionId}`);
85
+ const envelopes = events.map((event, index) => ({ sessionId, seq: current.lastSeq + index + 1, event }));
86
+ const next = foldEvents(current, envelopes);
87
+ const insert = this.db.prepare('INSERT INTO events(session_id,seq,type,at,payload_json) VALUES (?,?,?,?,?)');
88
+ for (const envelope of envelopes)
89
+ insert.run(sessionId, envelope.seq, envelope.event.type, envelope.event.at, JSON.stringify(envelope.event));
90
+ this.db.prepare('UPDATE sessions SET last_seq=?, status=?, snapshot_json=?, updated_at=? WHERE session_id=?')
91
+ .run(next.lastSeq, next.status, JSON.stringify(next), envelopes.at(-1).event.at, sessionId);
92
+ if (options?.baseline) {
93
+ this.db.prepare('INSERT INTO usage_baselines(provider_ref,baseline_json,updated_at) VALUES (?,?,?) ON CONFLICT(provider_ref) DO UPDATE SET baseline_json=excluded.baseline_json, updated_at=excluded.updated_at')
94
+ .run(options.baseline.providerRef, JSON.stringify(options.baseline.value), envelopes.at(-1).event.at);
95
+ }
96
+ this.cache.set(sessionId, next);
97
+ return envelopes;
98
+ }, () => this.cache.delete(sessionId));
99
+ }
100
+ read(sessionId, afterSeq, limit) {
101
+ const rows = this.db.prepare('SELECT seq,payload_json FROM events WHERE session_id=? AND seq>? ORDER BY seq LIMIT ?').all(sessionId, afterSeq, limit + 1);
102
+ const events = rows.slice(0, limit).map((row) => ({ sessionId, seq: Number(row.seq), event: JSON.parse(String(row.payload_json)) }));
103
+ return { events, hasMore: rows.length > limit };
104
+ }
105
+ snapshot(sessionId) {
106
+ const snapshot = this.loadSnapshot(sessionId);
107
+ return snapshot ? structuredClone(snapshot) : null;
108
+ }
109
+ list(filter) {
110
+ const sql = 'SELECT session_id,driver,cwd,metadata_json,created_at,status,last_seq,snapshot_json FROM sessions';
111
+ const rows = filter === 'all' ? this.db.prepare(`${sql} ORDER BY created_at`).all() : this.db.prepare(`${sql} WHERE status=? ORDER BY created_at`).all(filter);
112
+ return rows.map((row) => {
113
+ const snapshot = JSON.parse(String(row.snapshot_json));
114
+ return {
115
+ sessionId: String(row.session_id),
116
+ driver: String(row.driver),
117
+ model: snapshot.model,
118
+ status: row.status === 'closed' ? 'closed' : 'open',
119
+ phase: snapshot.phase,
120
+ lastSeq: Number(row.last_seq),
121
+ createdAt: String(row.created_at),
122
+ metadata: JSON.parse(String(row.metadata_json)),
123
+ };
124
+ });
125
+ }
126
+ baseline(providerRef) {
127
+ const row = this.db.prepare('SELECT baseline_json FROM usage_baselines WHERE provider_ref=?').get(providerRef);
128
+ return row ? JSON.parse(String(row.baseline_json)) : null;
129
+ }
130
+ // ── Retention, lease and lifecycle ────────────────────────────────────────
131
+ /** Remove closed sessions last updated before `olderThan` (epoch ms). Open sessions are never removed. */
132
+ sweepClosed(olderThan) {
133
+ return this.write(() => {
134
+ const cutoff = new Date(olderThan).toISOString();
135
+ const ids = this.db.prepare("SELECT session_id FROM sessions WHERE status='closed' AND updated_at<?").all(cutoff).map((row) => String(row.session_id));
136
+ const remove = this.db.prepare('DELETE FROM sessions WHERE session_id=?');
137
+ for (const id of ids) {
138
+ remove.run(id);
139
+ this.cache.delete(id);
140
+ }
141
+ return ids.length;
142
+ });
143
+ }
144
+ /** Extend the lease; throws `journal_locked` when it was lost. */
145
+ renew() {
146
+ this.write(() => {
147
+ this.db.prepare('UPDATE host_lease SET heartbeat_at=?, expires_at=? WHERE singleton=1 AND owner=? AND epoch=?')
148
+ .run(new Date(this.now()).toISOString(), this.now() + this.leaseMs, this.lease.owner, this.lease.epoch);
149
+ });
150
+ }
151
+ /** Release the lease (clean shutdown) and close the database. Idempotent. */
152
+ close() {
153
+ if (this.closed)
154
+ return;
155
+ this.stopHeartbeat();
156
+ try {
157
+ this.transaction(() => {
158
+ this.db.prepare('DELETE FROM host_lease WHERE singleton=1 AND owner=? AND epoch=?').run(this.lease.owner, this.lease.epoch);
159
+ });
160
+ }
161
+ finally {
162
+ this.closed = true;
163
+ this.db.close();
164
+ }
165
+ }
166
+ checkIntegrity() {
167
+ const result = this.db.prepare('PRAGMA integrity_check').get();
168
+ if (result?.integrity_check !== 'ok')
169
+ throw new SessionError('internal', `Journal integrity check failed: ${String(result?.integrity_check)}`);
170
+ }
171
+ startHeartbeat(onLost) {
172
+ this.heartbeatTimer = setInterval(() => {
173
+ try {
174
+ this.renew();
175
+ }
176
+ catch (error) {
177
+ this.stopHeartbeat();
178
+ onLost?.(error instanceof SessionError ? error : new SessionError('journal_locked', String(error)));
179
+ }
180
+ }, JOURNAL_HEARTBEAT_MS);
181
+ this.heartbeatTimer.unref();
182
+ }
183
+ stopHeartbeat() {
184
+ if (this.heartbeatTimer)
185
+ clearInterval(this.heartbeatTimer);
186
+ this.heartbeatTimer = null;
187
+ }
188
+ // ── internals ─────────────────────────────────────────────────────────────
189
+ loadSnapshot(sessionId) {
190
+ const cached = this.cache.get(sessionId);
191
+ if (cached)
192
+ return cached;
193
+ const row = this.db.prepare('SELECT snapshot_json FROM sessions WHERE session_id=?').get(sessionId);
194
+ if (!row)
195
+ return null;
196
+ const snapshot = JSON.parse(String(row.snapshot_json));
197
+ this.cache.set(sessionId, snapshot);
198
+ return snapshot;
199
+ }
200
+ /** A fenced write: the lease is asserted inside the same transaction. */
201
+ write(fn, onRollback) {
202
+ if (this.closed)
203
+ throw new SessionError('internal', 'Session journal is closed');
204
+ return this.transaction(() => {
205
+ this.assertLease();
206
+ return fn();
207
+ }, onRollback);
208
+ }
209
+ transaction(fn, onRollback) {
210
+ this.db.exec('BEGIN IMMEDIATE');
211
+ try {
212
+ const result = fn();
213
+ this.db.exec('COMMIT');
214
+ return result;
215
+ }
216
+ catch (error) {
217
+ this.db.exec('ROLLBACK');
218
+ onRollback?.();
219
+ throw error;
220
+ }
221
+ }
222
+ assertLease() {
223
+ const row = this.db.prepare('SELECT owner,epoch,expires_at FROM host_lease WHERE singleton=1').get();
224
+ if (!row || String(row.owner) !== this.lease.owner || Number(row.epoch) !== this.lease.epoch || Number(row.expires_at) <= this.now()) {
225
+ throw new SessionError('journal_locked', 'The session journal lease was lost; another host owns this scope');
226
+ }
227
+ }
228
+ }
229
+ function migrate(db) {
230
+ const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
231
+ if (version > JOURNAL_SCHEMA_VERSION) {
232
+ throw new SessionError('store_incompatible', `Session journal schema ${version} is newer than supported ${JOURNAL_SCHEMA_VERSION}`);
233
+ }
234
+ for (let index = version; index < JOURNAL_SCHEMA_VERSION; index++) {
235
+ db.exec('BEGIN IMMEDIATE');
236
+ try {
237
+ db.exec(JOURNAL_MIGRATIONS[index]);
238
+ db.exec(`PRAGMA user_version=${index + 1}`);
239
+ db.exec('COMMIT');
240
+ }
241
+ catch (error) {
242
+ db.exec('ROLLBACK');
243
+ throw error;
244
+ }
245
+ }
246
+ }
247
+ function acquireLease(db, owner, now, leaseMs) {
248
+ if (!owner)
249
+ throw new SessionError('invalid_params', 'A journal owner is required');
250
+ db.exec('BEGIN IMMEDIATE');
251
+ try {
252
+ const at = now();
253
+ const prior = db.prepare('SELECT owner,expires_at FROM host_lease WHERE singleton=1').get();
254
+ if (prior && Number(prior.expires_at) > at) {
255
+ throw new SessionError('journal_locked', `Another session host (${String(prior.owner)}) owns this scope`);
256
+ }
257
+ const epoch = Number(db.prepare('SELECT epoch FROM lease_epoch WHERE singleton=1').get().epoch) + 1;
258
+ const previousEver = epoch > 1;
259
+ db.prepare('UPDATE lease_epoch SET epoch=? WHERE singleton=1').run(epoch);
260
+ const iso = new Date(at).toISOString();
261
+ db.prepare('INSERT INTO host_lease(singleton,owner,epoch,acquired_at,heartbeat_at,expires_at) VALUES (1,?,?,?,?,?) ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, epoch=excluded.epoch, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at')
262
+ .run(owner, epoch, iso, iso, at + leaseMs);
263
+ db.exec('COMMIT');
264
+ return { owner, epoch, expiresAt: at + leaseMs, previous: prior ? 'lost' : previousEver ? 'released' : 'none' };
265
+ }
266
+ catch (error) {
267
+ db.exec('ROLLBACK');
268
+ throw error;
269
+ }
270
+ }
271
+ function toRecord(row) {
272
+ return {
273
+ sessionId: String(row.session_id),
274
+ driver: String(row.driver),
275
+ cwd: String(row.cwd),
276
+ createdAt: String(row.created_at),
277
+ metadata: JSON.parse(String(row.metadata_json)),
278
+ };
279
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Ports owned by the session application layer. Each interface is as narrow as
3
+ * its use case: drivers move provider work, the journal persists committed
4
+ * events, the clock and ids make timing and identity deterministic in tests.
5
+ * Implementations live in drivers/, journal/ and testing/; the composition root
6
+ * (index.ts) wires them.
7
+ */
8
+ import type { SessionEvent, SessionEventEnvelope, ToolActivity } from './domain/events.js';
9
+ import type { SessionSnapshot } from './domain/snapshot.js';
10
+ import type { Attachment, DriverDescriptor, OutputChannel, SessionPolicy, SubagentKind, SubagentPhase } from './domain/types.js';
11
+ import type { ReportedUsage, UsageBaseline } from './domain/usage.js';
12
+ export interface DriverOpenSpec {
13
+ sessionId: string;
14
+ /** Increments every time the session gets a new provider process. */
15
+ generation: number;
16
+ cwd: string;
17
+ model: string;
18
+ effort: string | null;
19
+ systemPrompt: string | null;
20
+ policy: SessionPolicy;
21
+ /** Provider session to resume (Claude session id, Codex thread id), if any. */
22
+ providerSessionRef: string | null;
23
+ }
24
+ export interface DriverInput {
25
+ inputId: string;
26
+ text: string;
27
+ attachments?: Attachment[];
28
+ delivery: 'queue' | 'steer';
29
+ /** Set by the application for policy-driven turns (e.g. collecting settled sub-agents). */
30
+ origin?: 'user' | 'system';
31
+ }
32
+ export type CloseReason = 'idle' | 'stalled' | 'limit' | 'user_stop' | 'host_request' | 'config_change' | 'evicted' | 'shutdown' | 'session_closed';
33
+ /**
34
+ * Normalized, uncommitted facts a driver observed. Drivers never assign turn or
35
+ * session sequence numbers; the application turns these into journaled events.
36
+ */
37
+ export type DriverEvent = {
38
+ kind: 'process.started';
39
+ pid?: number;
40
+ version?: string;
41
+ } | {
42
+ kind: 'process.exited';
43
+ exitCode: number | null;
44
+ signal: string | null;
45
+ } | {
46
+ kind: 'provider.ref';
47
+ providerSessionRef: string;
48
+ } | {
49
+ kind: 'input.receipt';
50
+ inputId: string;
51
+ state: 'queued' | 'started' | 'completed' | 'rejected';
52
+ reason?: string;
53
+ }
54
+ /** `continuation` = the provider started a turn on its own (e.g. after a background sub-agent finished). */
55
+ | {
56
+ kind: 'turn.started';
57
+ trigger: 'input' | 'continuation';
58
+ inputIds: string[];
59
+ } | {
60
+ kind: 'turn.output';
61
+ channel: OutputChannel;
62
+ delta: string;
63
+ } | ({
64
+ kind: 'turn.tool';
65
+ } & ToolActivity) | {
66
+ kind: 'turn.completed';
67
+ status: 'completed' | 'failed' | 'stopped';
68
+ text: string;
69
+ error?: string;
70
+ usage: ReportedUsage;
71
+ } | {
72
+ kind: 'subagent.started';
73
+ subagentId: string;
74
+ parentId: string | null;
75
+ agentKind: SubagentKind;
76
+ agentType?: string;
77
+ description: string;
78
+ prompt?: string;
79
+ } | {
80
+ kind: 'subagent.phase';
81
+ subagentId: string;
82
+ phase: SubagentPhase;
83
+ reason?: string;
84
+ } | {
85
+ kind: 'subagent.output';
86
+ subagentId: string;
87
+ channel: 'text' | 'tool';
88
+ delta?: string;
89
+ tool?: ToolActivity;
90
+ }
91
+ /** Cumulative per-sub-agent usage as reported (the application derives deltas when needed). */
92
+ | {
93
+ kind: 'subagent.usage';
94
+ subagentId: string;
95
+ usage: ReportedUsage;
96
+ toolUses?: number;
97
+ durationMs?: number;
98
+ } | {
99
+ kind: 'subagent.result';
100
+ subagentId: string;
101
+ summary: string;
102
+ }
103
+ /** The provider's authoritative list of live sub-agent ids, when it reports one. */
104
+ | {
105
+ kind: 'roster';
106
+ liveSubagentIds: string[];
107
+ } | {
108
+ kind: 'diagnostic';
109
+ level: 'info' | 'warning';
110
+ code: string;
111
+ message: string;
112
+ };
113
+ export type DriverEventSink = (event: DriverEvent) => void;
114
+ export interface DriverSession {
115
+ /** Write input to the provider. Receipts arrive as `input.receipt` events. */
116
+ send(input: DriverInput): Promise<void>;
117
+ /** Stop the running turn only. The driver reports `turn.completed` with status `stopped`. */
118
+ interrupt(): Promise<void>;
119
+ /**
120
+ * Stop the given sub-agents (all when omitted). Resolves with the ids the
121
+ * driver could stop selectively; `'process'` means it had to end the process.
122
+ */
123
+ stopSubagents(subagentIds?: string[]): Promise<string[] | 'process'>;
124
+ /** Idempotent. Resolves after the process tree is gone; no events after that. */
125
+ close(reason: CloseReason): Promise<void>;
126
+ }
127
+ export interface DriverFactory {
128
+ readonly descriptor: DriverDescriptor;
129
+ open(spec: DriverOpenSpec, sink: DriverEventSink): Promise<DriverSession>;
130
+ }
131
+ export interface DriverCatalog {
132
+ get(driverId: string): DriverFactory | undefined;
133
+ descriptors(): DriverDescriptor[];
134
+ }
135
+ export interface SessionRecord {
136
+ sessionId: string;
137
+ driver: string;
138
+ cwd: string;
139
+ createdAt: string;
140
+ metadata: Record<string, unknown>;
141
+ }
142
+ export interface SessionSummary {
143
+ sessionId: string;
144
+ driver: string;
145
+ model: string;
146
+ status: 'open' | 'closed';
147
+ phase: SessionSnapshot['phase'];
148
+ lastSeq: number;
149
+ createdAt: string;
150
+ metadata: Record<string, unknown>;
151
+ }
152
+ export interface AppendOptions {
153
+ /** Persist the provider's cumulative totals in the same transaction as the events. */
154
+ baseline?: {
155
+ providerRef: string;
156
+ value: UsageBaseline;
157
+ };
158
+ }
159
+ /**
160
+ * Durable, append-only event store for one scope. All methods are synchronous
161
+ * (node:sqlite) and every append is one transaction that assigns gap-free
162
+ * sequence numbers. It validates nothing about meaning: the application folds
163
+ * events before appending them.
164
+ */
165
+ export interface SessionJournal {
166
+ createSession(record: SessionRecord): void;
167
+ getSession(sessionId: string): SessionRecord | null;
168
+ /** Events arrive stamped (`at`) and already validated by the application's fold. */
169
+ append(sessionId: string, events: SessionEvent[], options?: AppendOptions): SessionEventEnvelope[];
170
+ read(sessionId: string, afterSeq: number, limit: number): {
171
+ events: SessionEventEnvelope[];
172
+ hasMore: boolean;
173
+ };
174
+ /** Latest snapshot (folded and cached by the implementation). */
175
+ snapshot(sessionId: string): SessionSnapshot | null;
176
+ list(filter: 'open' | 'closed' | 'all'): SessionSummary[];
177
+ baseline(providerRef: string): UsageBaseline | null;
178
+ }
179
+ export interface TimerHandle {
180
+ cancel(): void;
181
+ }
182
+ export interface Clock {
183
+ now(): number;
184
+ /** ISO-8601 time for event stamps. */
185
+ iso(): string;
186
+ after(ms: number, callback: () => void): TimerHandle;
187
+ }
188
+ export interface Ids {
189
+ session(): string;
190
+ turn(): string;
191
+ input(): string;
192
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import type { DriverFactory } from '../ports.js';
2
+ /**
3
+ * Shared behavioural contract for every session driver (Liskov substitution:
4
+ * the application must not care which provider it talks to). Framework-free:
5
+ * each check throws on failure, so any test runner can execute it.
6
+ */
7
+ export interface ConformanceHarness {
8
+ /** A fresh factory whose provider answers one simple prompt with text and ends the turn. */
9
+ factory(): DriverFactory;
10
+ /** Let the provider progress until it waits for input (e.g. a replay's idle point). */
11
+ settle(factory: DriverFactory): Promise<void>;
12
+ /** Model to open sessions with. */
13
+ model: string;
14
+ /** Sub-agent policy the scripted turn was recorded with. */
15
+ subagents: 'enabled' | 'disabled';
16
+ }
17
+ export interface ConformanceCheck {
18
+ name: string;
19
+ run(harness: ConformanceHarness): Promise<void>;
20
+ }
21
+ export declare const DRIVER_CONFORMANCE: readonly ConformanceCheck[];
@@ -0,0 +1,97 @@
1
+ import { SessionService } from '../application/session-service.js';
2
+ import { validateDescriptor } from '../domain/descriptor.js';
3
+ import { resolvePolicy } from '../domain/policy.js';
4
+ import { FakeClock, SequentialIds } from './fake-clock.js';
5
+ import { MemoryJournal } from './memory-journal.js';
6
+ import { catalogOf } from './scripted-driver.js';
7
+ function assert(condition, message) {
8
+ if (!condition)
9
+ throw new Error(`Driver conformance: ${message}`);
10
+ }
11
+ async function oneTurn(harness) {
12
+ const factory = harness.factory();
13
+ const journal = new MemoryJournal();
14
+ const service = new SessionService({ journal, drivers: catalogOf(factory), clock: new FakeClock(), ids: new SequentialIds() });
15
+ const { sessionId } = await service.open({ driver: factory.descriptor.id, model: harness.model, cwd: process.cwd(), policy: { subagents: harness.subagents } });
16
+ await service.send(sessionId, { inputId: 'conformance-1', text: 'Reply briefly.', delivery: 'queue' });
17
+ await harness.settle(factory);
18
+ const events = () => journal.events(sessionId).map((envelope) => envelope.event);
19
+ return { factory, journal, service, sessionId, events };
20
+ }
21
+ export const DRIVER_CONFORMANCE = Object.freeze([
22
+ {
23
+ name: 'declares a valid, coherent descriptor',
24
+ async run(harness) {
25
+ validateDescriptor(harness.factory().descriptor);
26
+ },
27
+ },
28
+ {
29
+ name: 'refuses policies it cannot enforce instead of ignoring them',
30
+ async run(harness) {
31
+ const descriptor = harness.factory().descriptor;
32
+ const caps = descriptor.capabilities;
33
+ const refuses = (input) => {
34
+ try {
35
+ resolvePolicy(input, descriptor);
36
+ return false;
37
+ }
38
+ catch (error) {
39
+ return error.code === 'policy_unenforceable';
40
+ }
41
+ };
42
+ if (caps.subagents === 'supported')
43
+ assert(refuses({ subagents: 'disabled' }) === !caps.subagentDisable, 'sub-agent disabling must match subagentDisable');
44
+ assert(refuses({ subagents: 'disabled', tools: { mode: 'read-only' } }) === !caps.toolFiltering, 'tool filtering must match toolFiltering');
45
+ },
46
+ },
47
+ {
48
+ name: 'runs one user turn with ordered receipts and a terminal outcome',
49
+ async run(harness) {
50
+ const { events, service, sessionId } = await oneTurn(harness);
51
+ const list = events();
52
+ const types = list.map((event) => event.type);
53
+ const index = (predicate) => list.findIndex(predicate);
54
+ const accepted = index((event) => event.type === 'input.accepted');
55
+ const started = index((event) => event.type === 'input.state' && event.state === 'started');
56
+ const turnStarted = index((event) => event.type === 'turn.started');
57
+ const turnDone = index((event) => event.type === 'turn.completed');
58
+ assert(types.includes('session.process'), 'the provider process start is recorded');
59
+ assert(accepted >= 0 && started > accepted, 'the input is accepted, then started');
60
+ assert(turnStarted >= 0 && turnDone > turnStarted, 'the turn starts and completes');
61
+ const turn = list[turnStarted];
62
+ assert(turn.origin === 'user' && turn.inputIds.includes('conformance-1'), 'the turn is a user turn bound to its input');
63
+ const done = list[turnDone];
64
+ assert(done.status === 'completed' && done.text.trim().length > 0, 'the turn completes with text');
65
+ const snapshot = service.snapshot(sessionId);
66
+ assert(snapshot.inputs['conformance-1']?.state === 'completed', 'the input completes with its turn');
67
+ assert(snapshot.openTurn === null && snapshot.phase === 'idle', 'the session is idle after a turn without sub-agents');
68
+ },
69
+ },
70
+ {
71
+ name: 'reports usage according to its declared semantics',
72
+ async run(harness) {
73
+ const { events, factory } = await oneTurn(harness);
74
+ const done = events().find((event) => event.type === 'turn.completed');
75
+ assert(done, 'a turn completed');
76
+ const usage = factory.descriptor.capabilities.usage;
77
+ if (usage.costUsd === 'none')
78
+ assert(done.usage.costUsd === null || done.usage.costEstimated, 'a driver without billed cost never reports billed USD');
79
+ if (usage.tokens === 'none')
80
+ assert(done.usage.inputTokens === null && done.usage.outputTokens === null, 'a driver without tokens reports none');
81
+ for (const value of [done.usage.inputTokens, done.usage.outputTokens, done.usage.costUsd])
82
+ assert(value === null || (Number.isFinite(value) && value >= 0), 'usage values are null or non-negative');
83
+ },
84
+ },
85
+ {
86
+ name: 'closes idempotently and emits nothing afterwards',
87
+ async run(harness) {
88
+ const { service, sessionId, journal, factory } = await oneTurn(harness);
89
+ await service.close(sessionId, 'conformance');
90
+ await service.close(sessionId, 'conformance');
91
+ const count = journal.events(sessionId).length;
92
+ await harness.settle(factory);
93
+ assert(journal.events(sessionId).length === count, 'no events after close');
94
+ assert(service.snapshot(sessionId).status === 'closed', 'the session is closed');
95
+ },
96
+ },
97
+ ]);
@@ -0,0 +1,20 @@
1
+ import type { Clock, Ids, TimerHandle } from '../ports.js';
2
+ /** Deterministic clock: time only moves through `advance`. */
3
+ export declare class FakeClock implements Clock {
4
+ private current;
5
+ private seq;
6
+ private readonly timers;
7
+ constructor(start?: number);
8
+ now(): number;
9
+ iso(): string;
10
+ after(ms: number, callback: () => void): TimerHandle;
11
+ /** Move time forward, firing due timers in order (timers armed by callbacks included). */
12
+ advance(ms: number): void;
13
+ pendingTimers(): number;
14
+ }
15
+ export declare class SequentialIds implements Ids {
16
+ private counters;
17
+ session(): string;
18
+ turn(): string;
19
+ input(): string;
20
+ }
@@ -0,0 +1,34 @@
1
+ /** Deterministic clock: time only moves through `advance`. */
2
+ export class FakeClock {
3
+ current;
4
+ seq = 0;
5
+ timers = new Map();
6
+ constructor(start = Date.UTC(2026, 9, 7, 10, 0, 0)) { this.current = start; }
7
+ now() { return this.current; }
8
+ iso() { return new Date(this.current).toISOString(); }
9
+ after(ms, callback) {
10
+ const id = ++this.seq;
11
+ this.timers.set(id, { at: this.current + Math.max(0, ms), callback });
12
+ return { cancel: () => { this.timers.delete(id); } };
13
+ }
14
+ /** Move time forward, firing due timers in order (timers armed by callbacks included). */
15
+ advance(ms) {
16
+ const target = this.current + ms;
17
+ for (;;) {
18
+ const due = [...this.timers.entries()].filter(([, timer]) => timer.at <= target).sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
19
+ if (!due)
20
+ break;
21
+ this.timers.delete(due[0]);
22
+ this.current = Math.max(this.current, due[1].at);
23
+ due[1].callback();
24
+ }
25
+ this.current = target;
26
+ }
27
+ pendingTimers() { return this.timers.size; }
28
+ }
29
+ export class SequentialIds {
30
+ counters = { session: 0, turn: 0, input: 0 };
31
+ session() { return `session-${++this.counters.session}`; }
32
+ turn() { return `turn-${++this.counters.turn}`; }
33
+ input() { return `input-${++this.counters.input}`; }
34
+ }