@nifrajs/coding-agent 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +120 -100
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +9 -21
- package/dist/context.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +8 -21
- package/dist/diff.js.map +1 -1
- package/dist/handoffs.d.ts +5 -0
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +27 -0
- package/dist/handoffs.js.map +1 -1
- package/dist/host.d.ts +15 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +117 -13
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +22 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +75 -18
- package/dist/native.js.map +1 -1
- package/dist/orchestration/dispatch.d.ts +5 -1
- package/dist/orchestration/dispatch.d.ts.map +1 -1
- package/dist/orchestration/dispatch.js +1 -1
- package/dist/orchestration/dispatch.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +5 -6
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +14 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +26 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/orchestration/index.d.ts +1 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/recovery.d.ts +5 -1
- package/dist/orchestration/recovery.d.ts.map +1 -1
- package/dist/orchestration/recovery.js +5 -3
- package/dist/orchestration/recovery.js.map +1 -1
- package/dist/process.d.ts +22 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +63 -1
- package/dist/process.js.map +1 -1
- package/dist/rpc.d.ts +23 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +204 -46
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +30 -6
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +13 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +104 -24
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +13 -2
- package/dist/subagents.js.map +1 -1
- package/dist/turn-replay.d.ts +40 -0
- package/dist/turn-replay.d.ts.map +1 -0
- package/dist/turn-replay.js +136 -0
- package/dist/turn-replay.js.map +1 -0
- package/dist/ui.js +84 -30
- package/dist/ui.js.map +1 -1
- package/dist/verification.d.ts +7 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +66 -63
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +5 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +2 -1
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -6
- package/src/cli.ts +125 -93
- package/src/context.ts +12 -20
- package/src/diff.ts +11 -23
- package/src/handoffs.ts +27 -0
- package/src/host.ts +134 -12
- package/src/index.ts +1 -0
- package/src/native.ts +118 -21
- package/src/orchestration/dispatch.ts +6 -2
- package/src/orchestration/evidence-store.ts +5 -6
- package/src/orchestration/host.ts +44 -0
- package/src/orchestration/index.ts +1 -0
- package/src/orchestration/recovery.ts +14 -8
- package/src/process.ts +77 -0
- package/src/rpc.ts +261 -49
- package/src/session-migration.ts +29 -5
- package/src/sessions.ts +115 -29
- package/src/subagents.ts +11 -2
- package/src/turn-replay.ts +151 -0
- package/src/ui.ts +90 -32
- package/src/verification.ts +74 -60
- package/src/workflows.ts +7 -1
package/src/sessions.ts
CHANGED
|
@@ -57,6 +57,9 @@ export class FileSessionStore implements SessionStore {
|
|
|
57
57
|
private readonly maxEntries: number
|
|
58
58
|
private readonly maxBytes: number
|
|
59
59
|
private readonly sequences = new Map<string, number>()
|
|
60
|
+
// Entries and bytes in each session file this store has written, so an append under the caps
|
|
61
|
+
// needs no read of the log to know it.
|
|
62
|
+
private readonly tallies = new Map<string, { entries: number; bytes: number }>()
|
|
60
63
|
private readonly locks = new Map<string, Promise<void>>()
|
|
61
64
|
|
|
62
65
|
constructor(options: FileSessionStoreOptions) {
|
|
@@ -109,14 +112,21 @@ export class FileSessionStore implements SessionStore {
|
|
|
109
112
|
if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
|
|
110
113
|
throw new RangeError("session store: event exceeds maxEntryBytes")
|
|
111
114
|
const path = this.pathFor(sessionId)
|
|
115
|
+
const tally = await this.tallyOf(sessionId)
|
|
112
116
|
const file = await openSessionFile(path, O_APPEND | O_CREAT | O_WRONLY, 0o600)
|
|
113
117
|
try {
|
|
114
118
|
await file.writeFile(`${line}\n`, "utf8")
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.tallies.delete(sessionId)
|
|
121
|
+
throw error
|
|
115
122
|
} finally {
|
|
116
123
|
await file.close()
|
|
117
124
|
}
|
|
125
|
+
tally.entries += 1
|
|
126
|
+
tally.bytes += Buffer.byteLength(line, "utf8") + 1
|
|
118
127
|
this.sequences.set(sessionId, nextSeq)
|
|
119
|
-
|
|
128
|
+
if (tally.entries > this.maxEntries || tally.bytes > this.maxBytes)
|
|
129
|
+
await this.compact(sessionId, tally)
|
|
120
130
|
return entry
|
|
121
131
|
})
|
|
122
132
|
}
|
|
@@ -173,6 +183,7 @@ export class FileSessionStore implements SessionStore {
|
|
|
173
183
|
mode: 0o600,
|
|
174
184
|
})
|
|
175
185
|
this.sequences.set(targetSessionId, Math.max(0, lines.length - 1))
|
|
186
|
+
this.tallies.delete(targetSessionId)
|
|
176
187
|
return targetSessionId
|
|
177
188
|
})
|
|
178
189
|
}
|
|
@@ -188,40 +199,63 @@ export class FileSessionStore implements SessionStore {
|
|
|
188
199
|
return (entries.at(-1)?.seq ?? -1) + 1
|
|
189
200
|
}
|
|
190
201
|
|
|
191
|
-
|
|
202
|
+
/** The session file's tally, read once: its size, and how many entries its bounded tail holds. */
|
|
203
|
+
private async tallyOf(sessionId: string): Promise<{ entries: number; bytes: number }> {
|
|
204
|
+
const cached = this.tallies.get(sessionId)
|
|
205
|
+
if (cached !== undefined) return cached
|
|
206
|
+
const tally = { entries: 0, bytes: 0 }
|
|
192
207
|
const path = this.pathFor(sessionId)
|
|
193
|
-
const file = await openSessionFile(path, O_RDONLY)
|
|
194
|
-
let size: number
|
|
195
208
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
209
|
+
const file = await openSessionFile(path, O_RDONLY)
|
|
210
|
+
try {
|
|
211
|
+
tally.bytes = (await file.stat()).size
|
|
212
|
+
} finally {
|
|
213
|
+
await file.close()
|
|
214
|
+
}
|
|
215
|
+
tally.entries = parseSessionEntries(
|
|
216
|
+
await this.readTailText(path, this.maxEntries + 1, this.maxBytes + this.maxEntryBytes),
|
|
203
217
|
sessionId,
|
|
204
|
-
)
|
|
205
|
-
|
|
218
|
+
).length
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!isNotFound(error)) throw error
|
|
206
221
|
}
|
|
222
|
+
this.tallies.set(sessionId, tally)
|
|
223
|
+
return tally
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Rewrite the log down to a low-water mark below both caps, so the next rewrite is a quarter of the
|
|
228
|
+
* caps of appends away rather than the very next append.
|
|
229
|
+
*/
|
|
230
|
+
private async compact(
|
|
231
|
+
sessionId: string,
|
|
232
|
+
tally: { entries: number; bytes: number },
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
const path = this.pathFor(sessionId)
|
|
207
235
|
// The tail read is bounded by the retention cap plus one maximum-sized line. This keeps
|
|
208
236
|
// compaction memory bounded even if a pre-existing log is already oversized.
|
|
209
237
|
const recent = parseSessionEntries(
|
|
210
238
|
await this.readTailText(path, this.maxEntries + 1, this.maxBytes + this.maxEntryBytes),
|
|
211
239
|
sessionId,
|
|
212
240
|
)
|
|
213
|
-
const retained = retainEntries(
|
|
241
|
+
const retained = retainEntries(
|
|
242
|
+
recent,
|
|
243
|
+
Math.max(1, Math.floor((this.maxEntries * 3) / 4)),
|
|
244
|
+
Math.max(Math.floor((this.maxBytes * 3) / 4), this.maxEntryBytes + 1),
|
|
245
|
+
)
|
|
214
246
|
if (retained.length === 0) return
|
|
215
247
|
const content = `${retained.map((entry) => JSON.stringify(entry)).join("\n")}\n`
|
|
216
|
-
if (Buffer.byteLength(content, "utf8") >= size && size <= this.maxBytes) return
|
|
217
248
|
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
218
249
|
await writeFile(temporary, content, { encoding: "utf8", flag: "wx", mode: 0o600 })
|
|
219
250
|
try {
|
|
220
251
|
await rename(temporary, path)
|
|
221
252
|
} catch (error) {
|
|
222
253
|
await unlink(temporary).catch(() => {})
|
|
254
|
+
this.tallies.delete(sessionId)
|
|
223
255
|
throw error
|
|
224
256
|
}
|
|
257
|
+
tally.entries = retained.length
|
|
258
|
+
tally.bytes = Buffer.byteLength(content, "utf8")
|
|
225
259
|
this.sequences.set(sessionId, retained.at(-1)!.seq)
|
|
226
260
|
}
|
|
227
261
|
|
|
@@ -237,17 +271,19 @@ export class FileSessionStore implements SessionStore {
|
|
|
237
271
|
let remaining = Math.min(size, maxBytes)
|
|
238
272
|
const chunks: Buffer[] = []
|
|
239
273
|
const chunkBytes = 64 * 1024
|
|
274
|
+
let newlines = 0
|
|
240
275
|
while (remaining > 0) {
|
|
241
276
|
const take = Math.min(remaining, chunkBytes)
|
|
242
277
|
end -= take
|
|
243
278
|
const bytes = Buffer.allocUnsafe(take)
|
|
244
279
|
const result = await file.read(bytes, 0, take, end)
|
|
245
|
-
|
|
280
|
+
const chunk = bytes.subarray(0, result.bytesRead)
|
|
281
|
+
chunks.unshift(chunk)
|
|
246
282
|
remaining -= result.bytesRead
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const completeLines = end > 0 ?
|
|
250
|
-
if (completeLines >= minimumLines || end === 0 || result.bytesRead === 0)
|
|
283
|
+
for (let at = chunk.indexOf(10); at !== -1; at = chunk.indexOf(10, at + 1)) newlines++
|
|
284
|
+
// Before the start of the file the first line may be partial, so it does not count.
|
|
285
|
+
const completeLines = end > 0 ? newlines : newlines + 1
|
|
286
|
+
if (completeLines >= minimumLines || end === 0 || result.bytesRead === 0) break
|
|
251
287
|
}
|
|
252
288
|
return Buffer.concat(chunks).toString("utf8")
|
|
253
289
|
} finally {
|
|
@@ -309,7 +345,11 @@ export interface ContextWindowOptions {
|
|
|
309
345
|
readonly summarize?: (records: readonly ContextRecord[], maxChars: number) => string
|
|
310
346
|
}
|
|
311
347
|
|
|
312
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* In-memory prompt window with automatic, deterministic compaction and a hard size ceiling. Pinned
|
|
350
|
+
* and authoritative records survive compaction, newest first, within half of `maxTokens`; older ones
|
|
351
|
+
* are folded into the summary like any other record. The session store keeps every one of them.
|
|
352
|
+
*/
|
|
313
353
|
export class ContextWindow {
|
|
314
354
|
private readonly maxTokens: number
|
|
315
355
|
private readonly keepRecent: number
|
|
@@ -361,19 +401,21 @@ export class ContextWindow {
|
|
|
361
401
|
if (this.records.length <= this.keepRecent) return { before, after: before, removed: 0, reason }
|
|
362
402
|
// A compacted summary is replaceable context, not authoritative evidence. Keeping old pinned
|
|
363
403
|
// summaries in `protectedRecords` would make every threshold compaction grow the live window.
|
|
364
|
-
const protectedRecords =
|
|
365
|
-
(
|
|
366
|
-
record
|
|
367
|
-
|
|
404
|
+
const protectedRecords = newestWithin(
|
|
405
|
+
this.records.filter(
|
|
406
|
+
(record) =>
|
|
407
|
+
record.kind !== "memory.summary" &&
|
|
408
|
+
(record.pinned === true || isAuthoritative(record.kind)),
|
|
409
|
+
),
|
|
410
|
+
Math.floor(this.maxTokens / 2),
|
|
368
411
|
)
|
|
369
412
|
const recent = this.records.slice(-this.keepRecent)
|
|
370
413
|
const retained = new Set([...protectedRecords, ...recent])
|
|
371
414
|
const omitted = this.records.filter((record) => !retained.has(record))
|
|
372
415
|
if (omitted.length === 0) return { before, after: before, removed: 0, reason }
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
)
|
|
416
|
+
// A summary longer than the window it lives in would defeat the window.
|
|
417
|
+
const summaryChars = Math.min(this.maxSummaryChars, this.maxTokens)
|
|
418
|
+
const summary = this.summarizeRecord(omitted, summaryChars).slice(0, summaryChars)
|
|
377
419
|
this.records = [
|
|
378
420
|
...protectedRecords,
|
|
379
421
|
{ kind: "memory.summary", content: summary, pinned: true },
|
|
@@ -456,6 +498,33 @@ function isSessionLogEntry(value: unknown, sessionId: string): value is SessionL
|
|
|
456
498
|
)
|
|
457
499
|
}
|
|
458
500
|
|
|
501
|
+
/**
|
|
502
|
+
* A row `migrateLegacySession` wrote: the same identity and order, with the event type as `code` and
|
|
503
|
+
* no payload. Read as an entry of that type, so a host pointed at a migrated directory keeps the
|
|
504
|
+
* history and continues its sequence.
|
|
505
|
+
*/
|
|
506
|
+
function evidenceEntry(value: unknown, sessionId: string): SessionLogEntry | undefined {
|
|
507
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
|
|
508
|
+
const field = (key: string): unknown => Reflect.get(value, key)
|
|
509
|
+
const seq = field("seq")
|
|
510
|
+
const at = field("at")
|
|
511
|
+
const code = field("code")
|
|
512
|
+
if (
|
|
513
|
+
field("version") !== 1 ||
|
|
514
|
+
field("sessionId") !== sessionId ||
|
|
515
|
+
typeof seq !== "number" ||
|
|
516
|
+
!Number.isSafeInteger(seq) ||
|
|
517
|
+
typeof at !== "number" ||
|
|
518
|
+
typeof code !== "string" ||
|
|
519
|
+
field("count") !== 1 ||
|
|
520
|
+
typeof field("digest") !== "string" ||
|
|
521
|
+
"type" in value
|
|
522
|
+
)
|
|
523
|
+
return undefined
|
|
524
|
+
const entry = { version: 1, sessionId, seq, at, type: code } as const
|
|
525
|
+
return field("pinned") === true ? { ...entry, pinned: true } : entry
|
|
526
|
+
}
|
|
527
|
+
|
|
459
528
|
function parseSessionEntries(text: string, sessionId: string): SessionLogEntry[] {
|
|
460
529
|
const entries: SessionLogEntry[] = []
|
|
461
530
|
for (const line of text.split("\n")) {
|
|
@@ -463,6 +532,10 @@ function parseSessionEntries(text: string, sessionId: string): SessionLogEntry[]
|
|
|
463
532
|
try {
|
|
464
533
|
const value: unknown = JSON.parse(line)
|
|
465
534
|
if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
|
|
535
|
+
else {
|
|
536
|
+
const migrated = evidenceEntry(value, sessionId)
|
|
537
|
+
if (migrated !== undefined) entries.push(Object.freeze(migrated))
|
|
538
|
+
}
|
|
466
539
|
} catch {
|
|
467
540
|
// A tail read can begin in the middle of an old line. Invalid records are ignored so a
|
|
468
541
|
// damaged/partially rotated log cannot turn every history request into an unbounded retry.
|
|
@@ -517,6 +590,19 @@ function estimateTokens(records: readonly ContextRecord[]): number {
|
|
|
517
590
|
)
|
|
518
591
|
}
|
|
519
592
|
|
|
593
|
+
/** The newest `records` whose estimated tokens fit `budget`, in their original order; at least one. */
|
|
594
|
+
function newestWithin(records: readonly ContextRecord[], budget: number): ContextRecord[] {
|
|
595
|
+
const kept: ContextRecord[] = []
|
|
596
|
+
let tokens = 0
|
|
597
|
+
for (let index = records.length - 1; index >= 0; index--) {
|
|
598
|
+
const record = records[index]!
|
|
599
|
+
tokens += estimateTokens([record])
|
|
600
|
+
if (tokens > budget && kept.length > 0) break
|
|
601
|
+
kept.unshift(record)
|
|
602
|
+
}
|
|
603
|
+
return kept
|
|
604
|
+
}
|
|
605
|
+
|
|
520
606
|
function isAuthoritative(kind: string): boolean {
|
|
521
607
|
return (
|
|
522
608
|
kind.startsWith("approval") || kind.startsWith("verification") || kind === "session.checkpoint"
|
package/src/subagents.ts
CHANGED
|
@@ -214,8 +214,17 @@ export class BoundedSubagentRunner {
|
|
|
214
214
|
error: "subagent workspace is not in an allowed root",
|
|
215
215
|
}
|
|
216
216
|
}
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
const isolatedWorktree = this.options.workspace?.isolatedWorktree
|
|
218
|
+
if (isolatedWorktree !== undefined) {
|
|
219
|
+
// The deadline covers acquiring the workspace too. A lease that lands after it is released
|
|
220
|
+
// without any work starting in it.
|
|
221
|
+
const acquiring = Promise.resolve().then(() => isolatedWorktree(spec, controller.signal))
|
|
222
|
+
try {
|
|
223
|
+
workspace = await untilAborted(acquiring, controller.signal, stopped)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (controller.signal.aborted) acquiring.then((late) => late.cleanup?.()).catch(() => {})
|
|
226
|
+
throw error
|
|
227
|
+
}
|
|
219
228
|
} else if (requestedCwd !== undefined) {
|
|
220
229
|
workspace = { cwd: requestedCwd }
|
|
221
230
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export interface TurnReplayLimits {
|
|
2
|
+
readonly maxEvents: number
|
|
3
|
+
readonly maxBytes: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface HeldFrame {
|
|
7
|
+
readonly seq: number
|
|
8
|
+
readonly bytes: Uint8Array
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface TurnListener {
|
|
12
|
+
frame(bytes: Uint8Array): void
|
|
13
|
+
end(terminal: Uint8Array | undefined): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One `turn.send`'s SSE frames, held so a client that dropped can read what it missed and follow
|
|
18
|
+
* the rest. Cursors are the host's event seqs; one older than what is held needs a resync.
|
|
19
|
+
*/
|
|
20
|
+
export class ReplayableTurn {
|
|
21
|
+
private readonly held: HeldFrame[] = []
|
|
22
|
+
private heldBytes = 0
|
|
23
|
+
private droppedThrough: number
|
|
24
|
+
private lastSeq: number
|
|
25
|
+
private ended = false
|
|
26
|
+
private terminal: Uint8Array | undefined
|
|
27
|
+
private readonly listeners = new Set<TurnListener>()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param startedAfter the last seq before this turn: a cursor there reads the whole turn.
|
|
31
|
+
* @param onListeners told how many clients follow the turn whenever that changes while it runs.
|
|
32
|
+
*/
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly startedAfter: number,
|
|
35
|
+
private readonly limits: TurnReplayLimits,
|
|
36
|
+
private readonly onListeners: (count: number) => void,
|
|
37
|
+
) {
|
|
38
|
+
this.droppedThrough = startedAfter
|
|
39
|
+
this.lastSeq = startedAfter
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get done(): boolean {
|
|
43
|
+
return this.ended
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get latestSeq(): number {
|
|
47
|
+
return this.lastSeq
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get following(): number {
|
|
51
|
+
return this.listeners.size
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
push(seq: number, frame: Uint8Array): void {
|
|
55
|
+
if (this.ended) return
|
|
56
|
+
this.held.push({ seq, bytes: frame })
|
|
57
|
+
this.heldBytes += frame.byteLength
|
|
58
|
+
this.lastSeq = seq
|
|
59
|
+
// The newest frame always stays, even past maxBytes, so a client can follow a large event.
|
|
60
|
+
while (
|
|
61
|
+
this.held.length > this.limits.maxEvents ||
|
|
62
|
+
(this.heldBytes > this.limits.maxBytes && this.held.length > 1)
|
|
63
|
+
) {
|
|
64
|
+
const dropped = this.held.shift()
|
|
65
|
+
if (dropped === undefined) break
|
|
66
|
+
this.heldBytes -= dropped.bytes.byteLength
|
|
67
|
+
this.droppedThrough = dropped.seq
|
|
68
|
+
}
|
|
69
|
+
for (const listener of [...this.listeners]) {
|
|
70
|
+
try {
|
|
71
|
+
listener.frame(frame)
|
|
72
|
+
} catch {
|
|
73
|
+
this.detach(listener)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
end(terminal?: Uint8Array): void {
|
|
79
|
+
if (this.ended) return
|
|
80
|
+
this.ended = true
|
|
81
|
+
this.terminal = terminal
|
|
82
|
+
const listeners = [...this.listeners]
|
|
83
|
+
this.listeners.clear()
|
|
84
|
+
for (const listener of listeners) {
|
|
85
|
+
try {
|
|
86
|
+
listener.end(terminal)
|
|
87
|
+
} catch {
|
|
88
|
+
// That client already went away.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The frames after `afterSeq` (the whole turn when omitted), then the live ones until the turn
|
|
95
|
+
* ends. `undefined` when the cursor is older than what is held, or newer than any frame sent.
|
|
96
|
+
*/
|
|
97
|
+
attach(afterSeq?: number): ReadableStream<Uint8Array> | undefined {
|
|
98
|
+
const cursor = afterSeq ?? this.startedAfter
|
|
99
|
+
if (cursor < this.droppedThrough || cursor > this.lastSeq) return undefined
|
|
100
|
+
return this.open(cursor)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The whole turn from its first frame; always available to the client that started it. */
|
|
104
|
+
follow(): ReadableStream<Uint8Array> {
|
|
105
|
+
return this.open(this.startedAfter)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private open(cursor: number): ReadableStream<Uint8Array> {
|
|
109
|
+
let listener: TurnListener | undefined
|
|
110
|
+
// The queue counts bytes, so it can tell a follower that stopped reading: one more than the held
|
|
111
|
+
// budget behind the held frames is dropped, and can attach again from the last seq it read.
|
|
112
|
+
const behind = -this.limits.maxBytes
|
|
113
|
+
return new ReadableStream<Uint8Array>(
|
|
114
|
+
{
|
|
115
|
+
start: (controller) => {
|
|
116
|
+
for (const frame of this.held) if (frame.seq > cursor) controller.enqueue(frame.bytes)
|
|
117
|
+
if (this.ended) {
|
|
118
|
+
if (this.terminal !== undefined) controller.enqueue(this.terminal)
|
|
119
|
+
controller.close()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
listener = {
|
|
123
|
+
frame: (bytes) => {
|
|
124
|
+
if ((controller.desiredSize ?? 0) < behind) {
|
|
125
|
+
const reason = new Error("turn follower fell behind")
|
|
126
|
+
controller.error(reason)
|
|
127
|
+
throw reason
|
|
128
|
+
}
|
|
129
|
+
controller.enqueue(bytes)
|
|
130
|
+
},
|
|
131
|
+
end: (terminal) => {
|
|
132
|
+
if (terminal !== undefined) controller.enqueue(terminal)
|
|
133
|
+
controller.close()
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
this.listeners.add(listener)
|
|
137
|
+
this.onListeners(this.listeners.size)
|
|
138
|
+
},
|
|
139
|
+
cancel: () => {
|
|
140
|
+
if (listener !== undefined) this.detach(listener)
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
new ByteLengthQueuingStrategy({ highWaterMark: this.limits.maxBytes }),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private detach(listener: TurnListener): void {
|
|
148
|
+
if (!this.listeners.delete(listener) || this.ended) return
|
|
149
|
+
this.onListeners(this.listeners.size)
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -91,47 +91,105 @@ export class UiExtensionHost {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
const UI_SLOTS: ReadonlySet<unknown> = new Set([
|
|
95
|
+
"sidebar",
|
|
96
|
+
"main",
|
|
97
|
+
"timeline",
|
|
98
|
+
"diff",
|
|
99
|
+
"workflow",
|
|
100
|
+
"status",
|
|
101
|
+
])
|
|
102
|
+
const UI_TONES: ReadonlySet<unknown> = new Set(["neutral", "positive", "warning", "danger"])
|
|
103
|
+
const UI_DENSITIES: ReadonlySet<unknown> = new Set(["compact", "comfortable"])
|
|
104
|
+
|
|
105
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] }
|
|
106
|
+
|
|
107
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
108
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
109
|
+
const matches = (pattern: RegExp, value: unknown): value is string =>
|
|
110
|
+
typeof value === "string" && pattern.test(value)
|
|
111
|
+
const isSlot = (value: unknown): value is UiExtensionSlot => UI_SLOTS.has(value)
|
|
112
|
+
const isTone = (value: unknown): value is NonNullable<UiStatusWidget["tone"]> => UI_TONES.has(value)
|
|
113
|
+
const isDensity = (value: unknown): value is NonNullable<UiThemeDescriptor["density"]> =>
|
|
114
|
+
UI_DENSITIES.has(value)
|
|
115
|
+
|
|
116
|
+
// Manifests come from extension code, often parsed JSON: every field is type-checked, and only the
|
|
117
|
+
// known fields are copied into the frozen result.
|
|
94
118
|
function validateManifests(
|
|
95
|
-
manifests:
|
|
119
|
+
manifests: unknown,
|
|
96
120
|
options: UiExtensionHostOptions & { readonly maxExtensions: number },
|
|
97
121
|
): readonly UiExtensionManifest[] {
|
|
122
|
+
if (!Array.isArray(manifests)) throw new Error("UI extension manifests must be an array")
|
|
98
123
|
if (manifests.length > options.maxExtensions) throw new Error("ui extension limit exceeded")
|
|
99
124
|
const trusted = new Set(options.trustedCapabilities ?? [])
|
|
100
125
|
const ids = new Set<string>()
|
|
101
126
|
return Object.freeze(
|
|
102
|
-
manifests.map((manifest) => {
|
|
103
|
-
if (
|
|
104
|
-
throw new Error(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!
|
|
108
|
-
throw new Error(`invalid UI extension
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
manifests.map((manifest: unknown): UiExtensionManifest => {
|
|
128
|
+
if (!isRecord(manifest) || !matches(/^[a-z][a-z0-9._:-]{0,63}$/, manifest.id))
|
|
129
|
+
throw new Error("invalid UI extension id")
|
|
130
|
+
const id = manifest.id
|
|
131
|
+
const revision = manifest.revision
|
|
132
|
+
if (!matches(/^[a-zA-Z0-9._:-]{1,64}$/, revision))
|
|
133
|
+
throw new Error(`invalid UI extension revision: ${id}`)
|
|
134
|
+
const slot = manifest.slot
|
|
135
|
+
if (!isSlot(slot)) throw new Error(`invalid UI extension slot: ${id}`)
|
|
136
|
+
const label = manifest.label
|
|
137
|
+
if (typeof label !== "string" || label.length === 0 || label.length > 120)
|
|
138
|
+
throw new Error(`invalid UI extension label: ${id}`)
|
|
139
|
+
const capabilities = manifest.capabilities ?? []
|
|
140
|
+
if (
|
|
141
|
+
!Array.isArray(capabilities) ||
|
|
142
|
+
!capabilities.every((capability): capability is string => typeof capability === "string")
|
|
143
|
+
)
|
|
144
|
+
throw new Error(`invalid UI extension capabilities: ${id}`)
|
|
145
|
+
const result: Mutable<UiExtensionManifest> = {
|
|
146
|
+
id,
|
|
147
|
+
revision,
|
|
148
|
+
slot,
|
|
149
|
+
label,
|
|
150
|
+
capabilities: Object.freeze([...capabilities]),
|
|
114
151
|
}
|
|
115
|
-
if (manifest.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
!["neutral", "positive", "warning", "danger"].includes(manifest.status.tone)
|
|
121
|
-
)
|
|
122
|
-
throw new Error(`invalid UI status tone: ${manifest.id}`)
|
|
123
|
-
}
|
|
124
|
-
if (ids.has(manifest.id)) throw new Error(`duplicate UI extension: ${manifest.id}`)
|
|
125
|
-
ids.add(manifest.id)
|
|
126
|
-
const denied = (manifest.capabilities ?? []).find((capability) => !trusted.has(capability))
|
|
152
|
+
if (manifest.theme !== undefined) result.theme = uiTheme(manifest.theme, id)
|
|
153
|
+
if (manifest.status !== undefined) result.status = uiStatus(manifest.status, id)
|
|
154
|
+
if (ids.has(id)) throw new Error(`duplicate UI extension: ${id}`)
|
|
155
|
+
ids.add(id)
|
|
156
|
+
const denied = capabilities.find((capability) => !trusted.has(capability))
|
|
127
157
|
if (denied !== undefined)
|
|
128
|
-
throw new Error(`UI extension ${
|
|
129
|
-
return Object.freeze(
|
|
130
|
-
...manifest,
|
|
131
|
-
capabilities: Object.freeze([...(manifest.capabilities ?? [])]),
|
|
132
|
-
...(manifest.theme === undefined ? {} : { theme: Object.freeze({ ...manifest.theme }) }),
|
|
133
|
-
...(manifest.status === undefined ? {} : { status: Object.freeze({ ...manifest.status }) }),
|
|
134
|
-
})
|
|
158
|
+
throw new Error(`UI extension ${id} requires untrusted capability: ${denied}`)
|
|
159
|
+
return Object.freeze(result)
|
|
135
160
|
}),
|
|
136
161
|
)
|
|
137
162
|
}
|
|
163
|
+
|
|
164
|
+
function uiTheme(value: unknown, id: string): UiThemeDescriptor {
|
|
165
|
+
if (!isRecord(value) || !matches(/^[a-z][a-z0-9._-]{0,31}$/, value.name))
|
|
166
|
+
throw new Error(`invalid UI theme: ${id}`)
|
|
167
|
+
const theme: Mutable<UiThemeDescriptor> = { name: value.name }
|
|
168
|
+
const { accent, density } = value
|
|
169
|
+
if (accent !== undefined) {
|
|
170
|
+
if (!matches(/^#[0-9a-fA-F]{6}$/, accent)) throw new Error(`invalid UI theme accent: ${id}`)
|
|
171
|
+
theme.accent = accent
|
|
172
|
+
}
|
|
173
|
+
if (density !== undefined) {
|
|
174
|
+
if (!isDensity(density)) throw new Error(`invalid UI theme density: ${id}`)
|
|
175
|
+
theme.density = density
|
|
176
|
+
}
|
|
177
|
+
return Object.freeze(theme)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function uiStatus(value: unknown, id: string): UiStatusWidget {
|
|
181
|
+
if (
|
|
182
|
+
!isRecord(value) ||
|
|
183
|
+
typeof value.text !== "string" ||
|
|
184
|
+
value.text.length === 0 ||
|
|
185
|
+
value.text.length > 160
|
|
186
|
+
)
|
|
187
|
+
throw new Error(`invalid UI status widget: ${id}`)
|
|
188
|
+
const status: Mutable<UiStatusWidget> = { text: value.text }
|
|
189
|
+
const tone = value.tone
|
|
190
|
+
if (tone !== undefined) {
|
|
191
|
+
if (!isTone(tone)) throw new Error(`invalid UI status tone: ${id}`)
|
|
192
|
+
status.tone = tone
|
|
193
|
+
}
|
|
194
|
+
return Object.freeze(status)
|
|
195
|
+
}
|