@nifrajs/coding-agent 4.0.2 → 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.
Files changed (99) hide show
  1. package/README.md +14 -2
  2. package/dist/cli.d.ts +13 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +120 -100
  5. package/dist/cli.js.map +1 -1
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +9 -21
  8. package/dist/context.js.map +1 -1
  9. package/dist/diff.d.ts.map +1 -1
  10. package/dist/diff.js +8 -21
  11. package/dist/diff.js.map +1 -1
  12. package/dist/handoffs.d.ts +5 -0
  13. package/dist/handoffs.d.ts.map +1 -1
  14. package/dist/handoffs.js +27 -0
  15. package/dist/handoffs.js.map +1 -1
  16. package/dist/host.d.ts +15 -1
  17. package/dist/host.d.ts.map +1 -1
  18. package/dist/host.js +117 -13
  19. package/dist/host.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/native.d.ts +22 -0
  25. package/dist/native.d.ts.map +1 -1
  26. package/dist/native.js +75 -18
  27. package/dist/native.js.map +1 -1
  28. package/dist/orchestration/dispatch.d.ts +5 -1
  29. package/dist/orchestration/dispatch.d.ts.map +1 -1
  30. package/dist/orchestration/dispatch.js +1 -1
  31. package/dist/orchestration/dispatch.js.map +1 -1
  32. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  33. package/dist/orchestration/evidence-store.js +5 -6
  34. package/dist/orchestration/evidence-store.js.map +1 -1
  35. package/dist/orchestration/host.d.ts +14 -0
  36. package/dist/orchestration/host.d.ts.map +1 -1
  37. package/dist/orchestration/host.js +26 -0
  38. package/dist/orchestration/host.js.map +1 -1
  39. package/dist/orchestration/index.d.ts +1 -1
  40. package/dist/orchestration/index.d.ts.map +1 -1
  41. package/dist/orchestration/index.js.map +1 -1
  42. package/dist/orchestration/recovery.d.ts +5 -1
  43. package/dist/orchestration/recovery.d.ts.map +1 -1
  44. package/dist/orchestration/recovery.js +5 -3
  45. package/dist/orchestration/recovery.js.map +1 -1
  46. package/dist/process.d.ts +22 -1
  47. package/dist/process.d.ts.map +1 -1
  48. package/dist/process.js +63 -1
  49. package/dist/process.js.map +1 -1
  50. package/dist/rpc.d.ts +23 -0
  51. package/dist/rpc.d.ts.map +1 -1
  52. package/dist/rpc.js +204 -46
  53. package/dist/rpc.js.map +1 -1
  54. package/dist/session-migration.d.ts.map +1 -1
  55. package/dist/session-migration.js +30 -6
  56. package/dist/session-migration.js.map +1 -1
  57. package/dist/sessions.d.ts +13 -2
  58. package/dist/sessions.d.ts.map +1 -1
  59. package/dist/sessions.js +104 -24
  60. package/dist/sessions.js.map +1 -1
  61. package/dist/subagents.d.ts.map +1 -1
  62. package/dist/subagents.js +13 -2
  63. package/dist/subagents.js.map +1 -1
  64. package/dist/turn-replay.d.ts +40 -0
  65. package/dist/turn-replay.d.ts.map +1 -0
  66. package/dist/turn-replay.js +136 -0
  67. package/dist/turn-replay.js.map +1 -0
  68. package/dist/ui.js +84 -30
  69. package/dist/ui.js.map +1 -1
  70. package/dist/verification.d.ts +7 -0
  71. package/dist/verification.d.ts.map +1 -1
  72. package/dist/verification.js +66 -63
  73. package/dist/verification.js.map +1 -1
  74. package/dist/workflows.d.ts +5 -0
  75. package/dist/workflows.d.ts.map +1 -1
  76. package/dist/workflows.js +2 -1
  77. package/dist/workflows.js.map +1 -1
  78. package/package.json +6 -6
  79. package/src/cli.ts +125 -93
  80. package/src/context.ts +12 -20
  81. package/src/diff.ts +11 -23
  82. package/src/handoffs.ts +27 -0
  83. package/src/host.ts +134 -12
  84. package/src/index.ts +1 -0
  85. package/src/native.ts +118 -21
  86. package/src/orchestration/dispatch.ts +6 -2
  87. package/src/orchestration/evidence-store.ts +5 -6
  88. package/src/orchestration/host.ts +44 -0
  89. package/src/orchestration/index.ts +1 -0
  90. package/src/orchestration/recovery.ts +14 -8
  91. package/src/process.ts +77 -0
  92. package/src/rpc.ts +261 -49
  93. package/src/session-migration.ts +29 -5
  94. package/src/sessions.ts +115 -29
  95. package/src/subagents.ts +11 -2
  96. package/src/turn-replay.ts +151 -0
  97. package/src/ui.ts +90 -32
  98. package/src/verification.ts +74 -60
  99. 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
- await this.compactIfNeeded(sessionId)
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
- private async compactIfNeeded(sessionId: string): Promise<void> {
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
- size = (await file.stat()).size
197
- } finally {
198
- await file.close()
199
- }
200
- if (size <= this.maxBytes) {
201
- const recent = parseSessionEntries(
202
- await this.readTailText(path, this.maxEntries + 1, this.maxBytes),
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
- if (recent.length <= this.maxEntries) return
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(recent, this.maxEntries, this.maxBytes)
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
- chunks.unshift(bytes.subarray(0, result.bytesRead))
280
+ const chunk = bytes.subarray(0, result.bytesRead)
281
+ chunks.unshift(chunk)
246
282
  remaining -= result.bytesRead
247
- const text = Buffer.concat(chunks).toString("utf8")
248
- const lines = text.split("\n")
249
- const completeLines = end > 0 ? lines.length - 1 : lines.length
250
- if (completeLines >= minimumLines || end === 0 || result.bytesRead === 0) return text
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
- /** In-memory prompt window with automatic, deterministic compaction and a hard size ceiling. */
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 = this.records.filter(
365
- (record) =>
366
- record.kind !== "memory.summary" &&
367
- (record.pinned === true || isAuthoritative(record.kind)),
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
- const summary = this.summarizeRecord(omitted, this.maxSummaryChars).slice(
374
- 0,
375
- this.maxSummaryChars,
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
- if (this.options.workspace?.isolatedWorktree !== undefined) {
218
- workspace = await this.options.workspace.isolatedWorktree(spec, controller.signal)
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: readonly UiExtensionManifest[],
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 (!/^[a-z][a-z0-9._:-]{0,63}$/.test(manifest.id))
104
- throw new Error(`invalid UI extension id: ${manifest.id}`)
105
- if (!/^[a-zA-Z0-9._:-]{1,64}$/.test(manifest.revision))
106
- throw new Error(`invalid UI extension revision: ${manifest.id}`)
107
- if (!manifest.label || manifest.label.length > 120)
108
- throw new Error(`invalid UI extension label: ${manifest.id}`)
109
- if (manifest.theme !== undefined) {
110
- if (!/^[a-z][a-z0-9._-]{0,31}$/.test(manifest.theme.name))
111
- throw new Error(`invalid UI theme: ${manifest.id}`)
112
- if (manifest.theme.accent !== undefined && !/^#[0-9a-fA-F]{6}$/.test(manifest.theme.accent))
113
- throw new Error(`invalid UI theme accent: ${manifest.id}`)
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.status !== undefined) {
116
- if (!manifest.status.text || manifest.status.text.length > 160)
117
- throw new Error(`invalid UI status widget: ${manifest.id}`)
118
- if (
119
- manifest.status.tone !== undefined &&
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 ${manifest.id} requires untrusted capability: ${denied}`)
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
+ }