typeclaw 0.8.0 → 0.9.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 (92) hide show
  1. package/README.md +6 -6
  2. package/package.json +5 -3
  3. package/scripts/require-parallel.ts +41 -0
  4. package/src/agent/index.ts +55 -6
  5. package/src/agent/live-sessions.ts +34 -0
  6. package/src/agent/plugin-tools.ts +2 -0
  7. package/src/agent/session-meta.ts +21 -2
  8. package/src/agent/subagent-completion-reminder.ts +89 -0
  9. package/src/agent/subagents.ts +3 -2
  10. package/src/agent/system-prompt.ts +10 -8
  11. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  12. package/src/bundled-plugins/guard/index.ts +14 -1
  13. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  14. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  15. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  16. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  17. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  18. package/src/bundled-plugins/guard/policy.ts +7 -0
  19. package/src/bundled-plugins/memory/README.md +76 -62
  20. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  21. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  22. package/src/bundled-plugins/memory/citations.ts +19 -8
  23. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  24. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  25. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  26. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  27. package/src/bundled-plugins/memory/index.ts +236 -16
  28. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  29. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  30. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  31. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  32. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  33. package/src/bundled-plugins/memory/migration.ts +282 -1
  34. package/src/bundled-plugins/memory/paths.ts +42 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  36. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  37. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  38. package/src/bundled-plugins/memory/slug.ts +59 -0
  39. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  40. package/src/bundled-plugins/memory/strength.ts +3 -3
  41. package/src/bundled-plugins/memory/topics.ts +70 -16
  42. package/src/bundled-plugins/security/index.ts +24 -0
  43. package/src/bundled-plugins/security/permissions.ts +4 -0
  44. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  45. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  46. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  47. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  48. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  49. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  50. package/src/channels/adapters/kakaotalk.ts +64 -37
  51. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  52. package/src/channels/index.ts +5 -0
  53. package/src/channels/router.ts +201 -17
  54. package/src/channels/subagent-completion-bridge.ts +84 -0
  55. package/src/cli/builtins.ts +1 -0
  56. package/src/cli/index.ts +1 -0
  57. package/src/cli/init.ts +122 -14
  58. package/src/cli/inspect.ts +151 -0
  59. package/src/cron/consumer.ts +1 -1
  60. package/src/init/dockerfile.ts +268 -4
  61. package/src/init/hatching.ts +5 -6
  62. package/src/init/kakaotalk-auth.ts +6 -47
  63. package/src/init/validate-api-key.ts +121 -0
  64. package/src/inspect/index.ts +213 -0
  65. package/src/inspect/label.ts +50 -0
  66. package/src/inspect/live.ts +221 -0
  67. package/src/inspect/render.ts +163 -0
  68. package/src/inspect/replay.ts +265 -0
  69. package/src/inspect/session-list.ts +160 -0
  70. package/src/inspect/types.ts +110 -0
  71. package/src/plugin/hooks.ts +23 -1
  72. package/src/plugin/index.ts +2 -0
  73. package/src/plugin/manager.ts +1 -1
  74. package/src/plugin/registry.ts +1 -1
  75. package/src/plugin/types.ts +10 -0
  76. package/src/run/channel-session-factory.ts +7 -1
  77. package/src/run/index.ts +87 -21
  78. package/src/secrets/kakao-renewal.ts +3 -47
  79. package/src/server/index.ts +241 -60
  80. package/src/shared/index.ts +3 -0
  81. package/src/shared/protocol.ts +49 -0
  82. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  83. package/src/skills/typeclaw-claude-code/SKILL.md +57 -39
  84. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  85. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  86. package/src/skills/typeclaw-config/SKILL.md +1 -1
  87. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  88. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  89. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  90. package/src/skills/typeclaw-plugins/SKILL.md +25 -14
  91. package/src/test-helpers/wait-for.ts +7 -1
  92. package/typeclaw.schema.json +7 -0
@@ -0,0 +1,265 @@
1
+ import { SESSION_META_CUSTOM_TYPE } from '@/agent/session-meta'
2
+ import type { MinimalSessionOrigin } from '@/agent/session-meta'
3
+
4
+ import type { InspectEvent } from './types'
5
+
6
+ export type ReplayOptions = {
7
+ onWarn?: (msg: string) => void
8
+ }
9
+
10
+ export async function* replayJsonl(filePath: string, opts: ReplayOptions = {}): AsyncGenerator<InspectEvent> {
11
+ const file = Bun.file(filePath)
12
+ if (!(await file.exists())) {
13
+ opts.onWarn?.(`could not open ${filePath}: file does not exist`)
14
+ return
15
+ }
16
+ let stream: ReadableStream<Uint8Array>
17
+ try {
18
+ stream = file.stream()
19
+ } catch (err) {
20
+ opts.onWarn?.(`could not open ${filePath}: ${describeErr(err)}`)
21
+ return
22
+ }
23
+ yield* replayLines(safeStreamLines(stream, filePath, opts), opts)
24
+ }
25
+
26
+ async function* safeStreamLines(
27
+ stream: ReadableStream<Uint8Array>,
28
+ filePath: string,
29
+ opts: ReplayOptions,
30
+ ): AsyncGenerator<string> {
31
+ try {
32
+ yield* streamLines(stream)
33
+ } catch (err) {
34
+ opts.onWarn?.(`error reading ${filePath}: ${describeErr(err)}`)
35
+ }
36
+ }
37
+
38
+ export async function* replayLines(
39
+ lines: AsyncIterable<string>,
40
+ opts: ReplayOptions = {},
41
+ ): AsyncGenerator<InspectEvent> {
42
+ const pending = new Map<string, { name: string; startTs: number }>()
43
+ for await (const line of lines) {
44
+ const trimmed = line.trim()
45
+ if (trimmed === '') continue
46
+ let entry: unknown
47
+ try {
48
+ entry = JSON.parse(trimmed)
49
+ } catch {
50
+ opts.onWarn?.('skipping malformed JSONL line')
51
+ continue
52
+ }
53
+ yield* eventsFromEntry(entry, pending)
54
+ }
55
+ }
56
+
57
+ function* eventsFromEntry(
58
+ entry: unknown,
59
+ pending: Map<string, { name: string; startTs: number }>,
60
+ ): Iterable<InspectEvent> {
61
+ const meta = readSessionMeta(entry)
62
+ if (meta !== null) {
63
+ yield { cat: 'meta', ts: numberOr(readField(entry, 'timestamp'), 0), origin: meta }
64
+ return
65
+ }
66
+ if (!isMessageEntry(entry)) return
67
+ const message = entry.message
68
+ const role = message.role
69
+ const ts = numberOr(readField(message, 'timestamp'), 0)
70
+ if (role === 'user') {
71
+ const text = readTextContent(message.content)
72
+ if (text !== null) yield { cat: 'user', ts, text }
73
+ return
74
+ }
75
+ if (role === 'assistant') {
76
+ yield* assistantEvents(message as AssistantMessage, ts, pending)
77
+ return
78
+ }
79
+ }
80
+
81
+ function* assistantEvents(
82
+ message: AssistantMessage,
83
+ ts: number,
84
+ pending: Map<string, { name: string; startTs: number }>,
85
+ ): Iterable<InspectEvent> {
86
+ const text = readTextContent(message.content)
87
+ if (text !== null && text !== '') {
88
+ const ev: InspectEvent = {
89
+ cat: 'assistant',
90
+ ts,
91
+ text,
92
+ ...(typeof message.provider === 'string' ? { provider: message.provider } : {}),
93
+ ...(typeof message.model === 'string' ? { model: message.model } : {}),
94
+ }
95
+ yield ev
96
+ }
97
+ if (Array.isArray(message.content)) {
98
+ for (const block of message.content) {
99
+ const callEvents = readToolCall(block, ts)
100
+ for (const ev of callEvents) {
101
+ if (ev.cat === 'tool' && ev.phase === 'start') pending.set(ev.toolCallId, { name: ev.name, startTs: ev.ts })
102
+ yield ev
103
+ }
104
+ const resultEvents = readToolResult(block, ts, pending)
105
+ yield* resultEvents
106
+ }
107
+ }
108
+ if (typeof message.errorMessage === 'string' && message.errorMessage !== '') {
109
+ yield { cat: 'error', ts, message: message.errorMessage }
110
+ }
111
+ const usage = readUsage(message.usage)
112
+ if (usage !== null && (usage.totalTokens > 0 || typeof message.stopReason === 'string')) {
113
+ yield {
114
+ cat: 'done',
115
+ ts,
116
+ ...(typeof message.stopReason === 'string' ? { stopReason: message.stopReason } : {}),
117
+ ...usage,
118
+ }
119
+ }
120
+ }
121
+
122
+ function readToolCall(block: unknown, ts: number): InspectEvent[] {
123
+ if (typeof block !== 'object' || block === null) return []
124
+ const b = block as Record<string, unknown>
125
+ if (b.type !== 'toolCall') return []
126
+ const toolCallId = typeof b.id === 'string' ? b.id : null
127
+ const name = typeof b.name === 'string' ? b.name : null
128
+ if (toolCallId === null || name === null) return []
129
+ return [
130
+ {
131
+ cat: 'tool',
132
+ ts,
133
+ phase: 'start',
134
+ toolCallId,
135
+ name,
136
+ ...(b.arguments !== undefined ? { args: b.arguments } : {}),
137
+ },
138
+ ]
139
+ }
140
+
141
+ function readToolResult(
142
+ block: unknown,
143
+ ts: number,
144
+ pending: Map<string, { name: string; startTs: number }>,
145
+ ): InspectEvent[] {
146
+ if (typeof block !== 'object' || block === null) return []
147
+ const b = block as Record<string, unknown>
148
+ if (b.type !== 'toolResult') return []
149
+ const toolCallId = typeof b.toolCallId === 'string' ? b.toolCallId : null
150
+ if (toolCallId === null) return []
151
+ const entry = pending.get(toolCallId)
152
+ pending.delete(toolCallId)
153
+ const name = entry?.name ?? (typeof b.name === 'string' ? b.name : 'unknown')
154
+ const durationMs = entry !== undefined ? Math.max(0, ts - entry.startTs) : 0
155
+ const isError = b.isError === true
156
+ return [
157
+ {
158
+ cat: 'tool',
159
+ ts,
160
+ phase: 'end',
161
+ toolCallId,
162
+ name,
163
+ ...(b.output !== undefined ? { result: b.output } : {}),
164
+ isError,
165
+ durationMs,
166
+ },
167
+ ]
168
+ }
169
+
170
+ function readUsage(value: unknown): {
171
+ input: number
172
+ output: number
173
+ cacheRead: number
174
+ cacheWrite: number
175
+ totalTokens: number
176
+ cost: number
177
+ } | null {
178
+ if (typeof value !== 'object' || value === null) return null
179
+ const u = value as Record<string, unknown>
180
+ const cost = u.cost as Record<string, unknown> | undefined
181
+ return {
182
+ input: numberOr(u.input, 0),
183
+ output: numberOr(u.output, 0),
184
+ cacheRead: numberOr(u.cacheRead, 0),
185
+ cacheWrite: numberOr(u.cacheWrite, 0),
186
+ totalTokens: numberOr(u.totalTokens, 0),
187
+ cost: numberOr(cost?.total, 0),
188
+ }
189
+ }
190
+
191
+ function readTextContent(content: unknown): string | null {
192
+ if (typeof content === 'string') return content
193
+ if (!Array.isArray(content)) return null
194
+ const parts: string[] = []
195
+ for (const block of content) {
196
+ if (typeof block !== 'object' || block === null) continue
197
+ const b = block as Record<string, unknown>
198
+ if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text)
199
+ }
200
+ if (parts.length === 0) return null
201
+ return parts.join('')
202
+ }
203
+
204
+ type AssistantMessage = {
205
+ role: 'assistant'
206
+ content?: unknown
207
+ provider?: unknown
208
+ model?: unknown
209
+ usage?: unknown
210
+ errorMessage?: unknown
211
+ stopReason?: unknown
212
+ }
213
+
214
+ function isMessageEntry(value: unknown): value is { type: 'message'; message: { role: string; [k: string]: unknown } } {
215
+ if (typeof value !== 'object' || value === null) return false
216
+ const v = value as Record<string, unknown>
217
+ if (v.type !== 'message') return false
218
+ if (typeof v.message !== 'object' || v.message === null) return false
219
+ const m = v.message as Record<string, unknown>
220
+ return typeof m.role === 'string'
221
+ }
222
+
223
+ function readSessionMeta(value: unknown): MinimalSessionOrigin | null {
224
+ if (typeof value !== 'object' || value === null) return null
225
+ const v = value as Record<string, unknown>
226
+ if (v.type !== 'custom') return null
227
+ if (v.customType !== SESSION_META_CUSTOM_TYPE) return null
228
+ if (typeof v.data !== 'object' || v.data === null) return null
229
+ const d = v.data as Record<string, unknown>
230
+ if (typeof d.origin !== 'object' || d.origin === null) return null
231
+ const o = d.origin as Record<string, unknown>
232
+ if (typeof o.kind !== 'string') return null
233
+ return d.origin as MinimalSessionOrigin
234
+ }
235
+
236
+ function readField(value: unknown, key: string): unknown {
237
+ if (typeof value !== 'object' || value === null) return undefined
238
+ return (value as Record<string, unknown>)[key]
239
+ }
240
+
241
+ function numberOr(value: unknown, fallback: number): number {
242
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
243
+ return value
244
+ }
245
+
246
+ function describeErr(err: unknown): string {
247
+ if (err instanceof Error) return err.message
248
+ return String(err)
249
+ }
250
+
251
+ async function* streamLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
252
+ const decoder = new TextDecoder()
253
+ let buf = ''
254
+ for await (const chunk of stream) {
255
+ buf += decoder.decode(chunk, { stream: true })
256
+ let nl = buf.indexOf('\n')
257
+ while (nl !== -1) {
258
+ const line = buf.slice(0, nl)
259
+ buf = buf.slice(nl + 1)
260
+ yield line
261
+ nl = buf.indexOf('\n')
262
+ }
263
+ }
264
+ if (buf.length > 0) yield buf
265
+ }
@@ -0,0 +1,160 @@
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import type { MinimalSessionOrigin } from '@/agent/session-meta'
5
+
6
+ import { replayJsonl } from './replay'
7
+
8
+ export type SessionSummary = {
9
+ sessionId: string
10
+ sessionFile: string
11
+ basename: string
12
+ mtimeMs: number
13
+ origin: MinimalSessionOrigin | null
14
+ firstPrompt: string | null
15
+ }
16
+
17
+ export type ListSessionsOptions = {
18
+ sessionsDir: string
19
+ limit?: number
20
+ sinceMs?: number
21
+ onWarn?: (msg: string) => void
22
+ }
23
+
24
+ // pi-coding-agent writes session files as `${ISO_TIMESTAMP}_${SESSION_ID}.jsonl`,
25
+ // where SESSION_ID is a UUIDv7 by default. Older typeclaw versions (pre-May
26
+ // 2026, before the channel session-file basename was persisted) also produced
27
+ // bare `${SESSION_ID}.jsonl` files; legacy agent folders still carry those
28
+ // alongside the canonical form, and skipping them hides real history from
29
+ // `typeclaw inspect`. Accept both shapes: take whatever follows the last `_`
30
+ // as the id, or the whole stem when no `_` is present. The id must be
31
+ // filesystem-safe (no `/`, `\`, or whitespace) and must start with a non-`_`
32
+ // character so empty-id filenames like `_.jsonl` don't slip through.
33
+ const FILENAME_PATTERN = /^(?:.*_)?([^_/\\\s][^/\\\s]*)\.jsonl$/
34
+
35
+ export async function listSessions(opts: ListSessionsOptions): Promise<SessionSummary[]> {
36
+ const entries = await readSessionFiles(opts.sessionsDir, opts.onWarn)
37
+ const withStats = await Promise.all(
38
+ entries.map(async (entry) => {
39
+ const s = await safeStat(entry.path)
40
+ if (s === null) return null
41
+ const mtimeMs = s.mtimeMs
42
+ if (opts.sinceMs !== undefined && mtimeMs < opts.sinceMs) return null
43
+ return { ...entry, mtimeMs }
44
+ }),
45
+ )
46
+ const valid = withStats.filter(
47
+ (v): v is { path: string; basename: string; sessionId: string; mtimeMs: number } => v !== null,
48
+ )
49
+ valid.sort((a, b) => b.mtimeMs - a.mtimeMs)
50
+ const limited = opts.limit !== undefined ? valid.slice(0, opts.limit) : valid
51
+
52
+ return Promise.all(
53
+ limited.map(async ({ path, basename, sessionId, mtimeMs }) => {
54
+ const peek = await peekSession(path, opts.onWarn)
55
+ return {
56
+ sessionId,
57
+ sessionFile: path,
58
+ basename,
59
+ mtimeMs,
60
+ origin: peek.origin,
61
+ firstPrompt: peek.firstPrompt,
62
+ }
63
+ }),
64
+ )
65
+ }
66
+
67
+ export type ResolveResult =
68
+ | { ok: true; summary: SessionSummary }
69
+ | { ok: false; reason: 'not-found' | 'ambiguous'; matches: SessionSummary[] }
70
+
71
+ const MIN_PREFIX_LENGTH = 4
72
+
73
+ export async function resolveSession(
74
+ sessionsDir: string,
75
+ sessionIdOrPrefix: string,
76
+ onWarn?: (msg: string) => void,
77
+ ): Promise<ResolveResult> {
78
+ const all = await listSessions({ sessionsDir, ...(onWarn !== undefined ? { onWarn } : {}) })
79
+ const exact = all.find((s) => s.sessionId === sessionIdOrPrefix)
80
+ if (exact !== undefined) return { ok: true, summary: exact }
81
+
82
+ if (sessionIdOrPrefix.length < MIN_PREFIX_LENGTH || !isSessionIdShape(sessionIdOrPrefix)) {
83
+ return { ok: false, reason: 'not-found', matches: [] }
84
+ }
85
+ const prefixMatches = all.filter((s) => s.sessionId.startsWith(sessionIdOrPrefix))
86
+ if (prefixMatches.length === 0) return { ok: false, reason: 'not-found', matches: [] }
87
+ if (prefixMatches.length === 1) return { ok: true, summary: prefixMatches[0]! }
88
+ return { ok: false, reason: 'ambiguous', matches: prefixMatches }
89
+ }
90
+
91
+ const SESSION_ID_SHAPE = /^[^_/\\\s][^/\\\s]*$/
92
+
93
+ export function isSessionIdShape(value: string): boolean {
94
+ return SESSION_ID_SHAPE.test(value)
95
+ }
96
+
97
+ async function readSessionFiles(
98
+ dir: string,
99
+ onWarn?: (msg: string) => void,
100
+ ): Promise<{ path: string; basename: string; sessionId: string }[]> {
101
+ let entries
102
+ try {
103
+ entries = await readdir(dir, { withFileTypes: true, encoding: 'utf8' })
104
+ } catch (err) {
105
+ if (isNoEnt(err)) return []
106
+ throw err
107
+ }
108
+ const out: { path: string; basename: string; sessionId: string }[] = []
109
+ for (const entry of entries) {
110
+ const name = entry.name
111
+ if (!name.endsWith('.jsonl')) continue
112
+ if (!entry.isFile() && !entry.isSymbolicLink()) {
113
+ onWarn?.(`skipping non-file in sessions/: ${name}`)
114
+ continue
115
+ }
116
+ const match = FILENAME_PATTERN.exec(name)
117
+ if (!match) {
118
+ onWarn?.(`skipping session file with unexpected name: ${name}`)
119
+ continue
120
+ }
121
+ out.push({ path: join(dir, name), basename: name, sessionId: match[1]! })
122
+ }
123
+ return out
124
+ }
125
+
126
+ async function safeStat(path: string): Promise<{ mtimeMs: number } | null> {
127
+ try {
128
+ const s = await stat(path)
129
+ return { mtimeMs: s.mtimeMs }
130
+ } catch {
131
+ return null
132
+ }
133
+ }
134
+
135
+ const PREVIEW_MAX_BYTES = 64 * 1024
136
+
137
+ async function peekSession(
138
+ path: string,
139
+ onWarn?: (msg: string) => void,
140
+ ): Promise<{ origin: MinimalSessionOrigin | null; firstPrompt: string | null }> {
141
+ let origin: MinimalSessionOrigin | null = null
142
+ let firstPrompt: string | null = null
143
+ let bytesRead = 0
144
+ for await (const event of replayJsonl(path, onWarn !== undefined ? { onWarn } : {})) {
145
+ if (event.cat === 'meta' && origin === null) origin = event.origin
146
+ if (event.cat === 'user' && firstPrompt === null) firstPrompt = event.text
147
+ if (origin !== null && firstPrompt !== null) break
148
+ bytesRead += approximateSize(event)
149
+ if (bytesRead > PREVIEW_MAX_BYTES) break
150
+ }
151
+ return { origin, firstPrompt }
152
+ }
153
+
154
+ function approximateSize(event: { ts: number }): number {
155
+ return JSON.stringify(event).length
156
+ }
157
+
158
+ function isNoEnt(err: unknown): boolean {
159
+ return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'
160
+ }
@@ -0,0 +1,110 @@
1
+ import type { MinimalSessionOrigin } from '@/agent/session-meta'
2
+
3
+ export const INSPECT_CATEGORIES = [
4
+ 'meta',
5
+ 'user',
6
+ 'assistant',
7
+ 'tool',
8
+ 'error',
9
+ 'done',
10
+ 'broadcast',
11
+ 'cron-fire',
12
+ ] as const
13
+ export type InspectCategory = (typeof INSPECT_CATEGORIES)[number]
14
+
15
+ export type InspectEvent =
16
+ | { cat: 'meta'; ts: number; origin: MinimalSessionOrigin }
17
+ | { cat: 'user'; ts: number; text: string }
18
+ | { cat: 'assistant'; ts: number; text: string; provider?: string; model?: string }
19
+ | {
20
+ cat: 'tool'
21
+ ts: number
22
+ phase: 'start' | 'end'
23
+ toolCallId: string
24
+ name: string
25
+ args?: unknown
26
+ result?: unknown
27
+ isError?: boolean
28
+ durationMs?: number
29
+ }
30
+ | { cat: 'error'; ts: number; message: string }
31
+ | {
32
+ cat: 'done'
33
+ ts: number
34
+ stopReason?: string
35
+ input: number
36
+ output: number
37
+ cacheRead: number
38
+ cacheWrite: number
39
+ totalTokens: number
40
+ cost: number
41
+ }
42
+ | { cat: 'broadcast'; ts: number; payload: unknown; meta?: Record<string, string> }
43
+ | { cat: 'cron-fire'; ts: number; jobId: string; payload: unknown }
44
+
45
+ export type InspectFilter = {
46
+ include?: ReadonlySet<InspectCategory>
47
+ exclude?: ReadonlySet<InspectCategory>
48
+ }
49
+
50
+ export type ParsedFilterResult = { ok: true; filter: InspectFilter } | { ok: false; reason: string }
51
+
52
+ export function parseFilter(spec: string | undefined): ParsedFilterResult {
53
+ if (spec === undefined || spec.trim() === '') return { ok: true, filter: {} }
54
+ const include = new Set<InspectCategory>()
55
+ const exclude = new Set<InspectCategory>()
56
+ for (const raw of spec.split(',')) {
57
+ const token = raw.trim()
58
+ if (token === '') continue
59
+ const negated = token.startsWith('!')
60
+ const name = (negated ? token.slice(1) : token).toLowerCase()
61
+ if (!isInspectCategory(name)) {
62
+ return { ok: false, reason: `unknown filter category "${name}" (valid: ${INSPECT_CATEGORIES.join(', ')})` }
63
+ }
64
+ if (negated) exclude.add(name)
65
+ else include.add(name)
66
+ }
67
+ const filter: InspectFilter = {}
68
+ if (include.size > 0) filter.include = include
69
+ if (exclude.size > 0) filter.exclude = exclude
70
+ return { ok: true, filter }
71
+ }
72
+
73
+ export function matchesFilter(event: InspectEvent, filter: InspectFilter): boolean {
74
+ if (filter.exclude?.has(event.cat)) return false
75
+ if (filter.include !== undefined && !filter.include.has(event.cat)) return false
76
+ return true
77
+ }
78
+
79
+ function isInspectCategory(value: string): value is InspectCategory {
80
+ return (INSPECT_CATEGORIES as readonly string[]).includes(value)
81
+ }
82
+
83
+ const DURATION_PATTERN = /^(\d+)(s|m|h|d)$/
84
+
85
+ export type ParsedDurationResult = { ok: true; ms: number } | { ok: false; reason: string }
86
+
87
+ export function parseDuration(spec: string): ParsedDurationResult {
88
+ const match = DURATION_PATTERN.exec(spec.trim())
89
+ if (!match) return { ok: false, reason: `invalid duration "${spec}" (expected forms: 30s, 5m, 1h, 7d)` }
90
+ const value = Number(match[1])
91
+ const unit = match[2]
92
+ let mult: number
93
+ switch (unit) {
94
+ case 's':
95
+ mult = 1000
96
+ break
97
+ case 'm':
98
+ mult = 60_000
99
+ break
100
+ case 'h':
101
+ mult = 3_600_000
102
+ break
103
+ case 'd':
104
+ mult = 86_400_000
105
+ break
106
+ default:
107
+ return { ok: false, reason: `invalid duration unit "${unit}"` }
108
+ }
109
+ return { ok: true, ms: value * mult }
110
+ }
@@ -31,6 +31,21 @@ export const IDLE_HANDLER_TIMEOUT_MS = 25_000
31
31
  // legitimate transcript flush while still bounding the failure mode.
32
32
  export const END_HANDLER_TIMEOUT_MS = 60_000
33
33
 
34
+ // Per-handler ceiling for session.prompt. session.prompt fires
35
+ // synchronously inside createResourceLoader (src/agent/index.ts), which on
36
+ // channel-origin cold start is itself inside ensureLive's 30s watchdog
37
+ // (src/channels/router.ts). An unbounded hook there silently consumes the
38
+ // outer budget and times out without naming the offending plugin in the
39
+ // logs. 20s leaves ~10s of headroom for the rest of the cold-start chain
40
+ // (loadSelf, loadMemory shard reads, prefetchChannelContext) before
41
+ // ensureLive fires.
42
+ //
43
+ // The bundled memory plugin's session.prompt fires memory-retrieval
44
+ // detached, so it returns in <1ms in steady state; this ceiling guards
45
+ // third-party hooks that legitimately need to do work inline, AND a
46
+ // regression in the memory plugin that re-introduces an inline await.
47
+ export const PROMPT_HANDLER_TIMEOUT_MS = 20_000
48
+
34
49
  export type RegisteredHook<K extends keyof Hooks> = {
35
50
  pluginName: string
36
51
  agentDir: string
@@ -59,6 +74,8 @@ export type CreateHookBusOptions = {
59
74
  idleHandlerTimeoutMs?: number
60
75
  // Test seam: per-handler ceiling for session.end invocations.
61
76
  endHandlerTimeoutMs?: number
77
+ // Test seam: per-handler ceiling for session.prompt invocations.
78
+ promptHandlerTimeoutMs?: number
62
79
  }
63
80
 
64
81
  type Registries = {
@@ -75,6 +92,7 @@ type Registries = {
75
92
  export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
76
93
  const idleHandlerTimeoutMs = options.idleHandlerTimeoutMs ?? IDLE_HANDLER_TIMEOUT_MS
77
94
  const endHandlerTimeoutMs = options.endHandlerTimeoutMs ?? END_HANDLER_TIMEOUT_MS
95
+ const promptHandlerTimeoutMs = options.promptHandlerTimeoutMs ?? PROMPT_HANDLER_TIMEOUT_MS
78
96
  const r: Registries = {
79
97
  'session.start': [],
80
98
  'session.end': [],
@@ -155,7 +173,11 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
155
173
  async runSessionPrompt(event) {
156
174
  for (const reg of r['session.prompt']) {
157
175
  try {
158
- await reg.handler(event, ctx(reg))
176
+ await raceWithTimeout(
177
+ Promise.resolve(reg.handler(event, ctx(reg))),
178
+ promptHandlerTimeoutMs,
179
+ `plugin ${reg.pluginName} session.prompt`,
180
+ )
159
181
  } catch (err) {
160
182
  reportHookError(reg, 'session.prompt', err)
161
183
  }
@@ -50,6 +50,8 @@ export type {
50
50
  SessionIdleEvent,
51
51
  SessionPromptEvent,
52
52
  SessionStartEvent,
53
+ SessionTurnEndEvent,
54
+ SessionTurnStartEvent,
53
55
  SpawnSubagentOptions,
54
56
  Subagent,
55
57
  SubagentContext,
@@ -101,7 +101,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
101
101
  config: validatedConfig as never,
102
102
  logger,
103
103
  permissions,
104
- spawnSubagent: (name, payload) => spawnSubagentImpl(name, payload),
104
+ spawnSubagent: (name, payload, options) => spawnSubagentImpl(name, payload, options),
105
105
  isBooted: () => booted,
106
106
  })
107
107
 
@@ -224,7 +224,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
224
224
  // Plugin-contributed jobs default to `owner` because they are part of the
225
225
  // agent's bundled (or operator-installed) runtime, not user-channel
226
226
  // schedules. Without this default they would resolve to `guest` and the
227
- // bundled memory dreaming cron (which writes MEMORY.md, runs git, etc.)
227
+ // bundled memory dreaming cron (which writes memory/topics/, runs git, etc.)
228
228
  // would lose every security bypass. Hand-authored cron.json entries take
229
229
  // a different path and must declare scheduledByRole explicitly.
230
230
  const scheduledByRole: PromptJob['scheduledByRole'] = 'owner'
@@ -167,9 +167,19 @@ export type SessionIdleEvent = {
167
167
  // don't wedge a turn-counter forever. `origin` carries the session's origin
168
168
  // so observers can exclude their own induced turns when counting (e.g. the
169
169
  // backup plugin excludes `subagent: 'backup'` to avoid self-gating).
170
+ //
171
+ // `userPrompt` is the EXACT text being passed to `session.prompt(text)` —
172
+ // the user's message for TUI/channel turns, the cron job's prompt for cron
173
+ // turns, or the rendered initial prompt for subagent turns. Distinct from
174
+ // `SessionPromptEvent.prompt` (the assembling system prompt for cache-safe
175
+ // suffix mutation at session creation time). Channel adapters that prefix
176
+ // inbound text with attribution headers (`[Alice in #general]:`) pass the
177
+ // prefixed string here, matching what `session.prompt(text)` receives;
178
+ // observers that need the unattributed text would need a separate hook.
170
179
  export type SessionTurnStartEvent = {
171
180
  sessionId: string
172
181
  agentDir: string
182
+ userPrompt: string
173
183
  origin?: SessionOrigin
174
184
  }
175
185
 
@@ -1,6 +1,7 @@
1
1
  import { SessionManager } from '@mariozechner/pi-coding-agent'
2
2
 
3
3
  import { createSession as defaultCreateSession } from '@/agent'
4
+ import type { LiveSessionRegistry } from '@/agent/live-sessions'
4
5
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
5
6
  import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagents'
6
7
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
@@ -62,6 +63,7 @@ export type BuildChannelSessionFactoryDeps = {
62
63
  liveSubagentRegistry?: LiveSubagentRegistry
63
64
  subagentRegistry?: SubagentRegistry
64
65
  getCreateSessionForSubagent?: () => CreateSessionForSubagent
66
+ liveSessionRegistry?: LiveSessionRegistry
65
67
  }
66
68
 
67
69
  // Tight basename validation so a tampered or corrupt channels/sessions.json
@@ -129,10 +131,14 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
129
131
  : {}),
130
132
  })
131
133
 
134
+ const sessionId = sessionManager.getSessionId()
135
+ deps.liveSessionRegistry?.register({ sessionId, session })
136
+
132
137
  return {
133
138
  session,
134
- sessionId: sessionManager.getSessionId(),
139
+ sessionId,
135
140
  dispose: async () => {
141
+ deps.liveSessionRegistry?.unregister(sessionId)
136
142
  session.dispose()
137
143
  },
138
144
  ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),