@leviyuan/lodestar 0.2.7 → 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.
- package/README.md +48 -0
- package/daemon.ts +2 -0
- package/package.json +1 -1
- package/src/cardkit.ts +12 -7
- package/src/cards/console.ts +352 -0
- package/src/cards/elements.ts +22 -0
- package/src/cards/turn.ts +530 -0
- package/src/cards.ts +29 -795
- package/src/claude-process.ts +26 -4
- package/src/config.ts +16 -1
- package/src/feishu.ts +14 -47
- package/src/notify.ts +132 -0
- package/src/session-ask.ts +165 -0
- package/src/session-permission.ts +136 -0
- package/src/session-tools.ts +233 -0
- package/src/session-types.ts +91 -0
- package/src/session.ts +204 -655
- package/src/sysinfo.ts +273 -0
package/src/claude-process.ts
CHANGED
|
@@ -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.
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
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
|
-
*
|
|
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,
|
|
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.
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
450
|
+
const isImage = looksLikeImage(filePath)
|
|
484
451
|
try {
|
|
485
452
|
if (isImage) {
|
|
486
|
-
const key = await uploadImageMultipart(
|
|
487
|
-
if (!key) { await sendText(chatId, `❌ 出站图片上传失败: ${basename(
|
|
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(
|
|
492
|
-
if (!key) { await sendText(chatId, `❌ 出站文件上传失败: ${basename(
|
|
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(
|
|
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
|
+
}
|