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.
@@ -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) => {
@@ -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 succeeded =
1257
- context.toolCall.name === 'channel_reply' &&
1258
- !context.isError &&
1259
- (context.result.details as { ok?: unknown } | undefined)?.ok === true
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. The DefaultResourceLoader still
1455
- // renders the session-creation origin into the system prompt (v0.2
1456
- // work to regenerate that per-turn); but permission gating off
1457
- // `lastInboundAuthorId` happens in the tool layer and now sees the
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 two recovery shapes for log triage:
2197
- // - 'leaf': the assistant message IS the leaf (existing behavior; model
2198
- // ended its turn with text but forgot to call channel_reply).
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: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[]; now?: Date } = {
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
- // Two recovery shapes:
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 non-'stop' stopReason (e.g. mid-stream error)
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(session: AgentSession): { text: string; source: 'leaf' | 'pre-tool' } | null {
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 !== 'stop') return null
3032
- return { text: visibleAssistantText(leaf.message), source: 'leaf' }
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
@@ -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 signal = installSigintAbort()
49
- const escListener = isJson ? null : createEscListener()
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(): AbortSignal {
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.signal
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
- function createEscListener(): EscListener | null {
129
- const stdin = process.stdin
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) process.kill(process.pid, '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
- stdin.resume()
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.pause()
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
- const DIRECTORIES = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
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 this file the `packages/`
556
- // workspace root would silently disappear from the initial commit and confuse
557
- // the agent (its workspaces glob would resolve to nothing). The other
558
- // DIRECTORIES are either gitignored (workspace, sessions, mounts) or
559
- // immediately populated, so packages/ is the only one that needs this.
560
- await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
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
@@ -1,2 +1,3 @@
1
1
  export const PACKAGES_DIR = 'packages'
2
+ export const PUBLIC_DIR = 'public'
2
3
  export const GITKEEP_FILE = '.gitkeep'
@@ -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