@leviyuan/lodestar 0.2.8 → 0.2.9

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.
@@ -59,6 +59,15 @@ export interface ClaudeResultMeta {
59
59
  duration_ms: number | null
60
60
  num_turns: number | null
61
61
  usage: ClaudeUsage | null
62
+ /** SDK `result.subtype` — `success` on natural end_turn,
63
+ * `error_max_turns` if --max-turns was reached, `error_during_execution`
64
+ * for API-side failures (rate limit / 5xx / context overflow / refusal).
65
+ * Session.wireProc uses this to drive auto-retry + suppress phone push
66
+ * on aborted turns. */
67
+ subtype: string | null
68
+ /** Mirror of `subtype !== 'success'`; cached so callers don't have to
69
+ * re-derive it from the (open-ended) subtype string. */
70
+ is_error: boolean
62
71
  }
63
72
 
64
73
  export interface HookCallbackRequest {
@@ -92,13 +101,18 @@ export class ClaudeProcess extends EventEmitter {
92
101
  * turn" stats even while idle. */
93
102
  lastResult: ClaudeResultMeta = {
94
103
  cost_usd: null, duration_ms: null, num_turns: null, usage: null,
104
+ subtype: null, is_error: false,
95
105
  }
96
106
  /** Context-window capacity of the model that ran the latest turn —
97
107
  * lifted from `result.modelUsage[model].contextWindow` so we don't
98
- * have to hardcode `[1m]` vs stock variants. 200K is the safe
99
- * default if no result has landed yet (e.g. between spawn and the
100
- * first turn close). */
101
- lastContextWindow: number = 200_000
108
+ * have to hardcode `[1m]` vs stock variants. `null` until the first
109
+ * `result` event lands (spawn first turn close gap). Callers that
110
+ * render a percentage MUST treat null as "unknown" and drop the %
111
+ * rather than substituting a default — past life of this field was a
112
+ * 200K constant default which made fresh-spawn `hi` panels lie about
113
+ * the limit on this project's opus-4-7[1m] (1M) pin. (no_fallbacks
114
+ * rule applies: surface absence, don't fabricate.) */
115
+ lastContextWindow: number | null = null
102
116
 
103
117
  constructor(opts: SpawnOpts) {
104
118
  super()
@@ -242,11 +256,19 @@ export class ClaudeProcess extends EventEmitter {
242
256
  return
243
257
  }
244
258
  if (type === 'result') {
259
+ const subtype = typeof msg.subtype === 'string' ? msg.subtype : null
245
260
  this.lastResult = {
246
261
  cost_usd: typeof msg.total_cost_usd === 'number' ? msg.total_cost_usd : null,
247
262
  duration_ms: typeof msg.duration_ms === 'number' ? msg.duration_ms : null,
248
263
  num_turns: typeof msg.num_turns === 'number' ? msg.num_turns : null,
249
264
  usage: msg.usage ?? null,
265
+ subtype,
266
+ // Trust the SDK's own bool when present; otherwise derive from
267
+ // subtype so a future error_* variant we haven't seen still flags
268
+ // as error.
269
+ is_error: typeof msg.is_error === 'boolean'
270
+ ? msg.is_error
271
+ : (subtype !== null && subtype !== 'success'),
250
272
  }
251
273
  // modelUsage maps "<model id>" → { contextWindow, maxOutputTokens, … }.
252
274
  // For mixed-model runs the SDK reports one entry per model used in
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Read config.toml — minimal hand-rolled parser sufficient for the
3
- * two-section, scalar-value-only schema we expect:
3
+ * three-section, scalar-value-only schema we expect:
4
4
  *
5
5
  * [feishu]
6
6
  * app_id = "cli_..."
@@ -9,6 +9,10 @@
9
9
  * [runtime]
10
10
  * projects_root = "~/" # optional, defaults to $HOME
11
11
  *
12
+ * [notify] # all optional
13
+ * bind = "127.0.0.1" # default 127.0.0.1 (loopback only)
14
+ * port = 9876 # default 9876
15
+ *
12
16
  * Loaded synchronously at import time; downstream modules read the
13
17
  * exported `config` object directly.
14
18
  */
@@ -25,6 +29,10 @@ export interface LodestarConfig {
25
29
  runtime: {
26
30
  projects_root: string
27
31
  }
32
+ notify: {
33
+ bind: string
34
+ port: number
35
+ }
28
36
  }
29
37
 
30
38
  function expandTilde(v: string): string {
@@ -74,9 +82,16 @@ function loadConfig(): LodestarConfig {
74
82
  throw new Error(`lodestar: ${CONFIG_FILE} is missing [feishu].app_id / [feishu].app_secret`)
75
83
  }
76
84
  const projectsRoot = expandTilde(t.runtime?.projects_root ?? homedir())
85
+ const notifyBind = t.notify?.bind ?? '127.0.0.1'
86
+ const notifyPortRaw = t.notify?.port ?? '9876'
87
+ const notifyPort = Number.parseInt(notifyPortRaw, 10)
88
+ if (!Number.isFinite(notifyPort) || notifyPort <= 0 || notifyPort > 65535) {
89
+ throw new Error(`lodestar: [notify].port must be 1..65535, got "${notifyPortRaw}"`)
90
+ }
77
91
  return {
78
92
  feishu: { app_id: appId, app_secret: appSecret },
79
93
  runtime: { projects_root: projectsRoot },
94
+ notify: { bind: notifyBind, port: notifyPort },
80
95
  }
81
96
  }
82
97
 
package/src/feishu.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import * as lark from '@larksuiteoapi/node-sdk'
10
10
  import { execSync } from 'node:child_process'
11
11
  import { randomUUID } from 'node:crypto'
12
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
12
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
13
13
  import { homedir } from 'node:os'
14
14
  import { basename, extname, join } from 'node:path'
15
15
  import { config } from './config'
@@ -359,31 +359,6 @@ function looksLikeImage(filePath: string): boolean {
359
359
  return IMAGE_EXTS.has(extname(filePath).toLowerCase())
360
360
  }
361
361
 
362
- /** Defense against prompt-injection-driven exfiltration via the
363
- * [[send: /path]] marker. The resolved (symlink-followed) real path
364
- * must sit inside one of the explicitly-trusted roots. Anything
365
- * outside — `/etc/*`, `~/.ssh`, `~/.config/lodestar/config.toml`,
366
- * `~/.claude.json`, the user's home dotfiles — is rejected, even if a
367
- * symlink in an allowed root points to it.
368
- *
369
- * Returns [realPath, ''] on accept, [null, reason] on reject. */
370
- function gateOutboundPath(rawPath: string, allowedRoots: string[]): [string | null, string] {
371
- let real: string
372
- try { real = realpathSync(rawPath) }
373
- catch (e) { return [null, `realpath failed: ${e instanceof Error ? e.message : e}`] }
374
- for (const root of allowedRoots) {
375
- if (!root) continue
376
- if (real === root) return [real, '']
377
- const prefix = root.endsWith('/') ? root : root + '/'
378
- if (real.startsWith(prefix)) return [real, '']
379
- // Special-case the `/tmp/lodestar-` namespace: caller can pass
380
- // the bare prefix without a trailing slash to mean "any tmp file
381
- // whose basename starts with lodestar-".
382
- if (root.endsWith('-') && real.startsWith(root)) return [real, '']
383
- }
384
- return [null, `not inside any allowed root (real=${real})`]
385
- }
386
-
387
362
  async function uploadImageMultipart(filePath: string): Promise<string | null> {
388
363
  const token = await getTenantToken()
389
364
  const file = Bun.file(filePath)
@@ -453,49 +428,41 @@ export async function sendFile(chatId: string, fileKey: string): Promise<string
453
428
  }
454
429
 
455
430
  /** Upload a local file and post it as an image or file message in the
456
- * chat. Type is inferred from extension. The path MUST resolve inside
457
- * one of `allowedRoots` (defense against prompt-injection-driven
458
- * exfiltration via the `[[send: /path]]` marker see isPathAllowed).
459
- * Returns true on success. All failures (missing file, oversize,
460
- * outside allowed roots, upload reject, send reject) log and surface
461
- * an inline error message in the chat so the user knows. */
462
- export async function uploadAndSend(chatId: string, filePath: string, allowedRoots: string[]): Promise<boolean> {
463
- const [realPath, reason] = gateOutboundPath(filePath, allowedRoots)
464
- if (!realPath) {
465
- log(`feishu: uploadAndSend REJECTED ${filePath}: ${reason}`)
466
- await sendText(chatId, `❌ 出站文件被拒绝(路径不在允许范围): ${filePath}`)
467
- return false
468
- }
431
+ * chat. Type is inferred from extension. Returns true on success.
432
+ * All failures (missing file, oversize, upload reject, send reject)
433
+ * log and surface an inline error message in the chat so the user
434
+ * knows. */
435
+ export async function uploadAndSend(chatId: string, filePath: string): Promise<boolean> {
469
436
  try {
470
- const stats = statSync(realPath)
437
+ const stats = statSync(filePath)
471
438
  if (!stats.isFile()) {
472
439
  await sendText(chatId, `❌ 出站文件: 路径不是文件 — ${filePath}`)
473
440
  return false
474
441
  }
475
442
  if (stats.size > MAX_UPLOAD_BYTES) {
476
- await sendText(chatId, `❌ 出站文件: ${basename(realPath)} 超过 30 MB (${(stats.size / 1024 / 1024).toFixed(1)} MB)`)
443
+ await sendText(chatId, `❌ 出站文件: ${basename(filePath)} 超过 30 MB (${(stats.size / 1024 / 1024).toFixed(1)} MB)`)
477
444
  return false
478
445
  }
479
446
  } catch (e) {
480
447
  await sendText(chatId, `❌ 出站文件: 无法读取 ${filePath} (${e})`)
481
448
  return false
482
449
  }
483
- const isImage = looksLikeImage(realPath)
450
+ const isImage = looksLikeImage(filePath)
484
451
  try {
485
452
  if (isImage) {
486
- const key = await uploadImageMultipart(realPath)
487
- if (!key) { await sendText(chatId, `❌ 出站图片上传失败: ${basename(realPath)}`); return false }
453
+ const key = await uploadImageMultipart(filePath)
454
+ if (!key) { await sendText(chatId, `❌ 出站图片上传失败: ${basename(filePath)}`); return false }
488
455
  const msgId = await sendImage(chatId, key)
489
456
  return msgId != null
490
457
  } else {
491
- const key = await uploadFileMultipart(realPath)
492
- if (!key) { await sendText(chatId, `❌ 出站文件上传失败: ${basename(realPath)}`); return false }
458
+ const key = await uploadFileMultipart(filePath)
459
+ if (!key) { await sendText(chatId, `❌ 出站文件上传失败: ${basename(filePath)}`); return false }
493
460
  const msgId = await sendFile(chatId, key)
494
461
  return msgId != null
495
462
  }
496
463
  } catch (e) {
497
464
  log(`feishu: uploadAndSend ${filePath} failed: ${e}`)
498
- await sendText(chatId, `❌ 出站文件异常: ${basename(realPath)} — ${e}`)
465
+ await sendText(chatId, `❌ 出站文件异常: ${basename(filePath)} — ${e}`)
499
466
  return false
500
467
  }
501
468
  }
package/src/notify.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Outbound notification HTTP endpoint — any local process can POST a
3
+ * markdown message + project name and it lands in the matching Feishu
4
+ * group as a one-shot interactive card (non-streaming).
5
+ *
6
+ * Default bind: 127.0.0.1:9876. No auth — the daemon trusts loopback
7
+ * on the assumption that anything able to hit this port is already
8
+ * owner-equivalent (same security tier as the bot's stdin and the
9
+ * debug.sock). Do NOT bind 0.0.0.0 without adding your own front-end
10
+ * auth.
11
+ *
12
+ * POST /notify
13
+ * Content-Type: application/json
14
+ * { "project": "feishu",
15
+ * "text": "**build done** 12 files",
16
+ * "title": "build", // optional, default = project
17
+ * "level": "info" | "warn" | "error" // optional, default "info"
18
+ * }
19
+ * → 200 { ok: true, chat_id, message_id }
20
+ * → 400 bad/empty json or missing field
21
+ * → 404 project not bound to any Feishu group
22
+ * → 502 feishu sendCard failed (network / API rejection)
23
+ *
24
+ * GET / → plain-text help (so a `curl` from the shell reports a
25
+ * live server instead of 404).
26
+ *
27
+ * "project" must match a Feishu group name that the daemon already
28
+ * has a chat_id binding for — usually established by sending any
29
+ * message in that group at least once after the daemon started. Run
30
+ * `bun scripts/test-inject.ts "hi"` from the project root if you
31
+ * need to seed the binding without leaving the keyboard.
32
+ */
33
+
34
+ import { log } from './log'
35
+ import * as feishu from './feishu'
36
+
37
+ type Level = 'info' | 'warn' | 'error'
38
+ const VALID_LEVELS: ReadonlySet<Level> = new Set(['info', 'warn', 'error'])
39
+
40
+ function notifyCard(opts: { title: string; text: string; level: Level }): object {
41
+ const template = opts.level === 'error' ? 'red'
42
+ : opts.level === 'warn' ? 'yellow'
43
+ : 'blue'
44
+ const emoji = opts.level === 'error' ? '❌'
45
+ : opts.level === 'warn' ? '⚠️'
46
+ : '🔔'
47
+ const d = new Date()
48
+ const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
49
+ return {
50
+ schema: '2.0',
51
+ config: {},
52
+ header: {
53
+ title: { tag: 'plain_text', content: `${emoji} ${opts.title}` },
54
+ template,
55
+ },
56
+ body: {
57
+ elements: [
58
+ { tag: 'markdown', content: opts.text || '_(空消息)_' },
59
+ { tag: 'hr' },
60
+ { tag: 'markdown', content: `<font color='grey'>via notify · ${hhmm}</font>` },
61
+ ],
62
+ },
63
+ }
64
+ }
65
+
66
+ export interface NotifyOptions {
67
+ bind: string
68
+ port: number
69
+ }
70
+
71
+ export function startNotifyServer(opts: NotifyOptions): void {
72
+ try {
73
+ Bun.serve({
74
+ hostname: opts.bind,
75
+ port: opts.port,
76
+ fetch: async (req: Request) => {
77
+ const url = new URL(req.url)
78
+ if (req.method === 'GET' && url.pathname === '/') {
79
+ return new Response(
80
+ 'lodestar notify\n' +
81
+ 'POST /notify body={project,text,title?,level?}\n' +
82
+ 'levels: info|warn|error (default info)\n',
83
+ { headers: { 'content-type': 'text/plain; charset=utf-8' } },
84
+ )
85
+ }
86
+ if (req.method !== 'POST' || url.pathname !== '/notify') {
87
+ return new Response('use POST /notify', { status: 405 })
88
+ }
89
+
90
+ let body: any = {}
91
+ try { body = await req.json() } catch {
92
+ return new Response('bad json', { status: 400 })
93
+ }
94
+ const project = String(body.project ?? '').trim()
95
+ const text = String(body.text ?? '')
96
+ const titleRaw = String(body.title ?? '').trim()
97
+ const levelRaw = String(body.level ?? 'info').toLowerCase()
98
+ if (!project) return new Response('missing "project"', { status: 400 })
99
+ if (!text) return new Response('missing "text"', { status: 400 })
100
+
101
+ const level: Level = (VALID_LEVELS.has(levelRaw as Level) ? levelRaw : 'info') as Level
102
+ const title = titleRaw || project
103
+
104
+ const sessionName = feishu.sanitizeSessionName(project)
105
+ const chatId = feishu.chatIdForSession(sessionName)
106
+ if (!chatId) {
107
+ log(`notify: project "${project}" (sanitized "${sessionName}") has no chat binding → 404`)
108
+ return new Response(
109
+ `project "${project}" not bound — send any message in that Feishu group at least once after the daemon started, then retry`,
110
+ { status: 404 },
111
+ )
112
+ }
113
+
114
+ const card = notifyCard({ title, text, level })
115
+ const messageId = await feishu.sendCard(chatId, card)
116
+ if (!messageId) {
117
+ log(`notify: sendCard failed → 502 (project="${project}" chat=${chatId.slice(0, 8)}…)`)
118
+ return new Response('feishu sendCard failed (see daemon log)', { status: 502 })
119
+ }
120
+ log(`notify: → ${project} (${chatId.slice(0, 8)}…) level=${level} bytes=${text.length} msg=${messageId}`)
121
+ return Response.json({ ok: true, chat_id: chatId, message_id: messageId })
122
+ },
123
+ error: (err: Error) => {
124
+ log(`notify: handler crash: ${err.message}`)
125
+ return new Response('internal error', { status: 500 })
126
+ },
127
+ })
128
+ log(`notify: HTTP listening at http://${opts.bind}:${opts.port}/notify`)
129
+ } catch (e) {
130
+ log(`notify: server bind failed (${opts.bind}:${opts.port}): ${e}`)
131
+ }
132
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * AskUserQuestion flow split out of session.ts. The SDK routes
3
+ * AskUserQuestion through can_use_tool even under bypass mode, so the
4
+ * "answered" state lives across two SDK control messages — option
5
+ * clicks/custom text land via Feishu callbacks first, then
6
+ * can_use_tool arrives and we finalize with `updatedInput.answers`.
7
+ */
8
+
9
+ import type { Session } from './session'
10
+ import * as cardkit from './cardkit'
11
+ import * as cards from './cards'
12
+ import { log } from './log'
13
+
14
+ /** True iff there's at least one open AskUserQuestion awaiting an
15
+ * answer in this session. `daemon.handleMessage` uses this to
16
+ * decide whether an inbound chat message should be a custom answer
17
+ * (routed to onAskMessageAnswer) instead of opening a new turn. */
18
+ export function hasPendingAsk(s: Session): boolean {
19
+ return s.pendingAsks.size > 0
20
+ }
21
+
22
+ /** Funnel an arbitrary chat message into the *current* question
23
+ * of the oldest pending ask as a `customText` answer. Multi-
24
+ * question semantics: from the user's perspective, the chat
25
+ * input always answers whatever question is on screen right now
26
+ * (`pending.currentIdx`), and a new question slides in after. */
27
+ export async function onAskMessageAnswer(s: Session, text: string, user: string): Promise<void> {
28
+ const firstEntry = s.pendingAsks.entries().next()
29
+ if (firstEntry.done) {
30
+ log(`session "${s.sessionName}": onAskMessageAnswer with no pending — falling back to onUserMessage`)
31
+ await s.onUserMessage(text)
32
+ return
33
+ }
34
+ const [toolUseId, pending] = firstEntry.value
35
+ if (pending.currentIdx === undefined) {
36
+ log(`session "${s.sessionName}": pending ask ${toolUseId} already terminal — ignoring message`)
37
+ return
38
+ }
39
+ await onAskCustomAnswer(s, toolUseId, pending.currentIdx, text, user)
40
+ }
41
+
42
+ /** Click handler for an option button. The click must target the
43
+ * question currently on screen (`pending.currentIdx`); a stale
44
+ * click (e.g. user clicked an older render before it swapped in
45
+ * the next question) is logged and dropped — better than double-
46
+ * answering. */
47
+ export async function onAskAnswer(
48
+ s: Session,
49
+ toolUseId: string,
50
+ questionIdx: number,
51
+ optionIdx: number,
52
+ user: string,
53
+ ): Promise<void> {
54
+ const pending = s.pendingAsks.get(toolUseId)
55
+ if (!pending) { log(`session "${s.sessionName}": stray ask answer for ${toolUseId}`); return }
56
+ if (questionIdx !== pending.currentIdx) {
57
+ log(`session "${s.sessionName}": stale ask click q=${questionIdx} current=${pending.currentIdx}`)
58
+ return
59
+ }
60
+ advanceAsk(s, toolUseId, { optionIdx, user })
61
+ }
62
+
63
+ /** Custom-text branch. Same staleness rule as onAskAnswer; empty
64
+ * input is silently ignored (panel stays pending). */
65
+ export async function onAskCustomAnswer(
66
+ s: Session,
67
+ toolUseId: string,
68
+ questionIdx: number,
69
+ customText: string,
70
+ user: string,
71
+ ): Promise<void> {
72
+ const pending = s.pendingAsks.get(toolUseId)
73
+ if (!pending) { log(`session "${s.sessionName}": stray ask custom for ${toolUseId}`); return }
74
+ const trimmed = (customText ?? '').trim()
75
+ if (!trimmed) { log(`session "${s.sessionName}": empty custom answer, ignoring`); return }
76
+ if (questionIdx !== pending.currentIdx) {
77
+ log(`session "${s.sessionName}": stale ask custom q=${questionIdx} current=${pending.currentIdx}`)
78
+ return
79
+ }
80
+ advanceAsk(s, toolUseId, { customText: trimmed, user })
81
+ }
82
+
83
+ /** Record an answer for the current question, advance the state
84
+ * machine, repaint. If every question is now answered, finalize
85
+ * (or defer the finalize until can_use_tool lands — the race is
86
+ * handled by renderPermission). */
87
+ export function advanceAsk(
88
+ s: Session,
89
+ toolUseId: string,
90
+ answer: { optionIdx?: number; customText?: string; user: string },
91
+ ): void {
92
+ const pending = s.pendingAsks.get(toolUseId)
93
+ if (!pending || pending.currentIdx === undefined) return
94
+ const cur = pending.currentIdx
95
+ const q = pending.questions[cur]
96
+ if (!q) { log(`session "${s.sessionName}": advanceAsk currentIdx=${cur} out of range`); return }
97
+ // Resolve the literal answer value — custom text wins if both set.
98
+ let value: string
99
+ if (answer.customText !== undefined) {
100
+ value = answer.customText
101
+ } else if (answer.optionIdx !== undefined) {
102
+ const opt = q.options?.[answer.optionIdx]
103
+ if (!opt) { log(`session "${s.sessionName}": advanceAsk option ${answer.optionIdx} out of range`); return }
104
+ value = opt.label
105
+ } else {
106
+ log(`session "${s.sessionName}": advanceAsk with neither customText nor optionIdx`)
107
+ return
108
+ }
109
+ pending.answers[q.question] = value
110
+ pending.answered.set(cur, {
111
+ optionIdx: answer.optionIdx,
112
+ customText: answer.customText,
113
+ user: answer.user,
114
+ })
115
+ // Next unanswered idx — linear from cur+1. Implementation
116
+ // always moves forward; we don't currently let users revisit a
117
+ // previous question (would need richer UI affordance for that).
118
+ const total = pending.questions.length
119
+ let nextIdx: number | undefined = undefined
120
+ for (let i = cur + 1; i < total; i++) {
121
+ if (!pending.answered.has(i)) { nextIdx = i; break }
122
+ }
123
+ pending.currentIdx = nextIdx
124
+
125
+ const turn = s.currentTurn
126
+ const meta = turn?.toolByUseId.get(toolUseId)
127
+ if (turn && meta) {
128
+ const el = cards.askUserQuestionElement(
129
+ meta.i, toolUseId, pending.questions,
130
+ nextIdx === undefined ? '✅' : '🤔',
131
+ { currentIdx: nextIdx, answered: pending.answered },
132
+ )
133
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
134
+ }
135
+
136
+ if (nextIdx === undefined) {
137
+ // All done. Finalize iff we have the permission request id;
138
+ // otherwise renderPermission will pick it up when it arrives.
139
+ if (pending.requestId) finalizeAsk(s, toolUseId)
140
+ else log(`session "${s.sessionName}": ask ${toolUseId} all answered, waiting for can_use_tool`)
141
+ }
142
+ }
143
+
144
+ /** Settle a fully-answered AskUserQuestion: emit the SDK allow
145
+ * with the full `answers` record folded into `updatedInput`,
146
+ * drop bookkeeping, restore status. The terminal panel paint was
147
+ * already done by the final advanceAsk; this is just protocol. */
148
+ export function finalizeAsk(s: Session, toolUseId: string): void {
149
+ const pending = s.pendingAsks.get(toolUseId)
150
+ if (!pending || !pending.requestId) return
151
+ const meta = s.currentTurn?.toolByUseId.get(toolUseId)
152
+ const originalInput = meta?.input ?? {}
153
+ s.proc?.sendPermissionResponse(pending.requestId, 'allow', {
154
+ updatedInput: { ...originalInput, answers: pending.answers },
155
+ })
156
+ s.pendingPermissions.delete(pending.requestId)
157
+ if (meta) {
158
+ meta.output = JSON.stringify({ answers: pending.answers })
159
+ meta.isError = false
160
+ }
161
+ s.pendingAsks.delete(toolUseId)
162
+ if (s.pendingPermissions.size === 0 && s.status === 'awaiting_permission') {
163
+ s.status = 'working'
164
+ }
165
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Permission flow split out of session.ts. The daemon merges the
3
+ * permission ask into the existing tool element in the current turn
4
+ * card — one continuous timeline: ⏳ pending → 🔐 awaiting approval
5
+ * (with buttons) → ⏳ allowed / ❌ denied → ✅ with output. No
6
+ * floating orange card.
7
+ */
8
+
9
+ import type { Session } from './session'
10
+ import type { CanUseToolRequest } from './claude-process'
11
+ import * as cardkit from './cardkit'
12
+ import * as cards from './cards'
13
+ import * as feishu from './feishu'
14
+ import { log } from './log'
15
+ import { isTaskWorkflow, todosArray } from './session-tools'
16
+ import { finalizeAsk } from './session-ask'
17
+
18
+ export async function onPermissionDecision(
19
+ s: Session,
20
+ requestId: string,
21
+ decision: 'allow' | 'allow_always' | 'deny',
22
+ user: string,
23
+ ): Promise<void> {
24
+ const pending = s.pendingPermissions.get(requestId)
25
+ if (!pending) { log(`session "${s.sessionName}": stray permission ${requestId}`); return }
26
+ s.pendingPermissions.delete(requestId)
27
+
28
+ // Update the tool element in the main turn card in place — the
29
+ // permission decision lives on the same row as the tool call.
30
+ const turn = s.currentTurn
31
+ const meta = turn?.toolByUseId.get(pending.toolUseId)
32
+ if (turn && meta) {
33
+ const todos = isTaskWorkflow(meta.name) ? todosArray(s) : undefined
34
+ if (decision === 'deny') {
35
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, `🚫 已拒绝 by ${user || '匿名'}`, '❌', undefined, todos)
36
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
37
+ } else {
38
+ const label = decision === 'allow_always' ? '始终允许' : '已允许'
39
+ meta.resolvedNote = `✅ **${label}** by ${user || '匿名'}`
40
+ const el = cards.toolCallElement(meta.i, meta.name, meta.input, null, '⏳', meta.resolvedNote, todos)
41
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
42
+ }
43
+ }
44
+
45
+ const claudeDecision = decision === 'deny' ? 'deny' : 'allow'
46
+ s.proc?.sendPermissionResponse(requestId, claudeDecision)
47
+
48
+ if (decision === 'allow_always') {
49
+ s.proc?.sendSetPermissionMode('acceptEdits')
50
+ }
51
+
52
+ if (s.pendingPermissions.size === 0 && s.status === 'awaiting_permission') {
53
+ s.status = 'working'
54
+ }
55
+ }
56
+
57
+ /** Merge the permission ask into the existing tool element in the
58
+ * current turn card. The user sees one continuous timeline: ⏳ pending
59
+ * → 🔐 awaiting approval (with buttons) → ⏳ allowed / ❌ denied → ✅
60
+ * with output. No floating orange card.
61
+ *
62
+ * `tool_use` is emitted as part of the assistant message and lands on
63
+ * our `addTool` handler BEFORE the SDK's `can_use_tool` control_request
64
+ * arrives — so by the time we get here, `toolByUseId` already has the
65
+ * entry we need to replace.
66
+ *
67
+ * Edge cases (no current turn / missing tool_use_id / unknown id) are
68
+ * surfaced loudly and auto-denied. We don't fall back to a standalone
69
+ * card — per the project's no-fallbacks rule, hidden anomalies are
70
+ * worse than visible deny errors. */
71
+ export function renderPermission(s: Session, req: CanUseToolRequest): void {
72
+ const turn = s.currentTurn
73
+ if (!turn) {
74
+ log(`session "${s.sessionName}": can_use_tool with no current turn — auto-deny req=${req.request_id}`)
75
+ s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no active turn' })
76
+ return
77
+ }
78
+ const toolUseId = req.tool_use_id
79
+ if (!toolUseId) {
80
+ log(`session "${s.sessionName}": can_use_tool without tool_use_id — auto-deny req=${req.request_id}`)
81
+ s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no tool_use_id' })
82
+ return
83
+ }
84
+ const meta = turn.toolByUseId.get(toolUseId)
85
+ if (!meta) {
86
+ log(`session "${s.sessionName}": can_use_tool for unknown tool_use_id=${toolUseId} — auto-deny req=${req.request_id}`)
87
+ s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'unknown tool_use_id' })
88
+ return
89
+ }
90
+ // AskUserQuestion: SDK routes it through can_use_tool even under
91
+ // bypass. The PAYLOAD of "user has answered" is the permission
92
+ // response itself — specifically `updatedInput.answers`. So we
93
+ // CANNOT auto-allow here (that's the v0.1.2 bug: SDK got an empty
94
+ // answers map and immediately synthesised a "User has answered
95
+ // your questions: ." tool_result). Park the requestId on the
96
+ // pendingAsk record and wait for the user to click an option;
97
+ // onAskAnswer will then send allow + updatedInput.answers in one
98
+ // shot. If the user already clicked between addTool and now —
99
+ // the deferredAnswer slot — settle immediately.
100
+ if (meta.name === 'AskUserQuestion') {
101
+ const ask = s.pendingAsks.get(toolUseId)
102
+ if (!ask) {
103
+ log(`session "${s.sessionName}": AskUserQuestion ${toolUseId} missing pendingAsk — deny`)
104
+ s.proc?.sendPermissionResponse(req.request_id, 'deny', { denyMessage: 'no pending ask' })
105
+ return
106
+ }
107
+ ask.requestId = req.request_id
108
+ s.pendingPermissions.set(req.request_id, { toolUseId })
109
+ // Fast-clicker race: the user may have answered every question
110
+ // while we were still waiting for can_use_tool to arrive. If so,
111
+ // advanceAsk parked the all-done state and we drain it now.
112
+ if (ask.currentIdx === undefined) finalizeAsk(s, toolUseId)
113
+ return
114
+ }
115
+ s.status = 'awaiting_permission'
116
+ s.pendingPermissions.set(req.request_id, { toolUseId })
117
+ const el = cards.toolCallPermissionElement(meta.i, meta.name, meta.input, req.request_id)
118
+ void cardkit.replaceElement(turn.cardId, cards.ELEMENTS.tool(meta.i), el)
119
+ // Phone push — Claude is blocked until the user approves/denies.
120
+ // Set summary to "🔐 等审批: <tool>(<input summary>)" so the lock-
121
+ // screen notification shows which tool needs approval.
122
+ if (turn.userOpenId && turn.messageId) {
123
+ const inputSummary = cards.summarizeToolInput(meta.name, meta.input)
124
+ const tail = inputSummary && inputSummary.length > 30
125
+ ? inputSummary.slice(0, 30) + '…'
126
+ : inputSummary
127
+ const summary = tail
128
+ ? `🔐 等审批: ${meta.name} · ${tail}`
129
+ : `🔐 等审批: ${meta.name}`
130
+ void (async () => {
131
+ cardkit.cancelSummary(turn.cardId)
132
+ await cardkit.patchSettings(turn.cardId, { config: { summary: { content: summary } } })
133
+ await feishu.urgentApp(turn.messageId, [turn.userOpenId])
134
+ })()
135
+ }
136
+ }