@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.
- package/CHANGELOG.md +10 -1
- package/README.md +24 -13
- package/dist/agent/controller.d.ts +9 -2
- package/dist/agent/controller.js +29 -39
- package/dist/agent/queued-messages.d.ts +8 -0
- package/dist/agent/queued-messages.js +28 -0
- package/dist/agent/reducer.d.ts +8 -1
- package/dist/agent/reducer.js +23 -34
- package/dist/commands/builtins.js +21 -7
- package/dist/input/enter-sequences.d.ts +11 -10
- package/dist/input/enter-sequences.js +29 -27
- package/dist/input/stdin-filter.js +90 -26
- package/dist/logging/install.d.ts +3 -0
- package/dist/session/persist.d.ts +6 -7
- package/dist/session/persist.js +6 -9
- package/dist/session/store.d.ts +4 -1
- package/dist/session/validate-session.d.ts +3 -0
- package/dist/session/validate-session.js +207 -0
- package/dist/ui/App.js +55 -28
- package/dist/ui/Composer.d.ts +2 -1
- package/dist/ui/PromptInput.d.ts +2 -1
- package/dist/ui/PromptInput.js +50 -71
- package/dist/ui/QueueStatusRow.d.ts +22 -0
- package/dist/ui/QueueStatusRow.js +51 -0
- package/dist/ui/StatusBar.d.ts +2 -0
- package/dist/ui/StatusBar.js +30 -0
- package/dist/ui/composer-input.d.ts +28 -0
- package/dist/ui/composer-input.js +46 -0
- package/dist/ui/editor-reducer.d.ts +5 -0
- package/dist/ui/editor-reducer.js +2 -0
- package/dist/ui/enter-frames.d.ts +15 -31
- package/dist/ui/enter-frames.js +14 -33
- package/dist/ui/overlays/HelpOverlay.js +8 -0
- package/dist/ui/selection/selection-controller.js +57 -20
- package/dist/ui/transcript-text.js +20 -12
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
234
|
+
writeInput(text);
|
|
223
235
|
return;
|
|
224
236
|
}
|
|
225
237
|
const split = splitMouseEvents(text);
|
|
226
238
|
if (split.text.length > 0)
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
@@ -39,14 +39,13 @@ export interface SessionMeta {
|
|
|
39
39
|
export interface SavedSession {
|
|
40
40
|
version: number;
|
|
41
41
|
savedAt: number;
|
|
42
|
-
model
|
|
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,
|
|
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
|
|
60
|
-
*
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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
|
package/dist/session/persist.js
CHANGED
|
@@ -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
|
-
*
|
|
113
|
-
*
|
|
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
|
|
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: ${
|
|
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
|
}
|
package/dist/session/store.d.ts
CHANGED
|
@@ -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,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
|
+
}
|