@aragon-agent/cli 0.6.10 → 0.6.12

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.
@@ -33,9 +33,9 @@
33
33
  */
34
34
  import { PassThrough } from 'node:stream';
35
35
  import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
36
- import { PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
36
+ import { ENTER_NEWLINE_FRAME, PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
- import { rewriteEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
38
+ import { splitEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
39
39
  /**
40
40
  * How long a possible-prefix tail is held before being flushed as text.
41
41
  *
@@ -89,7 +89,18 @@ export function createStdinFilter(real, features, bridge, onWarn) {
89
89
  let burstTimer = null;
90
90
  let assemblyTimer = null;
91
91
  let disposed = false;
92
+ let finished = false;
92
93
  let paste = { kind: 'idle' };
94
+ let discardBracketed = false;
95
+ let outputBatch = null;
96
+ const writeInput = (text) => {
97
+ if (disposed || outputBatch?.refused)
98
+ return;
99
+ if (outputBatch)
100
+ outputBatch.chunks.push(text);
101
+ else
102
+ wrapper.write(text);
103
+ };
93
104
  // --- The surface Ink reads off the stream it is handed. ------------------
94
105
  // `isRawModeSupported()` reads `props.stdin.isTTY` (App.js:34), so this has
95
106
  // to be a DELEGATING GETTER rather than a snapshot: a stdin that is not a TTY
@@ -122,6 +133,8 @@ export function createStdinFilter(real, features, bridge, onWarn) {
122
133
  };
123
134
  /** `.unref()` so a held tail can never keep the process alive on its own. */
124
135
  const arm = (fn, ms) => {
136
+ if (finished || disposed)
137
+ return null;
125
138
  const t = setTimeout(fn, ms);
126
139
  t.unref?.();
127
140
  return t;
@@ -143,6 +156,10 @@ export function createStdinFilter(real, features, bridge, onWarn) {
143
156
  if (disposed)
144
157
  return;
145
158
  if (overflow || bytes > PASTE_MAX_BYTES) {
159
+ if (outputBatch?.refused)
160
+ return;
161
+ if (outputBatch)
162
+ outputBatch.refused = true;
146
163
  const text = `Paste too large (${formatPasteSize(bytes)}); limit is ` +
147
164
  `${formatPasteSize(PASTE_MAX_BYTES)}. Nothing was inserted.`;
148
165
  onWarn?.(text);
@@ -153,7 +170,7 @@ export function createStdinFilter(real, features, bridge, onWarn) {
153
170
  if (payload.length === 0)
154
171
  return;
155
172
  // ONE `write()` call, at the position the paste occupied in the stream.
156
- wrapper.write(PASTE_OPEN + payload + PASTE_CLOSE);
173
+ writeInput(PASTE_OPEN + payload + PASTE_CLOSE);
157
174
  };
158
175
  const flushBurst = () => {
159
176
  burstTimer = clearTimer(burstTimer);
@@ -169,6 +186,12 @@ export function createStdinFilter(real, features, bridge, onWarn) {
169
186
  return;
170
187
  const { body, bytes, overflow } = paste;
171
188
  paste = { kind: 'idle' };
189
+ if (discardBracketed) {
190
+ discardBracketed = false;
191
+ if (outputBatch)
192
+ outputBatch.refused = true;
193
+ return;
194
+ }
172
195
  if (reason === 'timeout') {
173
196
  // I-4: an unterminated marker must never wedge input. Emitting what
174
197
  // arrived is strictly better than holding the keyboard hostage.
@@ -190,20 +213,9 @@ export function createStdinFilter(real, features, bridge, onWarn) {
190
213
  * those would corrupt their data silently. v1 covered only Tier 1, which left
191
214
  * the loss in place for exactly the terminals Tier 2 exists to serve (P1-7).
192
215
  */
193
- const handleOutsidePaste = (text, holdTail) => {
216
+ const handleText = (text, holdTail) => {
194
217
  if (text.length === 0)
195
218
  return;
196
- // ENTER-FAMILY REWRITE, AND IT IS FIRST FOR A REASON
197
- // (tui-shift-enter-copy-queue 3.4). The Alt+Enter encoding contains a CR,
198
- // and `classifyChunk`'s primary rule -- "longer than one character AND
199
- // contains a line break" -- would buffer that two-byte key as a Tier 2
200
- // paste body whose sanitiser then strips the ESC, silently swallowing the
201
- // keystroke. After the rewrite the frame is NUL-led and classifies as
202
- // `keys` under both rules. The cost is that an UNBRACKETED paste whose
203
- // body happens to contain the literal Alt+Enter bytes loses that one
204
- // pair's newline; a bracketed body never reaches this function at all
205
- // (I-3), and losing two bytes of a heuristic tier beats eating the key.
206
- text = rewriteEnterSequences(text);
207
219
  if (paste.kind === 'burst') {
208
220
  paste = { kind: 'burst', body: paste.body + text, bytes: paste.bytes + byteLength(text) };
209
221
  burstTimer = clearTimer(burstTimer);
@@ -219,12 +231,12 @@ export function createStdinFilter(real, features, bridge, onWarn) {
219
231
  // Step 0: with `mouse` false this degenerates to "pass the text on", which
220
232
  // is byte-identical to the unwrapped stream.
221
233
  if (!features.mouse) {
222
- wrapper.write(text);
234
+ writeInput(text);
223
235
  return;
224
236
  }
225
237
  const split = splitMouseEvents(text);
226
238
  if (split.text.length > 0)
227
- wrapper.write(split.text);
239
+ writeInput(split.text);
228
240
  for (const event of split.events)
229
241
  emit(event);
230
242
  if (split.pending.length === 0)
@@ -236,10 +248,23 @@ export function createStdinFilter(real, features, bridge, onWarn) {
236
248
  if (holdTail)
237
249
  pending += split.pending;
238
250
  else
239
- wrapper.write(split.pending);
251
+ writeInput(split.pending);
252
+ };
253
+ const handleOutsidePaste = (text, holdTail) => {
254
+ const segments = splitEnterSequences(text);
255
+ for (let index = 0; index < segments.length; index += 1) {
256
+ const segment = segments[index];
257
+ if (segment.kind === 'text') {
258
+ handleText(segment.text, holdTail && index === segments.length - 1);
259
+ continue;
260
+ }
261
+ // A key terminates the heuristic paste before its internal frame is written.
262
+ flushBurst();
263
+ writeInput(segment.kind === 'submit' ? '\r' : ENTER_NEWLINE_FRAME);
264
+ }
240
265
  };
241
266
  const appendBracketed = (text) => {
242
- if (paste.kind !== 'bracketed' || text.length === 0)
267
+ if (paste.kind !== 'bracketed' || text.length === 0 || discardBracketed)
243
268
  return;
244
269
  const bytes = paste.bytes + byteLength(text);
245
270
  const overflow = paste.overflow || bytes > PASTE_MAX_BYTES;
@@ -315,13 +340,39 @@ export function createStdinFilter(real, features, bridge, onWarn) {
315
340
  handleOutsidePaste(text, false);
316
341
  };
317
342
  const onData = (chunk) => {
318
- if (disposed)
343
+ if (disposed || finished)
319
344
  return;
320
345
  flushTimer = clearTimer(flushTimer);
321
346
  const decoded = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
322
347
  const carried = pending;
323
348
  pending = '';
324
- feed(carried + decoded);
349
+ // A rejected paste must not leave earlier text or a later Enter executable.
350
+ const batch = { chunks: [], refused: false };
351
+ outputBatch = batch;
352
+ try {
353
+ feed(carried + decoded.replace(/\u0000/g, ''));
354
+ }
355
+ finally {
356
+ outputBatch = null;
357
+ }
358
+ if (batch.refused) {
359
+ burstTimer = clearTimer(burstTimer);
360
+ if (paste.kind === 'bracketed') {
361
+ // Keep consuming an unfinished body so its next chunk cannot become keys.
362
+ discardBracketed = true;
363
+ paste = { kind: 'bracketed', body: '', bytes: 0, overflow: false };
364
+ }
365
+ else {
366
+ pending = '';
367
+ paste = { kind: 'idle' };
368
+ assemblyTimer = clearTimer(assemblyTimer);
369
+ }
370
+ return;
371
+ }
372
+ if (disposed || finished)
373
+ return;
374
+ for (const text of batch.chunks)
375
+ wrapper.write(text);
325
376
  if (paste.kind === 'bracketed' || pending.length === 0)
326
377
  return;
327
378
  if (pending.length > MAX_PENDING_CHARS) {
@@ -337,11 +388,24 @@ export function createStdinFilter(real, features, bridge, onWarn) {
337
388
  // P2-7: a closed stdin must still terminate Ink's reader, or it waits forever
338
389
  // on a stream that will never produce another byte.
339
390
  const onEnd = () => {
340
- flushBracketed('timeout');
341
- flushBurst();
342
- flushPending();
343
- if (!wrapper.writableEnded)
344
- wrapper.end();
391
+ if (finished || disposed)
392
+ return;
393
+ finished = true;
394
+ flushTimer = clearTimer(flushTimer);
395
+ burstTimer = clearTimer(burstTimer);
396
+ assemblyTimer = clearTimer(assemblyTimer);
397
+ const tail = pending;
398
+ pending = '';
399
+ if (paste.kind === 'bracketed') {
400
+ appendBracketed(tail);
401
+ flushBracketed('timeout');
402
+ }
403
+ else {
404
+ // The pending prefix belongs to the currently open burst, if any.
405
+ handleOutsidePaste(tail, false);
406
+ flushBurst();
407
+ }
408
+ wrapper.end();
345
409
  };
346
410
  // `setEncoding('utf8')` on the REAL stream so Node's StringDecoder handles
347
411
  // multi-byte boundaries for us -- the scan works on characters, and a UTF-8
@@ -144,6 +144,9 @@ export interface AgentEventSource {
144
144
  */
145
145
  export type AgentLogEvent = {
146
146
  type: 'agent_start';
147
+ } | {
148
+ type: 'steering_accepted';
149
+ ids: readonly string[];
147
150
  } | {
148
151
  type: 'agent_end';
149
152
  messages: unknown[];
@@ -39,14 +39,13 @@ export interface SessionMeta {
39
39
  export interface SavedSession {
40
40
  version: number;
41
41
  savedAt: number;
42
- model: ModelRef;
42
+ model?: ModelRef;
43
43
  messages: Message[];
44
44
  entries: Entry[];
45
45
  /**
46
46
  * The live todo list at save time (todo-plan-execution §3.13).
47
47
  *
48
- * `SESSION_VERSION` STAYS 1: the field is additive and optional, `loadSession`
49
- * validates only that `messages` and `entries` are arrays, and an older file
48
+ * `SESSION_VERSION` STAYS 1: the field is additive and optional, and an older file
50
49
  * simply yields `undefined` — which `restoreTodos` treats as "clear", the
51
50
  * right answer for a session that predates the feature (D-20 / D-22).
52
51
  */
@@ -56,8 +55,8 @@ export interface SavedSession {
56
55
  *
57
56
  * `SESSION_VERSION` STAYS 1 for the reason `todos?` records above, and this is
58
57
  * the precedent that file's own comments already set: the field is additive
59
- * and optional, and `loadSession` validates only array-ness of `messages` /
60
- * `entries`. A TUI `/save` file simply has no `meta`, which is exactly how
58
+ * and optional. Boundary validation preserves it without changing its shape.
59
+ * A TUI `/save` file simply has no `meta`, which is exactly how
61
60
  * `sessions list` tells the two apart.
62
61
  */
63
62
  meta?: SessionMeta;
@@ -88,8 +87,8 @@ export declare function saveSession(filePath: string, data: {
88
87
  * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
89
88
  * and any todo card that was saved mid-run (todo-plan-execution §3.8 / C-5).
90
89
  *
91
- * `saveSession` writes `entries` verbatim and `loadSession` validates only
92
- * array-ness, so a session saved while a dispatch was in flight resumes with a
90
+ * Structural validation cannot settle live state: a session saved while a
91
+ * dispatch was in flight can contain a valid
93
92
  * `kind: 'team'` entry claiming `active: true` while nothing is running. TWO
94
93
  * things then go wrong at once: the card spins forever, and `Transcript`'s
95
94
  * settled boundary is MONOTONIC — an entry that never settles never reaches
@@ -8,6 +8,7 @@
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, isAbsolute, join, resolve } from 'node:path';
10
10
  import { getSessionsDir } from '../config/store.js';
11
+ import { validateSession } from './validate-session.js';
11
12
  const SESSION_VERSION = 1;
12
13
  /** Resolve a `/save` target: absolute/relative path, or a name in the data dir. */
13
14
  export function resolveSessionPath(nameOrPath, cwd) {
@@ -109,8 +110,8 @@ export function saveSession(filePath, data) {
109
110
  * Settle any team entry that was saved mid-dispatch (team-subagents §5.3 / P1-5),
110
111
  * and any todo card that was saved mid-run (todo-plan-execution §3.8 / C-5).
111
112
  *
112
- * `saveSession` writes `entries` verbatim and `loadSession` validates only
113
- * array-ness, so a session saved while a dispatch was in flight resumes with a
113
+ * Structural validation cannot settle live state: a session saved while a
114
+ * dispatch was in flight can contain a valid
114
115
  * `kind: 'team'` entry claiming `active: true` while nothing is running. TWO
115
116
  * things then go wrong at once: the card spins forever, and `Transcript`'s
116
117
  * settled boundary is MONOTONIC — an entry that never settles never reaches
@@ -196,7 +197,7 @@ export function normalizeLoadedEntries(entries) {
196
197
  // THE SEVENTH CLAUSE (tui-shift-enter-copy-queue 5.2.5). Core's steering
197
198
  // queue is memory-only, so a `queued` entry on disk is a promise this
198
199
  // process can no longer keep: the message was never accepted into any
199
- // conversation. Downgrade to a WARN NOTICE carrying the first line
200
+ // conversation. Downgrade to a WARN NOTICE carrying the full text
200
201
  // rather than to a `user` entry -- pretending it was sent is the lie
201
202
  // that loses the user's words with a straight face.
202
203
  //
@@ -204,12 +205,11 @@ export function normalizeLoadedEntries(entries) {
204
205
  // live transcript, and a fresh id here would collide with the next
205
206
  // `e<n>` the reducer allocates.
206
207
  if (entry.kind === 'queued') {
207
- const firstLine = entry.text.split('\n', 1)[0] ?? '';
208
208
  const downgraded = {
209
209
  id: entry.id,
210
210
  kind: 'notice',
211
211
  level: 'warn',
212
- text: `Queued but never sent: ${firstLine}`,
212
+ text: `Queued but never sent: ${entry.text}`,
213
213
  };
214
214
  return downgraded;
215
215
  }
@@ -218,9 +218,6 @@ export function normalizeLoadedEntries(entries) {
218
218
  }
219
219
  export function loadSession(filePath) {
220
220
  const raw = readFileSync(filePath, 'utf-8');
221
- const parsed = JSON.parse(raw);
222
- if (!parsed || !Array.isArray(parsed.messages) || !Array.isArray(parsed.entries)) {
223
- throw new Error('Invalid session file (missing messages/entries).');
224
- }
221
+ const parsed = validateSession(JSON.parse(raw));
225
222
  return { ...parsed, entries: normalizeLoadedEntries(parsed.entries) };
226
223
  }
@@ -18,6 +18,7 @@
18
18
  * command the docs recommend. `list` and `prune` therefore see only files
19
19
  * carrying `meta.id`; `show` and `rm` accept any id, because the user named it.
20
20
  */
21
+ import type { ModelRef } from '@aragon-agent/core';
21
22
  import { type SavedSession, type SessionMeta } from './persist.js';
22
23
  /**
23
24
  * The id is a FILENAME under the user's home directory, and the caller supplying
@@ -84,7 +85,9 @@ export declare function readSessionFile(path: string): SavedSession | null;
84
85
  export declare function resolveSessionRef(idOrPath: string, cwd: string): string | null;
85
86
  export interface WriteSessionInput {
86
87
  id: string;
87
- session: Omit<SavedSession, 'version' | 'savedAt' | 'meta'>;
88
+ session: Omit<SavedSession, 'version' | 'savedAt' | 'meta' | 'model'> & {
89
+ model: ModelRef;
90
+ };
88
91
  meta: SessionMeta;
89
92
  }
90
93
  /**
@@ -0,0 +1,3 @@
1
+ import type { SavedSession } from './persist.js';
2
+ /** Return a prepared copy, preserving extensions; throw with the corrupt field path. */
3
+ export declare function validateSession(value: unknown): SavedSession;
@@ -0,0 +1,207 @@
1
+ import { isAdapterProvider } from '../config/schema.js';
2
+ import { normalizeTodos } from '../todo/normalize.js';
3
+ import { TODO_LIMITS } from '../todo/limits.js';
4
+ function fail(path, expected) {
5
+ throw new Error(`Invalid session file: ${path} must be ${expected}.`);
6
+ }
7
+ function record(value, path) {
8
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
9
+ fail(path, 'an object');
10
+ }
11
+ return value;
12
+ }
13
+ const string = (value, path) => {
14
+ if (typeof value !== 'string')
15
+ fail(path, 'a string');
16
+ };
17
+ const nonempty = (value, path) => {
18
+ if (typeof value !== 'string' || value.trim().length === 0)
19
+ fail(path, 'a non-empty string');
20
+ };
21
+ const number = (value, path) => {
22
+ if (typeof value !== 'number' || !Number.isFinite(value))
23
+ fail(path, 'a finite number');
24
+ };
25
+ const boolean = (value, path) => {
26
+ if (typeof value !== 'boolean')
27
+ fail(path, 'a boolean');
28
+ };
29
+ const object = (value, path) => { record(value, path); };
30
+ function optional(check) {
31
+ return (value, path) => { if (value !== undefined)
32
+ check(value, path); };
33
+ }
34
+ function enumeration(...values) {
35
+ return (value, path) => {
36
+ if (typeof value !== 'string' || !values.includes(value))
37
+ fail(path, values.join(' | '));
38
+ };
39
+ }
40
+ function array(check) {
41
+ return (value, path) => {
42
+ if (!Array.isArray(value))
43
+ fail(path, 'an array');
44
+ value.forEach((item, index) => check(item, `${path}[${index}]`));
45
+ };
46
+ }
47
+ function shape(fields) {
48
+ return (value, path) => {
49
+ const source = record(value, path);
50
+ for (const [key, check] of Object.entries(fields))
51
+ check(source[key], `${path}.${key}`);
52
+ };
53
+ }
54
+ const usage = shape({
55
+ inputTokens: number, outputTokens: number, cacheReadTokens: optional(number),
56
+ cacheWriteTokens: optional(number), cost: optional(number),
57
+ });
58
+ function contentPart(value, path) {
59
+ const part = record(value, path);
60
+ enumeration('text', 'image')(part.type, `${path}.type`);
61
+ if (part.type === 'text')
62
+ string(part.text, `${path}.text`);
63
+ else
64
+ shape({ mediaType: string, data: string })(part, path);
65
+ }
66
+ function contentBlock(value, path) {
67
+ const block = record(value, path);
68
+ enumeration('text', 'thinking', 'tool_call')(block.type, `${path}.type`);
69
+ if (block.type === 'tool_call') {
70
+ shape({ toolCallId: string, toolName: string, args: object })(block, path);
71
+ return;
72
+ }
73
+ string(block.text, `${path}.text`);
74
+ if (block.type === 'thinking')
75
+ optional(string)(block.signature, `${path}.signature`);
76
+ }
77
+ function message(value, path) {
78
+ const source = record(value, path);
79
+ enumeration('user', 'assistant', 'tool_result')(source.role, `${path}.role`);
80
+ if (source.role === 'assistant') {
81
+ shape({ content: array(contentBlock), usage: optional(usage),
82
+ stopReason: optional(enumeration('end_turn', 'tool_use', 'max_tokens', 'stop_sequence')),
83
+ })(source, path);
84
+ return;
85
+ }
86
+ if (source.role === 'tool_result') {
87
+ shape({ toolCallId: string, isError: optional(boolean) })(source, path);
88
+ }
89
+ else
90
+ optional(number)(source.timestamp, `${path}.timestamp`);
91
+ if (typeof source.content !== 'string')
92
+ array(contentPart)(source.content, `${path}.content`);
93
+ }
94
+ const patchLine = shape({
95
+ kind: enumeration('ctx', 'add', 'del'), text: string,
96
+ oldLine: optional(number), newLine: optional(number),
97
+ });
98
+ const patch = shape({
99
+ path: string, kind: enumeration('create', 'update'), added: number, removed: number,
100
+ truncated: boolean, lineCount: number,
101
+ degraded: optional(enumeration('binary', 'too-large', 'unreadable')),
102
+ hunks: array(shape({ oldStart: number, oldCount: number, newStart: number, newCount: number,
103
+ lines: array(patchLine) })),
104
+ });
105
+ const todo = shape({
106
+ content: string, activeForm: string, status: enumeration('pending', 'in_progress', 'completed'),
107
+ });
108
+ const subagent = shape({
109
+ label: string, description: string,
110
+ // Old team archives predate tier; renderers already treat absence as main.
111
+ tier: optional(enumeration('main', 'fast')),
112
+ phase: enumeration('queued', 'starting', 'thinking', 'tool', 'waiting', 'done', 'failed', 'aborted'),
113
+ startedAt: optional(number), endedAt: optional(number), turns: number, toolCalls: number,
114
+ lastTool: optional(string), usage, filesTouched: array(string), messagesSent: number,
115
+ summary: optional(string), error: optional(string), truncated: optional(boolean),
116
+ compactions: optional(number), activity: optional(string),
117
+ activityArgs: optional(shape({ path: optional(string), pattern: optional(string),
118
+ command: optional(string), to: optional(string), from: optional(string) })),
119
+ blockedWaits: optional(number), retries: optional(number), retryable: optional(boolean),
120
+ retry: optional(shape({ attempt: number, maxRetries: number })),
121
+ });
122
+ const ENTRY_FIELDS = {
123
+ user: { text: string },
124
+ queued: { text: string, queueId: optional(nonempty) },
125
+ notice: { text: string, level: enumeration('info', 'warn', 'error') },
126
+ assistant: {
127
+ text: string, thinking: optional(string), thinkingOpen: boolean, streaming: boolean,
128
+ aborted: optional(boolean), usage: optional(usage),
129
+ thinkingStartedAt: optional(number), thinkingMs: optional(number),
130
+ },
131
+ tool: {
132
+ toolCallId: string, name: string, label: string, argsRaw: string, args: optional(object),
133
+ status: enumeration('pending', 'running', 'done', 'error'), preview: optional(string),
134
+ durationMs: optional(number), isError: optional(boolean), patch: optional(patch),
135
+ live: optional(array(string)), liveSeq: optional(number), lastOutputAt: optional(number),
136
+ },
137
+ team: { dispatchId: string, requested: number, runs: array(subagent), aborted: boolean,
138
+ durationMs: optional(number), active: boolean },
139
+ todo: { items: array(todo), doneCount: number, total: number, live: boolean,
140
+ interrupted: optional(boolean) },
141
+ retry: {
142
+ attempt: number, maxRetries: number, errorType: string, message: string, delayMs: number,
143
+ resumeAt: optional(number), startedAt: number, totalRetries: optional(number),
144
+ elapsedMs: optional(number),
145
+ phase: enumeration('waiting', 'retrying', 'recovered', 'exhausted', 'interrupted'),
146
+ },
147
+ fast: { reviewIndex: number, model: string,
148
+ status: enumeration('running', 'ok', 'advice', 'empty', 'failed', 'dropped'),
149
+ text: optional(string), detail: optional(string), turn: number,
150
+ durationMs: optional(number), live: boolean },
151
+ compaction: {
152
+ index: number, trigger: enumeration('pressure', 'overflow', 'manual'),
153
+ mode: enumeration('summarized', 'truncated', 'relieved', 'none'), applied: boolean,
154
+ reason: optional(string), messagesBefore: number, messagesAfter: number,
155
+ tokensBefore: number, tokensAfter: number, summary: optional(string), model: string,
156
+ durationMs: optional(number), startedAt: optional(number), live: boolean,
157
+ tailRelief: optional(shape({ messages: number, charsRemoved: number })),
158
+ },
159
+ service: {
160
+ serviceId: string, command: string,
161
+ status: enumeration('starting', 'ready', 'running', 'exited', 'failed', 'stopped'),
162
+ url: optional(string), port: optional(number),
163
+ exitCode: (value, path) => { if (value !== null)
164
+ number(value, path); },
165
+ startedAt: number, readyAt: optional(number), endedAt: optional(number), rows: array(string),
166
+ rowsSeen: number, terminal: optional(boolean), killIncomplete: optional(boolean),
167
+ },
168
+ };
169
+ function validateEntries(value) {
170
+ const ids = new Set();
171
+ const queues = new Set();
172
+ array((item, path) => {
173
+ const entry = record(item, path);
174
+ nonempty(entry.id, `${path}.id`);
175
+ if (ids.has(entry.id))
176
+ fail(`${path}.id`, 'unique');
177
+ ids.add(entry.id);
178
+ enumeration(...Object.keys(ENTRY_FIELDS))(entry.kind, `${path}.kind`);
179
+ shape(ENTRY_FIELDS[entry.kind])(entry, path);
180
+ if (entry.kind !== 'queued' || entry.queueId === undefined)
181
+ return;
182
+ if (queues.has(entry.queueId))
183
+ fail(`${path}.queueId`, 'unique');
184
+ queues.add(entry.queueId);
185
+ })(value, 'entries');
186
+ }
187
+ function validateModel(value) {
188
+ const model = record(value, 'model');
189
+ shape({ providerId: nonempty, modelId: nonempty, baseUrl: optional(string) })(model, 'model');
190
+ if (typeof model.providerId !== 'string' || !isAdapterProvider(model.providerId)) {
191
+ fail('model.providerId', 'a supported adapter');
192
+ }
193
+ }
194
+ /** Return a prepared copy, preserving extensions; throw with the corrupt field path. */
195
+ export function validateSession(value) {
196
+ const source = record(value, 'session');
197
+ array(message)(source.messages, 'messages');
198
+ validateEntries(source.entries);
199
+ if (source.model !== undefined)
200
+ validateModel(source.model);
201
+ // Missing fields remain missing in old files, including exec metadata.
202
+ const prepared = { ...source };
203
+ if (source.todos !== undefined) {
204
+ prepared.todos = normalizeTodos(source.todos, TODO_LIMITS.maxItems).items;
205
+ }
206
+ return prepared;
207
+ }