typeclaw 0.35.0 → 0.36.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.
@@ -11,9 +11,15 @@ export type StreamLiveOptions = {
11
11
  onSubscribed?: (live: boolean) => void
12
12
  onError?: (message: string) => void
13
13
  connectTimeoutMs?: number
14
+ heartbeatIntervalMs?: number
15
+ pongTimeoutMs?: number
16
+ bufferedAmountCeiling?: number
14
17
  }
15
18
 
16
19
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000
20
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000
21
+ const DEFAULT_PONG_TIMEOUT_MS = 30_000
22
+ const DEFAULT_BUFFERED_AMOUNT_CEILING = 1_048_576
17
23
 
18
24
  export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<InspectEvent> {
19
25
  const WS = opts.WebSocketImpl ?? WebSocket
@@ -26,6 +32,17 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
26
32
  const accumulators = new Map<string, string>()
27
33
  const thinkingAccumulators = new Map<string, string>()
28
34
 
35
+ let heartbeat: ReturnType<typeof setInterval> | null = null
36
+ let awaitingPongSince: number | null = null
37
+ let supportsPing = false
38
+
39
+ const stopHeartbeat = (): void => {
40
+ if (heartbeat !== null) {
41
+ clearInterval(heartbeat)
42
+ heartbeat = null
43
+ }
44
+ }
45
+
29
46
  const wake = (): void => {
30
47
  if (resolveNext !== null) {
31
48
  const fn = resolveNext
@@ -43,13 +60,19 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
43
60
  return
44
61
  }
45
62
  if (msg.type === 'subscribed') {
63
+ supportsPing = msg.supportsPing === true
46
64
  opts.onSubscribed?.(msg.sessionLive)
47
65
  return
48
66
  }
67
+ if (msg.type === 'pong') {
68
+ awaitingPongSince = null
69
+ return
70
+ }
49
71
  if (msg.type === 'error') {
50
72
  opts.onError?.(msg.message)
51
73
  pendingError = msg.message
52
74
  closed = true
75
+ stopHeartbeat()
53
76
  try {
54
77
  ws.close()
55
78
  } catch {
@@ -84,6 +107,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
84
107
  })
85
108
  ws.addEventListener('close', () => {
86
109
  closed = true
110
+ stopHeartbeat()
87
111
  wake()
88
112
  })
89
113
 
@@ -99,6 +123,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
99
123
  'abort',
100
124
  () => {
101
125
  closed = true
126
+ stopHeartbeat()
102
127
  try {
103
128
  ws.close()
104
129
  } catch {
@@ -134,25 +159,115 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
134
159
  }
135
160
  ws.send(JSON.stringify(subscribe))
136
161
 
137
- while (true) {
138
- if (buffer.length > 0) {
139
- const next = buffer.shift()!
140
- yield next
141
- continue
162
+ startHeartbeat({
163
+ ws,
164
+ intervalMs: opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
165
+ pongTimeoutMs: opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS,
166
+ bufferedAmountCeiling: opts.bufferedAmountCeiling ?? DEFAULT_BUFFERED_AMOUNT_CEILING,
167
+ supportsPing: () => supportsPing,
168
+ isAwaitingPongSince: () => awaitingPongSince,
169
+ setAwaitingPongSince: (at) => {
170
+ awaitingPongSince = at
171
+ },
172
+ setTimer: (timer) => {
173
+ heartbeat = timer
174
+ },
175
+ onDead: () => {
176
+ closed = true
177
+ stopHeartbeat()
178
+ try {
179
+ ws.close()
180
+ } catch {
181
+ /* ignore */
182
+ }
183
+ wake()
184
+ },
185
+ })
186
+
187
+ try {
188
+ while (true) {
189
+ if (buffer.length > 0) {
190
+ const next = buffer.shift()!
191
+ yield next
192
+ continue
193
+ }
194
+ if (closed) {
195
+ if (pendingError !== null) throw new Error(pendingError)
196
+ return
197
+ }
198
+ const { event, done } = await new Promise<{ event: InspectEvent | null; done: boolean }>((resolve) => {
199
+ resolveNext = resolve
200
+ })
201
+ if (event !== null) yield event
202
+ if (done) {
203
+ if (pendingError !== null) throw new Error(pendingError)
204
+ return
205
+ }
206
+ }
207
+ } finally {
208
+ // Also fired when the consumer abandons the generator (break from a
209
+ // `for await` calls .return()): close the socket so it can't outlive the
210
+ // viewer, not just the heartbeat timer.
211
+ stopHeartbeat()
212
+ closed = true
213
+ try {
214
+ ws.close()
215
+ } catch {
216
+ /* ignore */
142
217
  }
143
- if (closed) {
144
- if (pendingError !== null) throw new Error(pendingError)
218
+ }
219
+ }
220
+
221
+ type HeartbeatOptions = {
222
+ ws: WebSocket
223
+ intervalMs: number
224
+ pongTimeoutMs: number
225
+ bufferedAmountCeiling: number
226
+ // Read live: the `subscribed` reply that sets it arrives after the timer is
227
+ // armed, so a snapshot taken at startHeartbeat time would always be false.
228
+ supportsPing: () => boolean
229
+ isAwaitingPongSince: () => number | null
230
+ setAwaitingPongSince: (at: number | null) => void
231
+ setTimer: (timer: ReturnType<typeof setInterval>) => void
232
+ onDead: () => void
233
+ }
234
+
235
+ // Steady-state liveness watchdog. The connect gate only bounds the OPENING
236
+ // phase; once subscribed, a wedged socket (send queue not draining, no
237
+ // 'close'/'error') would park the read loop forever. The interval fires on the
238
+ // event-loop timer queue independent of the dead socket, so it always runs.
239
+ // Two death signals, both treated as a clean close (return, never throw) so the
240
+ // viewer recovers to the picker:
241
+ // 1. bufferedAmount past a ceiling — our writes are not draining. Always on:
242
+ // it needs no server cooperation, so it works against any server version.
243
+ // 2. a ping with no pong within the deadline — round-trip liveness lost,
244
+ // which also covers idle tails (a quiet-but-healthy tail still pongs). Only
245
+ // armed when the server advertised supportsPing; a pre-heartbeat server
246
+ // answers an unknown ping with error+close, so probing it would kill the
247
+ // tail. Such a server degrades to bufferedAmount-only detection.
248
+ function startHeartbeat(opts: HeartbeatOptions): void {
249
+ let pingId = 0
250
+ const tick = (): void => {
251
+ if (opts.ws.bufferedAmount >= opts.bufferedAmountCeiling) {
252
+ opts.onDead()
145
253
  return
146
254
  }
147
- const { event, done } = await new Promise<{ event: InspectEvent | null; done: boolean }>((resolve) => {
148
- resolveNext = resolve
149
- })
150
- if (event !== null) yield event
151
- if (done) {
152
- if (pendingError !== null) throw new Error(pendingError)
255
+ if (!opts.supportsPing()) return
256
+ const awaiting = opts.isAwaitingPongSince()
257
+ if (awaiting !== null) {
258
+ if (Date.now() - awaiting >= opts.pongTimeoutMs) opts.onDead()
153
259
  return
154
260
  }
261
+ pingId += 1
262
+ const ping: InspectClientMessage = { type: 'ping', id: pingId }
263
+ try {
264
+ opts.ws.send(JSON.stringify(ping))
265
+ opts.setAwaitingPongSince(Date.now())
266
+ } catch {
267
+ opts.onDead()
268
+ }
155
269
  }
270
+ opts.setTimer(setInterval(tick, opts.intervalMs))
156
271
  }
157
272
 
158
273
  function frameToEvent(
@@ -10,7 +10,15 @@ export type OpenItemContext = {
10
10
  createTailScope: () => TailController
11
11
  }
12
12
 
13
- export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<TItem | null>
13
+ // `refresh` (the `r` key) re-lists and re-renders the picker without opening
14
+ // anything; `highlightKey` is the row highlighted when `r` was pressed, so the
15
+ // selection survives the refresh.
16
+ export type SelectOutcome<TItem> =
17
+ | { kind: 'picked'; item: TItem }
18
+ | { kind: 'cancelled' }
19
+ | { kind: 'refresh'; highlightKey?: string }
20
+
21
+ export type SelectItem<TItem> = (items: TItem[], opts: { initialKey?: string }) => Promise<SelectOutcome<TItem>>
14
22
 
15
23
  export type OpenItemResult = {
16
24
  result: RunInspectResult
@@ -46,8 +54,12 @@ export type RunViewerLoopOptions<TItem> = {
46
54
  // inside `openItem` AFTER the picker resolves and disposed before the picker
47
55
  // re-opens, so clack always owns a clean cooked-mode stdin.
48
56
  export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): Promise<RunInspectResult> {
57
+ // `preselectKey` auto-opens a row once (the `inspect <id>` arg). `highlightKey`
58
+ // only seeds the picker's initial highlight (esc-return + `r` refresh) and
59
+ // never bypasses the picker — keeping the two apart is what lets refresh
60
+ // re-render the list instead of re-opening the last viewer.
49
61
  let preselectKey = opts.preselectKey
50
- let lastPickedKey: string | undefined
62
+ let highlightKey: string | undefined
51
63
  // Writable is only safe on the very first list. Returning to the picker means
52
64
  // a viewer was just opened and left — any writable session it might represent
53
65
  // is gone (detach ends the live session), so subsequent refreshes are
@@ -58,16 +70,21 @@ export async function runViewerLoop<TItem>(opts: RunViewerLoopOptions<TItem>): P
58
70
  const items = await opts.listItems({ allowWritable })
59
71
  if (items.length === 0) return opts.onEmpty()
60
72
 
61
- let chosen: TItem | null
73
+ let chosen: TItem
62
74
  if (preselectKey !== undefined) {
63
- chosen = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
75
+ const match = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
64
76
  preselectKey = undefined
65
- if (chosen === null) return opts.onEmpty()
77
+ if (match === null) return opts.onEmpty()
78
+ chosen = match
66
79
  } else {
67
- const hint = lastPickedKey
68
- chosen = await opts.selectItem(items, hint !== undefined ? { initialKey: hint } : {})
69
- if (chosen === null) return { ok: false, exitCode: 130, reason: 'cancelled' }
70
- lastPickedKey = opts.keyOf(chosen)
80
+ const outcome = await opts.selectItem(items, highlightKey !== undefined ? { initialKey: highlightKey } : {})
81
+ if (outcome.kind === 'cancelled') return { ok: false, exitCode: 130, reason: 'cancelled' }
82
+ if (outcome.kind === 'refresh') {
83
+ if (outcome.highlightKey !== undefined) highlightKey = outcome.highlightKey
84
+ continue
85
+ }
86
+ chosen = outcome.item
87
+ highlightKey = opts.keyOf(chosen)
71
88
  }
72
89
 
73
90
  const opened = await opts.openItem(chosen, { createTailScope: opts.createTailScope })
@@ -6,15 +6,42 @@ import type { InspectEvent } from './types'
6
6
  export type RenderOptions = {
7
7
  color: boolean
8
8
  maxTextLength?: number
9
+ // When false, the timestamp column is blanked (kept the same width so the tag
10
+ // column stays aligned). The line view passes this from a TimeGate so a run of
11
+ // same-category events shows the timestamp only on its first line.
12
+ showTime?: boolean
9
13
  }
10
14
 
15
+ const TIME_WIDTH = '--:--:--'.length
16
+
11
17
  export function renderEvent(event: InspectEvent, opts: RenderOptions): string {
12
- const time = renderTime(event.ts, opts)
18
+ const time = opts.showTime === false ? ' '.repeat(TIME_WIDTH) : renderTime(event.ts, opts)
13
19
  const tag = renderTag(event, opts)
14
20
  const body = renderBody(event, opts)
15
21
  return `${time} ${tag} ${body}`
16
22
  }
17
23
 
24
+ // Decides whether an event's timestamp should be shown, given the running render
25
+ // position: a timestamp prints only when the event's category differs from the
26
+ // previous one, so a run of same-category events (e.g. back-to-back thinking
27
+ // blocks or a tool start/end pair) carries a single timestamp at its start.
28
+ export class TimeGate {
29
+ private prevCat: InspectEvent['cat'] | null = null
30
+
31
+ shouldShow(cat: InspectEvent['cat']): boolean {
32
+ const show = cat !== this.prevCat
33
+ this.prevCat = cat
34
+ return show
35
+ }
36
+
37
+ // Clear the running category so the next event always shows its timestamp.
38
+ // Called at a visible section boundary (the live divider) so the first live
39
+ // row is stamped even when it shares the last replayed event's category.
40
+ reset(): void {
41
+ this.prevCat = null
42
+ }
43
+ }
44
+
18
45
  const DEFAULT_MAX_TEXT = 200
19
46
 
20
47
  function renderTime(ts: number, opts: RenderOptions): string {
@@ -14,6 +14,7 @@ import { colors, markdownTheme } from '@/tui/theme'
14
14
 
15
15
  import { streamSessionEvents, type LiveSourceFactory, type StreamPhase } from './index'
16
16
  import { originLabel, shortSessionId } from './label'
17
+ import { TimeGate } from './render'
17
18
  import type { SessionSummary } from './session-list'
18
19
  import type { InspectEvent, InspectFilter } from './types'
19
20
 
@@ -25,6 +26,7 @@ export type TranscriptViewOptions = {
25
26
  sinceMs: number | undefined
26
27
  liveSource?: LiveSourceFactory
27
28
  createTerminal?: () => Terminal
29
+ maxHistoryEntries?: number
28
30
  }
29
31
 
30
32
  export const MAX_LIVE_HISTORY_ENTRIES = 250
@@ -53,12 +55,15 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
53
55
  // grow until the viewer stalls; the window evicts the oldest entry to keep
54
56
  // render cost bounded. Components are evicted per event so a timestamp never
55
57
  // outlives its body. Header and pinned status are never evicted.
56
- const history = new BoundedComponentWindow(MAX_LIVE_HISTORY_ENTRIES)
57
- const appendEntry = (components: HistoryEntry): void => {
58
+ const history = new BoundedComponentWindow<HistoryEntry>(opts.maxHistoryEntries ?? MAX_LIVE_HISTORY_ENTRIES)
59
+ const appendEntry = (entry: HistoryEntry): void => {
58
60
  tui.removeChild(status)
59
- const evicted = history.push(components)
60
- if (evicted !== null) for (const component of evicted) tui.removeChild(component)
61
- for (const component of components) tui.addChild(component)
61
+ const evicted = history.push(entry)
62
+ if (evicted !== null) {
63
+ for (const component of evicted.components) tui.removeChild(component)
64
+ promoteVisibleRunHead(history, evicted)
65
+ }
66
+ for (const component of entry.components) tui.addChild(component)
62
67
  tui.addChild(status)
63
68
  }
64
69
 
@@ -91,15 +96,23 @@ export function createTranscriptView(opts: TranscriptViewOptions) {
91
96
  // during replay (one render at replay-end) to avoid redraw storms on long
92
97
  // transcripts; render per event once live.
93
98
  let live = false
99
+ const timeGate = new TimeGate()
94
100
  const onEvent = (event: InspectEvent): void => {
95
- appendEntry([new Text(formatEventTime(event.ts), 0, 0), componentFor(event)])
101
+ const body = componentFor(event)
102
+ const stamped = timeGate.shouldShow(event.cat)
103
+ const time = new Text(stamped ? formatEventTime(event.ts) : '', 0, 0)
104
+ appendEntry({ kind: 'event', cat: event.cat, ts: event.ts, time, stamped, components: [time, body] })
96
105
  if (live) tui.requestRender()
97
106
  }
98
107
  const onPhase = (phase: StreamPhase): void => {
99
108
  if (phase.phase === 'replay-end') {
100
109
  tui.requestRender()
101
110
  } else if (phase.phase === 'live-start') {
102
- appendEntry([new Text(divider(phase.sessionLive ? 'live' : 'live (broadcasts only)'), 0, 0)])
111
+ timeGate.reset()
112
+ appendEntry({
113
+ kind: 'divider',
114
+ components: [new Text(divider(phase.sessionLive ? 'live' : 'live (broadcasts only)'), 0, 0)],
115
+ })
103
116
  live = true
104
117
  tui.requestRender()
105
118
  }
@@ -199,19 +212,47 @@ function divider(text: string): string {
199
212
  return colors.dim(`─── ${text} ───`)
200
213
  }
201
214
 
202
- export type HistoryEntry = readonly Component[]
215
+ // After the stamped head of a same-category run is evicted, the new first
216
+ // visible row of that run would have a blank timestamp. Fill its already-present
217
+ // (blank) Text so the visible window never shows a run with no timestamp at all.
218
+ // Mutates text in place — no child add/remove/reorder.
219
+ function promoteVisibleRunHead(history: BoundedComponentWindow<HistoryEntry>, evicted: HistoryEntry): void {
220
+ if (evicted.kind !== 'event' || !evicted.stamped) return
221
+ const first = history.first()
222
+ if (first === undefined || first.kind !== 'event' || first.stamped || first.cat !== evicted.cat) return
223
+ first.time.setText(formatEventTime(first.ts))
224
+ first.stamped = true
225
+ }
226
+
227
+ // An event row carries its category + ts + the (possibly blank) timestamp Text
228
+ // so the window can re-stamp the visible run head after eviction. Non-event rows
229
+ // (the live divider) have no category and never participate in re-stamping.
230
+ export type HistoryEntry =
231
+ | {
232
+ kind: 'event'
233
+ cat: InspectEvent['cat']
234
+ ts: number
235
+ time: Text
236
+ stamped: boolean
237
+ components: readonly Component[]
238
+ }
239
+ | { kind: 'divider'; components: readonly Component[] }
203
240
 
204
- export class BoundedComponentWindow {
205
- private readonly entries: HistoryEntry[] = []
241
+ export class BoundedComponentWindow<T> {
242
+ private readonly entries: T[] = []
206
243
 
207
244
  constructor(private readonly maxEntries: number) {}
208
245
 
209
- push(entry: HistoryEntry): HistoryEntry | null {
246
+ push(entry: T): T | null {
210
247
  this.entries.push(entry)
211
248
  if (this.entries.length <= this.maxEntries) return null
212
249
  return this.entries.shift() ?? null
213
250
  }
214
251
 
252
+ first(): T | undefined {
253
+ return this.entries[0]
254
+ }
255
+
215
256
  get size(): number {
216
257
  return this.entries.length
217
258
  }
@@ -72,8 +72,8 @@ export {
72
72
  type LoadPluginsResult,
73
73
  } from './manager'
74
74
  export type { PermissionService } from '@/permissions'
75
- export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
76
- export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
75
+ export type { LoadPluginEntryFn, PluginEntrySpec, ResolvedPlugin } from './loader'
76
+ export { derivePluginNameFromPackage, loadPluginEntry, PluginNotFoundError, splitPluginEntrySpec } from './loader'
77
77
  export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
78
78
  export {
79
79
  createLoadSkillTool,
@@ -13,6 +13,20 @@ export type ResolvedPlugin = {
13
13
 
14
14
  export type LoadPluginEntryFn = (entry: string, agentDir: string) => Promise<ResolvedPlugin>
15
15
 
16
+ // Thrown only when a plugin entry cannot be resolved at all (uninstalled
17
+ // package, missing local file, unresolvable export subpath). The manager
18
+ // treats this as non-fatal and skips the entry. Every other failure --
19
+ // path-escape, import-time evaluation throws, invalid definition -- stays a
20
+ // plain Error so it remains a hard boot error.
21
+ export class PluginNotFoundError extends Error {
22
+ readonly entry: string
23
+ constructor(entry: string, message: string, options?: { cause?: unknown }) {
24
+ super(message, options)
25
+ this.name = 'PluginNotFoundError'
26
+ this.entry = entry
27
+ }
28
+ }
29
+
16
30
  export async function loadPluginEntry(entry: string, agentDir: string): Promise<ResolvedPlugin> {
17
31
  if (isLocalPath(entry)) {
18
32
  return loadLocal(entry, agentDir)
@@ -33,7 +47,7 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
33
47
  throw new Error(`plugin path escapes agent directory: ${entry} (resolved to ${resolved})`)
34
48
  }
35
49
  if (!existsSync(resolved)) {
36
- throw new Error(`plugin path does not exist: ${entry} (resolved to ${resolved})`)
50
+ throw new PluginNotFoundError(entry, `plugin path does not exist: ${entry} (resolved to ${resolved})`)
37
51
  }
38
52
  const url = pathToFileURL(resolved).href
39
53
  const mod = (await import(url)) as { default?: unknown }
@@ -43,8 +57,14 @@ async function loadLocal(entry: string, agentDir: string): Promise<ResolvedPlugi
43
57
  }
44
58
 
45
59
  async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin> {
46
- const pkgJsonPath = findPackageJson(entry, agentDir)
47
- let pkgName = entry
60
+ // The version suffix (`name@1.2.3`, `@scope/name@1.2.3`) is consumed by the
61
+ // host reconcile step when materializing the entry into package.json. By load
62
+ // time the package is installed at `node_modules/<name>/` under its bare name,
63
+ // so passing the raw `name@version` here would miss the dir and fail the
64
+ // bare-import fallback too.
65
+ const { name: packageName } = splitPluginEntrySpec(entry)
66
+ const pkgJsonPath = findPackageJson(packageName, agentDir)
67
+ let pkgName = packageName
48
68
  let version: string | undefined
49
69
  let entryPath: string | null = null
50
70
  if (pkgJsonPath !== null) {
@@ -68,16 +88,46 @@ async function loadNpm(entry: string, agentDir: string): Promise<ResolvedPlugin>
68
88
  // Fall through to bare-import resolution.
69
89
  }
70
90
  }
71
- // Falls back to bare-import resolution when entryPath cannot be located on
72
- // disk. Modern packages with `exports` map (and no `main`/`module`) take
73
- // this path so Bun's resolver can read `exports`.
74
- const importTarget = entryPath !== null ? pathToFileURL(entryPath).href : entry
91
+ // Resolve before importing so an unresolvable entry (uninstalled package,
92
+ // missing export subpath) is classified as PluginNotFoundError WITHOUT
93
+ // running the module. Once resolution succeeds, any import-time throw is a
94
+ // genuine plugin bug and propagates fatally -- never swallowed as not-found.
95
+ // The entryPath branch covers packages whose `main`/`module` was already
96
+ // located on disk; the else branch lets Bun's resolver read `exports` maps.
97
+ let importTarget: string
98
+ if (entryPath !== null) {
99
+ importTarget = pathToFileURL(entryPath).href
100
+ } else {
101
+ try {
102
+ importTarget = Bun.resolveSync(packageName, agentDir)
103
+ } catch (err) {
104
+ throw new PluginNotFoundError(entry, `cannot resolve plugin "${entry}": ${describeError(err)}`, { cause: err })
105
+ }
106
+ }
75
107
  const mod = (await import(importTarget)) as { default?: unknown }
76
108
  const defined = expectDefined(mod, entry)
77
109
  const name = derivePluginNameFromPackage(pkgName)
78
110
  return { name, version, source: entry, defined }
79
111
  }
80
112
 
113
+ export type PluginEntrySpec = { name: string; versionSpec: string | undefined }
114
+
115
+ // Splits an npm-style entry into package name and optional version spec. The
116
+ // version delimiter is the LAST `@` that isn't the leading scope marker, so
117
+ // `@scope/pkg@1.2.3` → { name: '@scope/pkg', versionSpec: '1.2.3' } while
118
+ // `@scope/pkg` → { name: '@scope/pkg', versionSpec: undefined }.
119
+ export function splitPluginEntrySpec(entry: string): PluginEntrySpec {
120
+ const scoped = entry.startsWith('@')
121
+ const searchFrom = scoped ? entry.indexOf('/') + 1 : 0
122
+ const at = entry.indexOf('@', searchFrom)
123
+ if (at <= 0) return { name: entry, versionSpec: undefined }
124
+ const versionSpec = entry.slice(at + 1)
125
+ return {
126
+ name: entry.slice(0, at),
127
+ versionSpec: versionSpec.length > 0 ? versionSpec : undefined,
128
+ }
129
+ }
130
+
81
131
  export function derivePluginNameFromPackage(packageName: string): string {
82
132
  const PREFIX = 'typeclaw-plugin-'
83
133
  const SCOPED_PREFIX_RE = /^@[^/]+\//
@@ -85,6 +135,10 @@ export function derivePluginNameFromPackage(packageName: string): string {
85
135
  return stripped.startsWith(PREFIX) ? stripped.slice(PREFIX.length) : stripped
86
136
  }
87
137
 
138
+ function describeError(err: unknown): string {
139
+ return err instanceof Error ? err.message : String(err)
140
+ }
141
+
88
142
  function findPackageJson(entry: string, agentDir: string): string | null {
89
143
  const PACKAGE_JSON = 'package.json'
90
144
  let cur = agentDir
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  import { createPluginContext, createPluginLogger, type SpawnSubagentFn } from './context'
13
13
  import { createHookBus, type HookBus } from './hooks'
14
- import { loadPluginEntry, type LoadPluginEntryFn, type ResolvedPlugin } from './loader'
14
+ import { loadPluginEntry, type LoadPluginEntryFn, PluginNotFoundError, type ResolvedPlugin } from './loader'
15
15
  import { discardRegistrationsBy, emptyRegistry, type PluginRegistry, registerContributions } from './registry'
16
16
  import type { PluginExports } from './types'
17
17
 
@@ -51,11 +51,25 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
51
51
  throw new Error('plugin: spawnSubagent is not yet wired')
52
52
  }
53
53
 
54
+ // Non-fatal: a single unresolvable entry (uninstalled package, typo) must
55
+ // not abort boot for every other plugin -- warn and skip it. Only genuine
56
+ // resolution failures (PluginNotFoundError) are swallowed; path-escape,
57
+ // import-time throws, and invalid definitions stay fatal so a broken or
58
+ // malicious plugin still hard-fails boot.
59
+ const resolvedEntries = await Promise.all(
60
+ opts.entries.map(async (entry) => {
61
+ try {
62
+ return { entry, resolved: await loadEntry(entry, opts.agentDir) }
63
+ } catch (err) {
64
+ if (!(err instanceof PluginNotFoundError)) throw err
65
+ console.warn(`[plugin] failed to load "${entry}", ignoring: ${err.message}`)
66
+ return null
67
+ }
68
+ }),
69
+ )
54
70
  const allPlugins: { entry: string; resolved: ResolvedPlugin }[] = [
55
71
  ...(opts.bundled?.map((resolved) => ({ entry: `<bundled:${resolved.name}>`, resolved })) ?? []),
56
- ...(await Promise.all(
57
- opts.entries.map(async (entry) => ({ entry, resolved: await loadEntry(entry, opts.agentDir) })),
58
- )),
72
+ ...resolvedEntries.filter((e): e is { entry: string; resolved: ResolvedPlugin } => e !== null),
59
73
  ]
60
74
 
61
75
  const declaredPermissions = collectDeclaredPermissions(allPlugins)
@@ -1,6 +1,7 @@
1
1
  import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
2
  import backupPlugin from '@/bundled-plugins/backup'
3
3
  import bunHygienePlugin from '@/bundled-plugins/bun-hygiene'
4
+ import docRenderPlugin from '@/bundled-plugins/doc-render'
4
5
  import explorerPlugin from '@/bundled-plugins/explorer'
5
6
  import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
6
7
  import guardPlugin from '@/bundled-plugins/guard'
@@ -58,6 +59,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
58
59
  { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
59
60
  { name: 'backup', version: undefined, source: '<bundled>', defined: backupPlugin },
60
61
  { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
62
+ { name: 'doc-render', version: undefined, source: '<bundled>', defined: docRenderPlugin },
61
63
  { name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
62
64
  { name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
63
65
  { name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },