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.
- package/package.json +2 -1
- package/src/agent/plugin-tools.ts +18 -8
- package/src/agent/session-origin.ts +4 -3
- package/src/agent/system-prompt.ts +1 -1
- package/src/bundled-plugins/doc-render/index.ts +20 -0
- package/src/bundled-plugins/doc-render/render.ts +140 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +314 -0
- package/src/bundled-plugins/security/index.ts +15 -0
- package/src/bundled-plugins/security/permissions.ts +1 -0
- package/src/bundled-plugins/security/policies/plugin-addition.ts +240 -0
- package/src/channels/adapters/line.ts +12 -2
- package/src/cli/channel.ts +190 -7
- package/src/cli/inspect-select.ts +121 -0
- package/src/cli/inspect.ts +15 -7
- package/src/config/channels-mutation.ts +250 -0
- package/src/container/start.ts +24 -1
- package/src/init/reconcile-plugin-deps.ts +173 -0
- package/src/inspect/index.ts +5 -2
- package/src/inspect/live.ts +128 -13
- package/src/inspect/loop.ts +26 -9
- package/src/inspect/render.ts +28 -1
- package/src/inspect/transcript-view.ts +52 -11
- package/src/plugin/index.ts +2 -2
- package/src/plugin/loader.ts +61 -7
- package/src/plugin/manager.ts +18 -4
- package/src/run/bundled-plugins.ts +2 -0
- package/src/sandbox/availability.ts +87 -19
- package/src/sandbox/index.ts +4 -0
- package/src/secrets/storage.ts +27 -0
- package/src/server/index.ts +5 -1
- package/src/shared/protocol.ts +22 -11
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +0 -400
package/src/inspect/live.ts
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
package/src/inspect/loop.ts
CHANGED
|
@@ -10,7 +10,15 @@ export type OpenItemContext = {
|
|
|
10
10
|
createTailScope: () => TailController
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
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
|
|
73
|
+
let chosen: TItem
|
|
62
74
|
if (preselectKey !== undefined) {
|
|
63
|
-
|
|
75
|
+
const match = items.find((i) => opts.keyOf(i) === preselectKey) ?? null
|
|
64
76
|
preselectKey = undefined
|
|
65
|
-
if (
|
|
77
|
+
if (match === null) return opts.onEmpty()
|
|
78
|
+
chosen = match
|
|
66
79
|
} else {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
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 })
|
package/src/inspect/render.ts
CHANGED
|
@@ -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 = (
|
|
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(
|
|
60
|
-
if (evicted !== null)
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
241
|
+
export class BoundedComponentWindow<T> {
|
|
242
|
+
private readonly entries: T[] = []
|
|
206
243
|
|
|
207
244
|
constructor(private readonly maxEntries: number) {}
|
|
208
245
|
|
|
209
|
-
push(entry:
|
|
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
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -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,
|
|
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,
|
package/src/plugin/loader.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
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
|
package/src/plugin/manager.ts
CHANGED
|
@@ -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
|
-
...(
|
|
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 },
|