typeclaw 0.7.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.
- package/README.md +15 -9
- package/package.json +5 -3
- package/scripts/dump-system-prompt.ts +12 -1
- package/scripts/require-parallel.ts +41 -0
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +116 -14
- package/src/agent/live-sessions.ts +34 -0
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +97 -13
- package/src/agent/session-meta.ts +21 -2
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/subagent-completion-reminder.ts +89 -0
- package/src/agent/subagents.ts +3 -2
- package/src/agent/system-prompt.ts +49 -15
- package/src/bundled-plugins/explorer/explorer.ts +2 -2
- package/src/bundled-plugins/guard/index.ts +14 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
- package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
- package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
- package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
- package/src/bundled-plugins/guard/policy.ts +7 -0
- package/src/bundled-plugins/memory/README.md +76 -62
- package/src/bundled-plugins/memory/append-tool.ts +3 -2
- package/src/bundled-plugins/memory/citation-superset.ts +49 -11
- package/src/bundled-plugins/memory/citations.ts +19 -8
- package/src/bundled-plugins/memory/delete-tool.ts +57 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
- package/src/bundled-plugins/memory/dreaming.ts +364 -146
- package/src/bundled-plugins/memory/frontmatter.ts +165 -0
- package/src/bundled-plugins/memory/index.ts +236 -16
- package/src/bundled-plugins/memory/injection-plan.ts +15 -0
- package/src/bundled-plugins/memory/load-memory.ts +102 -103
- package/src/bundled-plugins/memory/load-shards.ts +156 -0
- package/src/bundled-plugins/memory/memory-logger.ts +16 -15
- package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
- package/src/bundled-plugins/memory/migration.ts +282 -1
- package/src/bundled-plugins/memory/paths.ts +42 -0
- package/src/bundled-plugins/memory/search-tool.ts +232 -0
- package/src/bundled-plugins/memory/secret-detector.ts +2 -2
- package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
- package/src/bundled-plugins/memory/slug.ts +59 -0
- package/src/bundled-plugins/memory/stream-io.ts +110 -1
- package/src/bundled-plugins/memory/strength.ts +3 -3
- package/src/bundled-plugins/memory/topics.ts +70 -16
- package/src/bundled-plugins/security/index.ts +24 -0
- package/src/bundled-plugins/security/permissions.ts +4 -0
- package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
- package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
- package/src/channels/adapters/kakaotalk.ts +64 -37
- package/src/channels/adapters/slack-bot-classify.ts +2 -27
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/index.ts +5 -0
- package/src/channels/router.ts +328 -18
- package/src/channels/subagent-completion-bridge.ts +84 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/init.ts +122 -14
- package/src/cli/inspect.ts +151 -0
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/cron/consumer.ts +1 -1
- package/src/init/dockerfile.ts +353 -2
- package/src/init/hatching.ts +5 -6
- package/src/init/kakaotalk-auth.ts +6 -47
- package/src/init/validate-api-key.ts +121 -0
- package/src/inspect/index.ts +213 -0
- package/src/inspect/label.ts +50 -0
- package/src/inspect/live.ts +221 -0
- package/src/inspect/render.ts +163 -0
- package/src/inspect/replay.ts +265 -0
- package/src/inspect/session-list.ts +160 -0
- package/src/inspect/types.ts +110 -0
- package/src/plugin/hooks.ts +23 -1
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +1 -1
- package/src/plugin/registry.ts +1 -1
- package/src/plugin/types.ts +10 -0
- package/src/run/channel-session-factory.ts +7 -1
- package/src/run/index.ts +87 -21
- package/src/secrets/kakao-renewal.ts +3 -47
- package/src/server/index.ts +241 -60
- package/src/shared/index.ts +4 -1
- package/src/shared/local-time.ts +17 -0
- package/src/shared/protocol.ts +49 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
- package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
- package/src/skills/typeclaw-config/SKILL.md +38 -33
- package/src/skills/typeclaw-cron/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-memory/SKILL.md +16 -163
- package/src/skills/typeclaw-permissions/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +26 -15
- package/src/test-helpers/wait-for.ts +7 -1
- 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
|
+
}
|
package/src/plugin/hooks.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/plugin/index.ts
CHANGED
package/src/plugin/manager.ts
CHANGED
|
@@ -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
|
|
package/src/plugin/registry.ts
CHANGED
|
@@ -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
|
|
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'
|
package/src/plugin/types.ts
CHANGED
|
@@ -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
|
|
139
|
+
sessionId,
|
|
135
140
|
dispose: async () => {
|
|
141
|
+
deps.liveSessionRegistry?.unregister(sessionId)
|
|
136
142
|
session.dispose()
|
|
137
143
|
},
|
|
138
144
|
...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
|