typeclaw 0.15.1 → 0.16.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 +1 -1
- package/src/agent/index.ts +3 -1
- package/src/agent/plugin-tools.ts +38 -0
- package/src/agent/session-meta.ts +6 -2
- package/src/agent/session-origin.ts +58 -3
- package/src/agent/subagents.ts +6 -1
- package/src/agent/system-prompt.ts +41 -32
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -6
- package/src/bundled-plugins/memory/index.ts +25 -6
- package/src/bundled-plugins/security/index.ts +12 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +215 -0
- package/src/channels/adapters/github/inbound.ts +54 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/router.ts +74 -25
- package/src/cli/inspect.ts +20 -9
- package/src/init/index.ts +19 -9
- package/src/init/paths.ts +1 -0
- package/src/inspect/label.ts +2 -0
- package/src/inspect/live.ts +6 -1
- package/src/inspect/render.ts +8 -2
- package/src/inspect/replay.ts +6 -1
- package/src/inspect/types.ts +4 -1
- package/src/permissions/builtins.ts +12 -0
- package/src/permissions/permissions.ts +7 -0
- package/src/plugin/types.ts +12 -0
- package/src/sandbox/build.ts +19 -1
- package/src/sandbox/hidden-paths.ts +41 -0
- package/src/sandbox/index.ts +2 -1
- package/src/sandbox/policy.ts +15 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -0
- package/src/skills/typeclaw-troubleshooting/SKILL.md +104 -0
- package/src/usage/report.ts +4 -0
- package/src/usage/scan.ts +1 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import type { HiddenPaths } from '@/sandbox'
|
|
5
|
+
|
|
6
|
+
import type { SecurityBlock } from '../policy'
|
|
7
|
+
|
|
8
|
+
export const GUARD_PRIVATE_SURFACE_READ = 'privateSurfaceRead'
|
|
9
|
+
|
|
10
|
+
// bash is excluded: its access to hidden paths is contained by the bwrap
|
|
11
|
+
// sandbox (applyBashSandbox), not by blocking the call. Every OTHER tool is
|
|
12
|
+
// scanned, so a new file-reading tool — bundled or third-party — is covered
|
|
13
|
+
// the day it ships without a whitelist edit. websearch/webfetch take URLs, not
|
|
14
|
+
// local paths, and the path-plausibility filter keeps their args from matching.
|
|
15
|
+
const UNSCANNED_TOOLS = new Set(['bash'])
|
|
16
|
+
|
|
17
|
+
// The bash sandbox hides the role's private surface — the working DIRECTORIES
|
|
18
|
+
// (workspace/, memory/, sessions/) and the secret FILES (.env, secrets.json) —
|
|
19
|
+
// via bwrap masks, but every non-bash tool runs in the main process, outside
|
|
20
|
+
// any sandbox. find_entry, look_at, and the channel attachment tools all read
|
|
21
|
+
// files by a caller-supplied path, so without a guard a restricted role could
|
|
22
|
+
// read back through them exactly what bash masking denies. This guard mirrors
|
|
23
|
+
// the WHOLE deny-list (dirs + files) onto all of them, honouring the PR's
|
|
24
|
+
// "two enforcement points, one deny-list" invariant.
|
|
25
|
+
//
|
|
26
|
+
// It covers the full deny-list rather than delegating secret files to the
|
|
27
|
+
// secretExfilRead guard: that guard only inspects read/grep/find/ls (not
|
|
28
|
+
// edit/write/look_at/channel_send) and is acknowledgement-bypassable, so
|
|
29
|
+
// delegating would leave .env/secrets.json reachable through the uncovered
|
|
30
|
+
// tools — exactly the gap the bash masks close. secretExfilRead remains as
|
|
31
|
+
// independent defense in depth for the four tools it does cover.
|
|
32
|
+
//
|
|
33
|
+
// Posture is FAIL-CLOSED for restricted roles: it does not whitelist a known
|
|
34
|
+
// set of tools (that fails open the moment a new reader is added). It scans
|
|
35
|
+
// every arg of every non-bash tool — recursively, since paths hide in nested
|
|
36
|
+
// shapes like look_at's images[].path and channel_send's attachments[].path —
|
|
37
|
+
// and blocks any string that resolves to (a secret file) or under (a hidden
|
|
38
|
+
// directory) the deny-list.
|
|
39
|
+
export function checkPrivateSurfaceReadGuard(options: {
|
|
40
|
+
tool: string
|
|
41
|
+
args: Record<string, unknown>
|
|
42
|
+
agentDir: string
|
|
43
|
+
hidden: HiddenPaths
|
|
44
|
+
}): SecurityBlock | undefined {
|
|
45
|
+
const { tool, args, agentDir, hidden } = options
|
|
46
|
+
if (UNSCANNED_TOOLS.has(tool)) return undefined
|
|
47
|
+
const deniedDirs = hidden.dirs
|
|
48
|
+
const deniedFiles = hidden.files
|
|
49
|
+
if (deniedDirs.length === 0 && deniedFiles.length === 0) return undefined
|
|
50
|
+
|
|
51
|
+
for (const candidate of collectPathCandidates(args, tool)) {
|
|
52
|
+
const hit = matchHidden(candidate, agentDir, deniedDirs, deniedFiles)
|
|
53
|
+
if (hit !== undefined) {
|
|
54
|
+
return {
|
|
55
|
+
block: true,
|
|
56
|
+
reason: [
|
|
57
|
+
`Guard \`${GUARD_PRIVATE_SURFACE_READ}\` blocked ${tool}: argument \`${candidate}\` resolves to ${hit}, which is hidden from the current role.`,
|
|
58
|
+
'The bash sandbox masks the same path; reaching it through another tool is the same disclosure.',
|
|
59
|
+
].join(' '),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Field names whose values are ALWAYS free text (prose/queries/ids), NEVER a
|
|
67
|
+
// filesystem path, for EVERY tool. Scanning them caused false positives: a
|
|
68
|
+
// guest's `channel_reply({ text: "the memory leak" })` or `websearch({ query:
|
|
69
|
+
// "workspace setup" })` resolve to a bare hidden-dir name and were wrongly
|
|
70
|
+
// blocked. This is a DENYLIST OF KEY NAMES, not a tool whitelist: an unknown
|
|
71
|
+
// field on an unknown tool is still scanned (fail-closed for new path-bearing
|
|
72
|
+
// readers); we only skip values whose KEY is universally free text. `command`
|
|
73
|
+
// is here because bash (its only user) is already exempt via UNSCANNED_TOOLS.
|
|
74
|
+
//
|
|
75
|
+
// `glob` and `pattern` are deliberately ABSENT — they are tool-dependent (a
|
|
76
|
+
// glob/path-filter in grep/find, a regex only in grep) and handled by
|
|
77
|
+
// FREE_TEXT_KEYS_BY_TOOL below.
|
|
78
|
+
const NON_PATH_KEYS = new Set([
|
|
79
|
+
'text',
|
|
80
|
+
'query',
|
|
81
|
+
'prompt',
|
|
82
|
+
'selector',
|
|
83
|
+
'url',
|
|
84
|
+
'message',
|
|
85
|
+
'body',
|
|
86
|
+
'content',
|
|
87
|
+
'command',
|
|
88
|
+
'reason',
|
|
89
|
+
'subject',
|
|
90
|
+
'description',
|
|
91
|
+
'title',
|
|
92
|
+
'name',
|
|
93
|
+
// edit tool: replacement text is free-form and may quote a hidden path.
|
|
94
|
+
'oldText',
|
|
95
|
+
'newText',
|
|
96
|
+
// memory append tool: fragment topic is free text.
|
|
97
|
+
'topic',
|
|
98
|
+
// channel_send/channel_reply attachments[].filename and
|
|
99
|
+
// channel_fetch_attachment.filename: display-only metadata (defaults to the
|
|
100
|
+
// basename of the real `path`), never the file location the guard cares
|
|
101
|
+
// about — `attachments[].path` carries that and is NOT exempted.
|
|
102
|
+
'filename',
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
// Keys that are free text in SPECIFIC tools but path-bearing in others, so a
|
|
106
|
+
// global denylist would either over-block or open a bypass. Scoped per tool:
|
|
107
|
+
// - grep.pattern : a regex/search string (e.g. "sessions"), NOT a path.
|
|
108
|
+
// Notably NOT listed (and therefore SCANNED):
|
|
109
|
+
// - grep.glob / find.pattern : both are glob path-filters resolved RELATIVE
|
|
110
|
+
// to the search root, so `grep({ path: '.', glob: 'workspace/**' })` and
|
|
111
|
+
// `find({ path: '.', pattern: 'workspace/**' })` reach a hidden subtree.
|
|
112
|
+
// Exempting them let the only hidden-identifying arg through (the bypass a
|
|
113
|
+
// review caught). They have no false-positive risk: path.resolve treats
|
|
114
|
+
// glob metacharacters as literal, so `*.ts` -> `/agent/*.ts` (passes) while
|
|
115
|
+
// `workspace/**` -> `/agent/workspace/**` (correctly blocked).
|
|
116
|
+
// Fail-closed: only the listed tool's listed key is exempted; an unknown tool
|
|
117
|
+
// (or grep gaining a new key) scans everything.
|
|
118
|
+
const FREE_TEXT_KEYS_BY_TOOL: Record<string, ReadonlySet<string>> = {
|
|
119
|
+
grep: new Set(['pattern']),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Recursively collects strings that could be paths, skipping values under a
|
|
123
|
+
// universally-free-text key or a tool-scoped free-text key. matchHidden then
|
|
124
|
+
// realpath-resolves each candidate and fires only on one landing inside a
|
|
125
|
+
// hidden directory. Fail-closed by design: a bare path-bearing value equal to a
|
|
126
|
+
// hidden dir name (e.g. `path: "memory"`) is still blocked. `underExempt`
|
|
127
|
+
// propagates so nested values under an exempt key (e.g. a structured pattern)
|
|
128
|
+
// stay exempt; top-level strings and array elements carry no key and are always
|
|
129
|
+
// scanned (so attachments[].path is collected).
|
|
130
|
+
function collectPathCandidates(value: unknown, tool: string): string[] {
|
|
131
|
+
const out: string[] = []
|
|
132
|
+
walk(value, out, tool, false)
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function walk(value: unknown, out: string[], tool: string, underExempt: boolean): void {
|
|
137
|
+
if (typeof value === 'string') {
|
|
138
|
+
if (underExempt) return
|
|
139
|
+
out.push(value)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
for (const item of value) walk(item, out, tool, underExempt)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
if (value !== null && typeof value === 'object') {
|
|
147
|
+
const toolFreeText = FREE_TEXT_KEYS_BY_TOOL[tool]
|
|
148
|
+
for (const [key, item] of Object.entries(value)) {
|
|
149
|
+
const keyIsExempt = NON_PATH_KEYS.has(key) || (toolFreeText?.has(key) ?? false)
|
|
150
|
+
walk(item, out, tool, underExempt || keyIsExempt)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Resolving both sides against agentDir defeats traversal (workspace/../workspace/x),
|
|
156
|
+
// relative forms (./workspace), and absolute restatements. Secret files match on
|
|
157
|
+
// exact equality; hidden directories match the dir itself or anything under it,
|
|
158
|
+
// using a trailing slash so `workspace` does not also match a sibling
|
|
159
|
+
// `workspace-notes`.
|
|
160
|
+
//
|
|
161
|
+
// Symlink defense: lexical path.resolve is NOT enough. A restricted role can
|
|
162
|
+
// plant `public/leak -> ../.env` (or `-> ../memory`) via sandboxed bash, then
|
|
163
|
+
// read it back through a non-bash tool whose path lexically lands in the
|
|
164
|
+
// guest-visible `public/`. So we resolve the candidate's REAL path
|
|
165
|
+
// (realpathRealIntendedPath follows symlinks on every existing path component)
|
|
166
|
+
// before matching. Both sides are realpath'd because agentDir itself may sit
|
|
167
|
+
// under a symlink (e.g. /tmp -> /private/tmp on macOS); comparing a real
|
|
168
|
+
// candidate against a lexical deny-list would never match.
|
|
169
|
+
function matchHidden(
|
|
170
|
+
candidate: string,
|
|
171
|
+
agentDir: string,
|
|
172
|
+
deniedDirs: string[],
|
|
173
|
+
deniedFiles: string[],
|
|
174
|
+
): string | undefined {
|
|
175
|
+
const resolved = realpathRealIntendedPath(path.resolve(agentDir, candidate))
|
|
176
|
+
for (const file of deniedFiles) {
|
|
177
|
+
if (resolved === realpathRealIntendedPath(file)) return file
|
|
178
|
+
}
|
|
179
|
+
for (const dir of deniedDirs) {
|
|
180
|
+
const realDir = realpathRealIntendedPath(dir)
|
|
181
|
+
if (resolved === realDir || resolved.startsWith(`${realDir}/`)) return dir
|
|
182
|
+
}
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Resolves symlinks on the longest existing prefix of an absolute path, then
|
|
187
|
+
// re-appends the non-existent tail. A bare realpathSync throws on a path that
|
|
188
|
+
// does not exist yet (a write target, or a read of a not-yet-created file), so
|
|
189
|
+
// we walk up to the nearest existing ancestor, realpath THAT (collapsing any
|
|
190
|
+
// symlinked component including a planted symlink), and rejoin the remainder.
|
|
191
|
+
// This catches `public/leak/x` where `public/leak` is a symlink into a hidden
|
|
192
|
+
// dir even though `public/leak/x` itself does not exist. Sync (realpathSync)
|
|
193
|
+
// keeps the guard synchronous so the security tool.before check array stays
|
|
194
|
+
// non-async; the cost is one syscall per existing component, negligible at the
|
|
195
|
+
// tool-call boundary. Sync mirror of resolveRealIntendedPath in the guard
|
|
196
|
+
// plugin's non-workspace-write policy.
|
|
197
|
+
function realpathRealIntendedPath(absolutePath: string): string {
|
|
198
|
+
const pending: string[] = []
|
|
199
|
+
let current = absolutePath
|
|
200
|
+
while (true) {
|
|
201
|
+
try {
|
|
202
|
+
return path.join(realpathSync.native(current), ...pending.reverse())
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if (!isNotFoundError(err)) throw err
|
|
205
|
+
}
|
|
206
|
+
const parent = path.dirname(current)
|
|
207
|
+
if (parent === current) return absolutePath
|
|
208
|
+
pending.push(path.basename(current))
|
|
209
|
+
current = parent
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isNotFoundError(err: unknown): boolean {
|
|
214
|
+
return err instanceof Error && 'code' in err && err.code === 'ENOENT'
|
|
215
|
+
}
|
|
@@ -13,6 +13,9 @@ export type GithubWebhookHandlerOptions = {
|
|
|
13
13
|
allowlist: () => readonly string[]
|
|
14
14
|
selfId: () => string | null
|
|
15
15
|
selfLogin: () => string | null
|
|
16
|
+
// Defaults to 'pat' when omitted. Only 'app' promotes an opened PR to a
|
|
17
|
+
// review request; see classifyOpenedAsReview for why.
|
|
18
|
+
authType?: () => 'pat' | 'app'
|
|
16
19
|
route: (message: InboundMessage) => void
|
|
17
20
|
logger: GithubInboundLogger
|
|
18
21
|
// Optional: resolves whether the bot is a member of the given team. When
|
|
@@ -56,6 +59,7 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
56
59
|
const teamIsBotMember = await resolveTeamMembership(event, payload, options)
|
|
57
60
|
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
58
61
|
teamIsBotMember,
|
|
62
|
+
authType: options.authType?.() ?? 'pat',
|
|
59
63
|
})
|
|
60
64
|
if (classified === null) return ok()
|
|
61
65
|
|
|
@@ -77,7 +81,7 @@ export function classifyGithubInbound(
|
|
|
77
81
|
event: string,
|
|
78
82
|
payload: Record<string, unknown>,
|
|
79
83
|
selfLogin: string | null,
|
|
80
|
-
options?: { teamIsBotMember?: boolean },
|
|
84
|
+
options?: { teamIsBotMember?: boolean; authType?: 'pat' | 'app' },
|
|
81
85
|
): InboundMessage | null {
|
|
82
86
|
const repository = readRepository(payload)
|
|
83
87
|
if (repository === null) return null
|
|
@@ -177,6 +181,14 @@ export function classifyGithubInbound(
|
|
|
177
181
|
teamIsBotMember: options?.teamIsBotMember,
|
|
178
182
|
})
|
|
179
183
|
}
|
|
184
|
+
// A GitHub App cannot be added to a PR's requested_reviewers, so it never
|
|
185
|
+
// receives a review_requested event targeting itself. The opened event is
|
|
186
|
+
// the only signal it can act on, so in App mode an opened PR is promoted to
|
|
187
|
+
// a review request. A PAT-backed bot is a real user that can be requested,
|
|
188
|
+
// so it waits for the explicit request instead of reviewing every PR.
|
|
189
|
+
if (action === 'opened' && options?.authType === 'app') {
|
|
190
|
+
return classifyOpenedAsReview({ payload, pr, number, base, selfLogin })
|
|
191
|
+
}
|
|
180
192
|
return buildInbound(
|
|
181
193
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
182
194
|
pr.body,
|
|
@@ -291,6 +303,47 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
|
|
|
291
303
|
}
|
|
292
304
|
}
|
|
293
305
|
|
|
306
|
+
type OpenedAsReviewInput = {
|
|
307
|
+
payload: Record<string, unknown>
|
|
308
|
+
pr: Record<string, unknown>
|
|
309
|
+
number: number
|
|
310
|
+
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
311
|
+
selfLogin: string | null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function classifyOpenedAsReview(input: OpenedAsReviewInput): InboundMessage | null {
|
|
315
|
+
const { payload, pr, number, base, selfLogin } = input
|
|
316
|
+
if (selfLogin === null) return null
|
|
317
|
+
const sender = readUser(payload.sender)
|
|
318
|
+
if (sender === null) return null
|
|
319
|
+
if (sender.login === selfLogin) return null
|
|
320
|
+
|
|
321
|
+
const title = readString(pr, 'title') ?? `#${number}`
|
|
322
|
+
const head = readString(readRecord(pr.head), 'ref')
|
|
323
|
+
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
324
|
+
const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
|
|
325
|
+
const text =
|
|
326
|
+
`@${sender.login} requested your review on PR #${number}: "${title}".${branchSegment}` +
|
|
327
|
+
' Please review the changes line-by-line and post your feedback.'
|
|
328
|
+
|
|
329
|
+
const updatedAt = readString(pr, 'updated_at') ?? ''
|
|
330
|
+
const prId = readNumber(pr, 'id') ?? number
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
...base,
|
|
334
|
+
chat: `pr:${number}`,
|
|
335
|
+
thread: null,
|
|
336
|
+
text,
|
|
337
|
+
externalMessageId: `pr-${prId}-opened-${updatedAt}`,
|
|
338
|
+
authorId: String(sender.id),
|
|
339
|
+
authorName: sender.login,
|
|
340
|
+
authorIsBot: sender.type === 'Bot',
|
|
341
|
+
isBotMention: true,
|
|
342
|
+
replyToBotMessageId: null,
|
|
343
|
+
ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
294
347
|
export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
|
|
295
348
|
|
|
296
349
|
export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
|
|
@@ -128,6 +128,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
128
128
|
allowlist: () => options.configRef().eventAllowlist,
|
|
129
129
|
selfId: () => selfId,
|
|
130
130
|
selfLogin: () => selfLogin,
|
|
131
|
+
authType: () => options.secrets.auth.type,
|
|
131
132
|
isBotInTeam,
|
|
132
133
|
logger,
|
|
133
134
|
route: (message) => {
|
package/src/channels/router.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
|
|
|
3
3
|
import type { AssistantMessage } from '@mariozechner/pi-ai'
|
|
4
4
|
import { SessionManager } from '@mariozechner/pi-coding-agent'
|
|
5
5
|
|
|
6
|
-
import { createSession, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
6
|
+
import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
9
|
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
@@ -1248,16 +1248,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1248
1248
|
// tools and `channel_send` must keep the follow-up so genuine multi-step turns
|
|
1249
1249
|
// continue. A prior non-typeclaw `afterToolCall` (none today) would be
|
|
1250
1250
|
// composed, not clobbered.
|
|
1251
|
+
//
|
|
1252
|
+
// `channel_reply({ continue: true })` is the explicit opt-out: a mid-turn
|
|
1253
|
+
// status reply ("working on it…") that the model follows with more work this
|
|
1254
|
+
// turn. The tool surfaces that intent as `details.continue === true`, and we
|
|
1255
|
+
// keep the follow-up so the turn proceeds. The kimi 32k loop only recurs when
|
|
1256
|
+
// the model genuinely has nothing left to say after a reply, which `continue`
|
|
1257
|
+
// asserts is not the case; Layer 2's maxTokens cap still bounds any misuse.
|
|
1251
1258
|
const installChannelReplyTerminalHook = (live: LiveSession): void => {
|
|
1252
1259
|
const { agent } = live.session
|
|
1253
1260
|
const prior = agent.afterToolCall
|
|
1254
1261
|
agent.afterToolCall = async (context, signal) => {
|
|
1255
1262
|
const result = prior ? await prior(context, signal) : undefined
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
if (succeeded && agent.signal?.aborted !== true) {
|
|
1263
|
+
const details = context.result.details as { ok?: unknown; continue?: unknown } | undefined
|
|
1264
|
+
const succeeded = context.toolCall.name === 'channel_reply' && !context.isError && details?.ok === true
|
|
1265
|
+
const keepTurnAlive = details?.continue === true
|
|
1266
|
+
if (succeeded && !keepTurnAlive && agent.signal?.aborted !== true) {
|
|
1261
1267
|
logger.info(`[channels] ${live.keyId} terminal_after_channel_reply`)
|
|
1262
1268
|
agent.abort()
|
|
1263
1269
|
}
|
|
@@ -1421,11 +1427,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1421
1427
|
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
1422
1428
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1423
1429
|
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1424
|
-
const text = composeTurnPrompt(observed, batch, {
|
|
1425
|
-
adapter: live.key.adapter,
|
|
1426
|
-
loopGuardActive: live.loopGuardActive,
|
|
1427
|
-
systemReminders: reminders,
|
|
1428
|
-
})
|
|
1429
1430
|
|
|
1430
1431
|
if (batch.length > 0) {
|
|
1431
1432
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
@@ -1451,12 +1452,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1451
1452
|
}
|
|
1452
1453
|
|
|
1453
1454
|
// Update the live origin holder so this turn's tool.before events
|
|
1454
|
-
// carry the current actor's id
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1455
|
+
// carry the current actor's id, and resolve the live role from it for
|
|
1456
|
+
// the per-turn <your-role> anchor below. Done BEFORE composeTurnPrompt
|
|
1457
|
+
// so the anchor reflects the speaker of THIS turn, not the session-
|
|
1458
|
+
// creation snapshot the system prompt still renders. Permission gating
|
|
1459
|
+
// off `lastInboundAuthorId` happens in the tool layer and sees the same
|
|
1458
1460
|
// live value.
|
|
1459
1461
|
live.originRef.current = buildLiveOrigin(live)
|
|
1462
|
+
const liveRole = permissions.describe(live.originRef.current).role
|
|
1463
|
+
|
|
1464
|
+
const text = composeTurnPrompt(observed, batch, {
|
|
1465
|
+
adapter: live.key.adapter,
|
|
1466
|
+
loopGuardActive: live.loopGuardActive,
|
|
1467
|
+
systemReminders: reminders,
|
|
1468
|
+
role: liveRole,
|
|
1469
|
+
})
|
|
1460
1470
|
|
|
1461
1471
|
// Bracketing logs around the LLM call so a hung prompt() is
|
|
1462
1472
|
// diagnosable from logs alone (we see prompting without prompted).
|
|
@@ -2193,9 +2203,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2193
2203
|
return
|
|
2194
2204
|
}
|
|
2195
2205
|
|
|
2196
|
-
// `source` distinguishes the
|
|
2197
|
-
// - 'leaf': the assistant message IS the leaf
|
|
2198
|
-
// ended its turn with text but forgot to
|
|
2206
|
+
// `source` distinguishes the three recovery shapes for log triage:
|
|
2207
|
+
// - 'leaf': the assistant message IS the leaf with stopReason 'stop'
|
|
2208
|
+
// (existing behavior; model ended its turn with text but forgot to
|
|
2209
|
+
// call channel_reply).
|
|
2210
|
+
// - 'mid-turn': the assistant message IS the leaf with stopReason
|
|
2211
|
+
// 'toolUse'; the model narrated a reply, committed to a tool plan, and
|
|
2212
|
+
// the turn ended before a follow-up that would have called a channel
|
|
2213
|
+
// tool was persisted. The narration is the only user-facing text.
|
|
2199
2214
|
// - 'pre-tool': the leaf is a toolResult (or other non-assistant entry)
|
|
2200
2215
|
// and the assistant message lives upstream in the branch. This is the
|
|
2201
2216
|
// Kimi-on-Fireworks `kimi-k2p6-turbo` failure mode where the post-tool
|
|
@@ -2528,13 +2543,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2528
2543
|
function composeTurnPrompt(
|
|
2529
2544
|
observed: readonly ObservedInbound[],
|
|
2530
2545
|
batch: readonly QueuedInbound[],
|
|
2531
|
-
state: {
|
|
2546
|
+
state: {
|
|
2547
|
+
adapter?: AdapterId
|
|
2548
|
+
loopGuardActive: boolean
|
|
2549
|
+
systemReminders?: readonly string[]
|
|
2550
|
+
now?: Date
|
|
2551
|
+
role?: string
|
|
2552
|
+
} = {
|
|
2532
2553
|
loopGuardActive: false,
|
|
2533
2554
|
},
|
|
2534
2555
|
): string {
|
|
2535
2556
|
const adapter = state.adapter ?? 'discord-bot'
|
|
2536
2557
|
const parts: string[] = []
|
|
2537
2558
|
parts.push(renderTurnTimeAnchor(state.now), '')
|
|
2559
|
+
const roleAnchor = state.role !== undefined ? renderTurnRoleAnchor(state.role) : undefined
|
|
2560
|
+
if (roleAnchor !== undefined) parts.push(roleAnchor, '')
|
|
2538
2561
|
// System reminders (subagent-completion wakeups today) lead the turn body
|
|
2539
2562
|
// because they are typically what triggered the drain — when the prompt
|
|
2540
2563
|
// queue is empty and the only thing in this iteration is a reminder, the
|
|
@@ -2995,7 +3018,7 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
2995
3018
|
// assistant message — i.e., text the user should see but didn't, because the
|
|
2996
3019
|
// model failed to call `channel_reply`/`channel_send` before its turn ended.
|
|
2997
3020
|
//
|
|
2998
|
-
//
|
|
3021
|
+
// Three recovery shapes:
|
|
2999
3022
|
//
|
|
3000
3023
|
// - source: 'leaf'
|
|
3001
3024
|
// The leaf entry IS an assistant message with `stopReason === 'stop'`.
|
|
@@ -3003,6 +3026,20 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
3003
3026
|
// tool. Pre-existing behavior; this is what the historical
|
|
3004
3027
|
// `latestAssistantText` covered.
|
|
3005
3028
|
//
|
|
3029
|
+
// - source: 'mid-turn'
|
|
3030
|
+
// The leaf IS an assistant message with `stopReason === 'toolUse'` that
|
|
3031
|
+
// carries visible text. The model narrated a user-facing reply ("on it,
|
|
3032
|
+
// bumping to 16x now") AND committed to a tool plan in the same message,
|
|
3033
|
+
// but the turn ended before any follow-up assistant message that would
|
|
3034
|
+
// have called `channel_reply` was persisted — the upstream pi-agent-core
|
|
3035
|
+
// loop's post-tool follow-up never landed, or the run was aborted
|
|
3036
|
+
// mid-loop. The model treated its visible prose as ambient narration; in
|
|
3037
|
+
// a channel session that prose is dead text. Recovers it so the user gets
|
|
3038
|
+
// the reply the model thought it had already given. Observed against
|
|
3039
|
+
// Fireworks' `kimi-k2p6-turbo` on KakaoTalk: the agent posted speed-change
|
|
3040
|
+
// status as narration, kept taking screenshots, and the user saw nothing.
|
|
3041
|
+
// This is the leaf-is-assistant twin of the 'pre-tool' shape below.
|
|
3042
|
+
//
|
|
3006
3043
|
// - source: 'pre-tool'
|
|
3007
3044
|
// The leaf is a `toolResult` and the immediately-prior assistant message
|
|
3008
3045
|
// has `stopReason === 'toolUse'` (it called the tool that produced this
|
|
@@ -3014,22 +3051,34 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
|
|
|
3014
3051
|
//
|
|
3015
3052
|
// Returns null when no recovery is appropriate:
|
|
3016
3053
|
// - No leaf, no messages in branch, branch is malformed
|
|
3017
|
-
// - Leaf is an assistant with
|
|
3054
|
+
// - Leaf is an assistant with `stopReason` of 'length' / 'error' / 'aborted'
|
|
3018
3055
|
// and is NOT preceded by a toolResult pattern — we don't recover partial
|
|
3019
3056
|
// errored output because it's typically a truncation, not a deliberate
|
|
3020
|
-
// reply
|
|
3057
|
+
// reply. Only 'stop' (turn-complete) and 'toolUse' (committed to a tool
|
|
3058
|
+
// plan, prose stranded) signal text the model meant for the user.
|
|
3021
3059
|
// - Leaf is a user/system message (model hasn't responded yet)
|
|
3022
3060
|
//
|
|
3023
3061
|
// `visibleAssistantText` returning '' (empty string) is a valid recovery
|
|
3024
3062
|
// target — the caller's downstream guards (`endsWithNoReplySignal('')` returns
|
|
3025
3063
|
// true) handle the no-content case explicitly via the `no_reply` log.
|
|
3026
|
-
function recoverableAssistantText(
|
|
3064
|
+
function recoverableAssistantText(
|
|
3065
|
+
session: AgentSession,
|
|
3066
|
+
): { text: string; source: 'leaf' | 'mid-turn' | 'pre-tool' } | null {
|
|
3027
3067
|
const leaf = session.sessionManager.getLeafEntry()
|
|
3028
3068
|
if (!leaf) return null
|
|
3029
3069
|
|
|
3030
3070
|
if (leaf.type === 'message' && leaf.message.role === 'assistant') {
|
|
3031
|
-
if (leaf.message.stopReason
|
|
3032
|
-
|
|
3071
|
+
if (leaf.message.stopReason === 'stop') {
|
|
3072
|
+
return { text: visibleAssistantText(leaf.message), source: 'leaf' }
|
|
3073
|
+
}
|
|
3074
|
+
// The model committed to a tool plan but its visible prose never reached
|
|
3075
|
+
// the channel and no follow-up message that would have called a channel
|
|
3076
|
+
// tool was persisted. Recover the stranded prose. Other non-'stop' stop
|
|
3077
|
+
// reasons (length/error/aborted) are truncations, not deliberate replies.
|
|
3078
|
+
if (leaf.message.stopReason === 'toolUse') {
|
|
3079
|
+
return { text: visibleAssistantText(leaf.message), source: 'mid-turn' }
|
|
3080
|
+
}
|
|
3081
|
+
return null
|
|
3033
3082
|
}
|
|
3034
3083
|
|
|
3035
3084
|
// Pre-tool recovery: the leaf must be a toolResult message, and walking
|
package/src/cli/inspect.ts
CHANGED
|
@@ -45,8 +45,12 @@ export const inspectCommand = defineCommand({
|
|
|
45
45
|
|
|
46
46
|
const isJson = args.json === true
|
|
47
47
|
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
48
|
-
const
|
|
49
|
-
const
|
|
48
|
+
const signalCtrl = installSigintAbort()
|
|
49
|
+
const signal = signalCtrl.signal
|
|
50
|
+
// Raw-mode Ctrl-C arrives as byte 0x03 and must abort the exit controller
|
|
51
|
+
// directly: under Bun a self-issued process.kill(SIGINT) does not reliably
|
|
52
|
+
// re-enter our process.once('SIGINT') handler, so the live tail never exits.
|
|
53
|
+
const escListener = isJson ? null : createEscListener(() => signalCtrl.abort())
|
|
50
54
|
const liveHint = escListener === null ? undefined : escHintLine(color)
|
|
51
55
|
|
|
52
56
|
// try/finally so a thrown loop never leaves the terminal stuck in raw mode.
|
|
@@ -108,14 +112,14 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
108
112
|
})
|
|
109
113
|
}
|
|
110
114
|
|
|
111
|
-
function installSigintAbort():
|
|
115
|
+
function installSigintAbort(): AbortController {
|
|
112
116
|
const ctrl = new AbortController()
|
|
113
117
|
const onSig = (): void => {
|
|
114
118
|
ctrl.abort()
|
|
115
119
|
}
|
|
116
120
|
process.once('SIGINT', onSig)
|
|
117
121
|
process.once('SIGTERM', onSig)
|
|
118
|
-
return ctrl
|
|
122
|
+
return ctrl
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
type EscListener = {
|
|
@@ -125,8 +129,10 @@ type EscListener = {
|
|
|
125
129
|
stop: () => void
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
133
|
+
|
|
134
|
+
export function createEscListener(onSigint: () => void, input: RawInput = process.stdin): EscListener | null {
|
|
135
|
+
const stdin = input
|
|
130
136
|
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
131
137
|
|
|
132
138
|
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
@@ -134,15 +140,17 @@ function createEscListener(): EscListener | null {
|
|
|
134
140
|
|
|
135
141
|
const onData = (chunk: Buffer): void => {
|
|
136
142
|
const { sigint } = ctrl.onChunk(chunk)
|
|
137
|
-
if (sigint)
|
|
143
|
+
if (sigint) onSigint()
|
|
138
144
|
}
|
|
139
145
|
|
|
140
146
|
const start = (): void => {
|
|
141
147
|
if (active) return
|
|
142
148
|
active = true
|
|
143
149
|
stdin.setRawMode(true)
|
|
144
|
-
|
|
150
|
+
// Attach the data handler before resume() so no raw-mode keystroke can slip
|
|
151
|
+
// through between resuming the stream and registering the listener.
|
|
145
152
|
stdin.on('data', onData)
|
|
153
|
+
stdin.resume()
|
|
146
154
|
}
|
|
147
155
|
const stop = (): void => {
|
|
148
156
|
if (!active) return
|
|
@@ -153,7 +161,10 @@ function createEscListener(): EscListener | null {
|
|
|
153
161
|
} catch {
|
|
154
162
|
/* terminal already torn down */
|
|
155
163
|
}
|
|
156
|
-
stdin
|
|
164
|
+
// Do NOT pause stdin here: this teardown hands control to the clack picker,
|
|
165
|
+
// and under Bun clack does not reliably re-flow a previously paused
|
|
166
|
+
// process.stdin, so its keypresses never arrive and arrow keys echo as raw
|
|
167
|
+
// bytes. Leaving the stream flowing lets clack own raw mode during the picker.
|
|
157
168
|
ctrl.clearPending()
|
|
158
169
|
}
|
|
159
170
|
|
package/src/init/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } fr
|
|
|
23
23
|
import { buildGitignore, GITIGNORE_FILE } from './gitignore'
|
|
24
24
|
import { buildHatchingPrompt } from './hatching'
|
|
25
25
|
import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
|
|
26
|
-
import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
26
|
+
import { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
|
|
27
27
|
import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
28
28
|
|
|
29
29
|
export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
|
|
@@ -31,7 +31,7 @@ export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun
|
|
|
31
31
|
export type { EagerGithubWebhookInstallResult } from './github-webhook-install'
|
|
32
32
|
export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } from './github-webhook-install'
|
|
33
33
|
|
|
34
|
-
export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
|
|
34
|
+
export { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
|
|
35
35
|
|
|
36
36
|
export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
|
|
37
37
|
|
|
@@ -55,7 +55,15 @@ const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as con
|
|
|
55
55
|
// stay in `workspace/`. The directory is scaffolded empty so the layout is
|
|
56
56
|
// discoverable on day one; a `.gitkeep` is written below so it survives the
|
|
57
57
|
// initial commit.
|
|
58
|
-
|
|
58
|
+
//
|
|
59
|
+
// `public/` is a top-level sibling, NOT `workspace/public/`, on purpose:
|
|
60
|
+
// role-based path hiding (src/sandbox/hidden-paths.ts) masks `workspace/` from
|
|
61
|
+
// the guest tier but never masks `public/`, so `public/` is the one place a
|
|
62
|
+
// guest turn can read and write. `workspace/` is an arbitrary free-write zone
|
|
63
|
+
// with no reserved subdir names; a magic `workspace/public/` would silently
|
|
64
|
+
// expose any subdir an agent happened to name `public`. A root sibling keeps
|
|
65
|
+
// the deny-list flat (no carve-out) and the public/private split legible.
|
|
66
|
+
const DIRECTORIES = ['workspace', 'public', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
|
|
59
67
|
|
|
60
68
|
export type GitInitResult = { ok: true; skipped: boolean } | { ok: false; reason: string }
|
|
61
69
|
export type DockerAssetsResult = { ok: true; devMode: boolean } | { ok: false; reason: string }
|
|
@@ -552,12 +560,14 @@ export type ScaffoldOptions = {
|
|
|
552
560
|
export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
|
|
553
561
|
await Promise.all(DIRECTORIES.map((dir) => mkdir(join(root, dir), { recursive: true })))
|
|
554
562
|
|
|
555
|
-
// git does not track empty directories, so without
|
|
556
|
-
// workspace root
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
563
|
+
// git does not track empty directories, so without these files the empty
|
|
564
|
+
// `packages/` (a bun workspace root) and `public/` (the guest-visible zone)
|
|
565
|
+
// would silently disappear from the initial commit. The other DIRECTORIES are
|
|
566
|
+
// either gitignored (workspace, sessions, mounts) or immediately populated.
|
|
567
|
+
await Promise.all([
|
|
568
|
+
writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists),
|
|
569
|
+
writeFile(join(root, PUBLIC_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists),
|
|
570
|
+
])
|
|
561
571
|
|
|
562
572
|
// Only fields without sensible defaults elsewhere are emitted. Everything
|
|
563
573
|
// with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
|
package/src/init/paths.ts
CHANGED
package/src/inspect/label.ts
CHANGED
|
@@ -20,6 +20,8 @@ export function originLabel(origin: MinimalSessionOrigin): string {
|
|
|
20
20
|
return `Subagent ${origin.subagent} ← ${shortSessionId(origin.parentSessionId)}`
|
|
21
21
|
case 'channel':
|
|
22
22
|
return channelLabel(origin)
|
|
23
|
+
case 'system':
|
|
24
|
+
return `System ${origin.component}`
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|