typeclaw 0.35.1 → 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.
@@ -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 },
@@ -243,6 +243,33 @@ export class SecretsBackend implements AuthStorageBackend {
243
243
  }
244
244
  }
245
245
 
246
+ // Removes `channels.<kind>` from the envelope. Returns `true` when the
247
+ // adapter slot was present and removed, `false` when nothing changed
248
+ // (idempotent on the CLI side — `channel remove discord-bot` twice should
249
+ // not error on the second call). Mirrors `removeProviderCredentialSync`:
250
+ // rewrites the file only when something changed so canonical-shape reads
251
+ // pay zero cost.
252
+ removeChannelSync(kind: string): boolean {
253
+ if (!existsSync(this.secretsPath)) return false
254
+ let release: (() => void) | undefined
255
+ try {
256
+ release = this.acquireSyncLockWithRetry()
257
+ const envelope = this.readEnvelope()
258
+ if (!(kind in envelope.channels)) return false
259
+ const { [kind]: _removed, ...rest } = envelope.channels
260
+ const next: SecretsFile = {
261
+ ...envelope,
262
+ $schema: envelope.$schema ?? SCHEMA_REL,
263
+ version: SECRETS_FILE_VERSION,
264
+ channels: rest,
265
+ }
266
+ this.writeEnvelopeAtomic(next)
267
+ return true
268
+ } finally {
269
+ release?.()
270
+ }
271
+ }
272
+
246
273
  writeChannelsSync(next: Channels): void {
247
274
  this.ensureParentDir()
248
275
  this.ensureFileExists()