deskbuddy 0.2.0-beta.10 → 0.2.0-beta.13
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/dist/cli.js +451 -252
- package/dist/hook.cjs +142 -24
- package/dist/opencode.mjs +16 -2
- package/dist/wake.cjs +8 -5
- package/hook/hook.cjs +142 -24
- package/hook/opencode.d.mts +1 -0
- package/hook/opencode.mjs +16 -2
- package/hook/wake.cjs +8 -5
- package/package.json +1 -1
package/dist/hook.cjs
CHANGED
|
@@ -26,7 +26,7 @@ const MAX_SESSIONS = 20
|
|
|
26
26
|
* 快照整包带的能力声明(前置 spec 2026-09-29 §4.4,服务端写 deskbuddy:caps:{clientId})。
|
|
27
27
|
* 与 src/core/snapshot.ts 的 CAPS 一致
|
|
28
28
|
*/
|
|
29
|
-
const CAPS = ['cmd_result', 'window_inject', 'resume_ended', 'reply_idle_window']
|
|
29
|
+
const CAPS = ['cmd_result', 'window_inject', 'resume_ended', 'reply_idle_window', 'turns']
|
|
30
30
|
/** 状态文件里保留的最近事件条数,与 src/core/state.ts 的 RECENT_MAX 一致 */
|
|
31
31
|
const RECENT_MAX = 8
|
|
32
32
|
const REPORT_TIMEOUT_MS = 2000
|
|
@@ -69,7 +69,8 @@ const FALLBACK_REGISTRY = {
|
|
|
69
69
|
claude_code: {
|
|
70
70
|
format: 'cc-hook',
|
|
71
71
|
processNames: ['claude'],
|
|
72
|
-
titleSources: ['cc-ai-title', 'git-branch', 'project'],
|
|
72
|
+
titleSources: ['cc-ai-title', 'first-prompt', 'git-branch', 'project'],
|
|
73
|
+
turnEvents: ['UserPromptSubmit'],
|
|
73
74
|
events: {
|
|
74
75
|
UserPromptSubmit: 'watch',
|
|
75
76
|
PreToolUse: 'watch',
|
|
@@ -88,6 +89,7 @@ const FALLBACK_REGISTRY = {
|
|
|
88
89
|
// codex-thread-name 是机会主义的一级:session_index.jsonl 里的 thread_name
|
|
89
90
|
// 只有 /rename 或 VS Code 扩展起过名的才有,多数是空的,取不到用首条指令
|
|
90
91
|
titleSources: ['codex-thread-name', 'first-prompt', 'git-branch', 'project'],
|
|
92
|
+
turnEvents: ['UserPromptSubmit'],
|
|
91
93
|
events: {
|
|
92
94
|
UserPromptSubmit: 'watch',
|
|
93
95
|
PreToolUse: 'watch',
|
|
@@ -103,6 +105,7 @@ const FALLBACK_REGISTRY = {
|
|
|
103
105
|
// WorkBuddy 的 transcript 与 Claude Code 同格式,也写 ai-title(2026-10-01 WorkBuddy 5.6.2 实测);
|
|
104
106
|
// 工作目录是按时间自动建的,目录名不能当标题
|
|
105
107
|
titleSources: ['cc-ai-title', 'first-prompt', 'git-branch', 'project'],
|
|
108
|
+
turnEvents: ['UserPromptSubmit'],
|
|
106
109
|
events: {
|
|
107
110
|
UserPromptSubmit: 'watch',
|
|
108
111
|
PreToolUse: 'watch',
|
|
@@ -119,6 +122,7 @@ const FALLBACK_REGISTRY = {
|
|
|
119
122
|
// 桌面端会话库里的标题(先是首条指令,后被换成 AI 摘要);命令行跑的会话不在库里,落到首条指令
|
|
120
123
|
// (CLI 1.1.61 实测 transcript 里没有 ai-title,UserPromptSubmit 带 prompt)
|
|
121
124
|
titleSources: ['qoder-session-title', 'first-prompt', 'git-branch', 'project'],
|
|
125
|
+
turnEvents: ['UserPromptSubmit'],
|
|
122
126
|
events: {
|
|
123
127
|
UserPromptSubmit: 'watch',
|
|
124
128
|
PreToolUse: 'watch',
|
|
@@ -133,6 +137,7 @@ const FALLBACK_REGISTRY = {
|
|
|
133
137
|
format: 'cc-hook',
|
|
134
138
|
processNames: ['Qoder'],
|
|
135
139
|
titleSources: ['qoder-session-title', 'first-prompt', 'git-branch', 'project'],
|
|
140
|
+
turnEvents: ['UserPromptSubmit'],
|
|
136
141
|
events: {
|
|
137
142
|
UserPromptSubmit: 'watch',
|
|
138
143
|
PreToolUse: 'watch',
|
|
@@ -147,6 +152,7 @@ const FALLBACK_REGISTRY = {
|
|
|
147
152
|
processNames: ['opencode'],
|
|
148
153
|
// opencode 自己生成的会话标题(插件从 session.updated 取,随事件传 oc_session_title),没有就用首条指令
|
|
149
154
|
titleSources: ['oc-session-title', 'first-prompt', 'git-branch', 'project'],
|
|
155
|
+
turnEvents: ['UserPromptSubmit'],
|
|
150
156
|
events: {
|
|
151
157
|
UserPromptSubmit: 'watch',
|
|
152
158
|
PreToolUse: 'watch',
|
|
@@ -226,6 +232,22 @@ function agentNames(registry, tool) {
|
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
/** 会话标题回落链。顺序由服务端映射表决定,客户端不写死。 */
|
|
235
|
+
/** 一轮开始的事件(窗口对话轮数 spec 2026-10-02 §2.1):没配时用内置条目,再没有就是 UserPromptSubmit */
|
|
236
|
+
const DEFAULT_TURN_EVENTS = ['UserPromptSubmit']
|
|
237
|
+
|
|
238
|
+
/** 与 src/core/registry.ts 的 turnEvents 一致 */
|
|
239
|
+
function turnEvents(registry, tool) {
|
|
240
|
+
const t = registry && registry.tools ? registry.tools[tool] : null
|
|
241
|
+
if (t && Array.isArray(t.turnEvents)) return t.turnEvents
|
|
242
|
+
const f = FALLBACK_REGISTRY.tools[tool]
|
|
243
|
+
return f && Array.isArray(f.turnEvents) ? f.turnEvents : DEFAULT_TURN_EVENTS
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 状态里的轮数:只认非负整数,其它(老版本没有这个字段)当作没有 */
|
|
247
|
+
function turnsOf(s) {
|
|
248
|
+
return s && Number.isInteger(s.turns) && s.turns >= 0 ? s.turns : undefined
|
|
249
|
+
}
|
|
250
|
+
|
|
229
251
|
function titleSources(registry, tool) {
|
|
230
252
|
const t = registry && registry.tools ? registry.tools[tool] : null
|
|
231
253
|
if (t && Array.isArray(t.titleSources) && t.titleSources.length > 0) return t.titleSources
|
|
@@ -730,6 +752,8 @@ function writeEnded(s, now) {
|
|
|
730
752
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
731
753
|
const t = now || Date.now()
|
|
732
754
|
const rec = { sessionId: sid, tool: s.tool || '', cwd: s.cwd || '', interactive: s.interactive === true, endedAt: t }
|
|
755
|
+
// 轮数随记录留下:--resume 同一个会话时接着数(窗口对话轮数 spec §2.1)
|
|
756
|
+
if (turnsOf(s) !== undefined) rec.turns = s.turns
|
|
733
757
|
const file = path.join(dir, sid + '.json')
|
|
734
758
|
fs.writeFileSync(file + '.tmp', JSON.stringify(rec), { encoding: 'utf8', mode: 0o600 })
|
|
735
759
|
fs.renameSync(file + '.tmp', file)
|
|
@@ -746,6 +770,19 @@ function writeEnded(s, now) {
|
|
|
746
770
|
}
|
|
747
771
|
}
|
|
748
772
|
|
|
773
|
+
/** 已结束记录里留下的轮数(与 src/core/ended.ts 的 readEnded 同一有效期);没有返回 undefined */
|
|
774
|
+
function endedTurns(sessionId) {
|
|
775
|
+
const sid = String(sessionId || '')
|
|
776
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(sid)) return undefined
|
|
777
|
+
try {
|
|
778
|
+
const r = JSON.parse(fs.readFileSync(path.join(deskbuddyHome(), 'ended', sid + '.json'), 'utf8'))
|
|
779
|
+
if (!r || r.sessionId !== sid || typeof r.endedAt !== 'number' || Date.now() - r.endedAt > ENDED_TTL_MS) return undefined
|
|
780
|
+
return turnsOf(r)
|
|
781
|
+
} catch (e) {
|
|
782
|
+
return undefined
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
749
786
|
/** 会话结束:先写已结束记录,再删状态(删状态的地方都走这里,/clear 除外) */
|
|
750
787
|
function retireState(s, now) {
|
|
751
788
|
writeEnded(s, now)
|
|
@@ -800,6 +837,8 @@ function readAliveStates(tool) {
|
|
|
800
837
|
function shouldReport(prev, next, nowMs) {
|
|
801
838
|
if (!prev) return true
|
|
802
839
|
if (prev.state !== next.state) return true
|
|
840
|
+
// 轮数变了也报:Esc 打断后状态停在 watch,下一句 watch→watch 不跃迁,不报的话设备要等 2 分钟保活才更新
|
|
841
|
+
if (turnsOf(prev) !== turnsOf(next)) return true
|
|
803
842
|
// 保活:状态没跃迁也要定期报一次,否则服务端会把这台机器判成失联
|
|
804
843
|
return nowMs - (prev.lastReportAt || 0) > KEEPALIVE_MS
|
|
805
844
|
}
|
|
@@ -828,17 +867,22 @@ function buildSnapshot(states, tool, cfg, nowMs, remote) {
|
|
|
828
867
|
.filter((s) => s.tool === tool) // 替换粒度是 (clientId, tool),混进别的工具会冲掉对方
|
|
829
868
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
830
869
|
.slice(0, MAX_SESSIONS)
|
|
831
|
-
.map((s) =>
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
870
|
+
.map((s) => {
|
|
871
|
+
const o = {
|
|
872
|
+
sessionId: s.sessionId,
|
|
873
|
+
title: s.title,
|
|
874
|
+
// 隐私:只带目录名,不带全路径——全路径会暴露公司目录结构与用户名
|
|
875
|
+
project: path.basename(s.project || ''),
|
|
876
|
+
state: s.state,
|
|
877
|
+
startedAt: s.startedAt,
|
|
878
|
+
updatedAt: s.updatedAt,
|
|
879
|
+
interactive: s.interactive === true,
|
|
880
|
+
remoteWindow: remote ? remote.has(s.sessionId) : s.remoteWindow === true,
|
|
881
|
+
}
|
|
882
|
+
// 窗口对话轮数:状态里没有就不带(老会话 / 老版本),服务端据此不显示而不是显示 0
|
|
883
|
+
if (turnsOf(s) !== undefined) o.turns = s.turns
|
|
884
|
+
return o
|
|
885
|
+
})
|
|
842
886
|
return { clientId: cfg.clientId, hostName: cfg.hostName, tool: tool, snapshotAt: nowMs, sessions: sessions, caps: CAPS.slice() }
|
|
843
887
|
}
|
|
844
888
|
|
|
@@ -1027,6 +1071,22 @@ function isInteractiveArgs(args) {
|
|
|
1027
1071
|
)
|
|
1028
1072
|
}
|
|
1029
1073
|
|
|
1074
|
+
/**
|
|
1075
|
+
* 嵌入宿主(插件 / 桌面版 / SDK 长连):`--input-format stream-json`(含 `=` 写法)且不带 -p / --print。
|
|
1076
|
+
* 这类会话 isInteractiveArgs 判 false(不是终端窗口),但用户就在宿主里一轮轮发话:没有唤醒等待者(wake.cjs 铁律③),
|
|
1077
|
+
* daemon 收到设备回复要按「用户自己的窗口」处理(正在跑就排队等 Stop、空闲才后台续跑并写 appended),不能直接并发 --resume。
|
|
1078
|
+
* 🔴 只落本机状态,不改 interactive(服务端按 interactive 挡 8348),也不上报
|
|
1079
|
+
*/
|
|
1080
|
+
function isEmbeddedArgs(args) {
|
|
1081
|
+
const tokens = String(args || '').trim().split(/\s+/).slice(1)
|
|
1082
|
+
let streamIn = false
|
|
1083
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1084
|
+
const t = tokens[i]
|
|
1085
|
+
if (t === '--input-format=stream-json' || (t === '--input-format' && tokens[i + 1] === 'stream-json')) streamIn = true
|
|
1086
|
+
}
|
|
1087
|
+
return streamIn && !tokens.some((t) => t === '--print' || /^-[a-zA-Z]*p[a-zA-Z]*$/.test(t))
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1030
1090
|
/** 只看进程在不在(kill 0),不查进程名:每次事件都要调,ps 太贵 */
|
|
1031
1091
|
function pidExists(pid) {
|
|
1032
1092
|
try {
|
|
@@ -1037,23 +1097,34 @@ function pidExists(pid) {
|
|
|
1037
1097
|
}
|
|
1038
1098
|
}
|
|
1039
1099
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1100
|
+
/** 智能体进程的命令行(Windows 见 winproc 的规范化参数串)。取不到返回 '' */
|
|
1101
|
+
function agentArgs(pid) {
|
|
1102
|
+
if (!pid) return ''
|
|
1042
1103
|
if (IS_WIN) {
|
|
1043
1104
|
const p = winProc(pid)
|
|
1044
|
-
return
|
|
1105
|
+
return p ? p.args : ''
|
|
1045
1106
|
}
|
|
1046
1107
|
try {
|
|
1047
|
-
|
|
1108
|
+
return execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], {
|
|
1048
1109
|
encoding: 'utf8',
|
|
1049
1110
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1050
|
-
})
|
|
1051
|
-
return args.trim() !== '' && isInteractiveArgs(args)
|
|
1111
|
+
}).trim()
|
|
1052
1112
|
} catch (e) {
|
|
1053
|
-
return
|
|
1113
|
+
return ''
|
|
1054
1114
|
}
|
|
1055
1115
|
}
|
|
1056
1116
|
|
|
1117
|
+
/** 交互窗口 / 嵌入宿主两个判定共用一次命令行查询。🔴 取不到命令行两个都按 false */
|
|
1118
|
+
function detectHost(pid) {
|
|
1119
|
+
const args = agentArgs(pid)
|
|
1120
|
+
if (!args) return { interactive: false, embedded: false }
|
|
1121
|
+
return { interactive: isInteractiveArgs(args), embedded: isEmbeddedArgs(args) }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function detectInteractive(pid) {
|
|
1125
|
+
return detectHost(pid).interactive
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1057
1128
|
// ---------- /clear 改绑(二期 P2) ----------
|
|
1058
1129
|
|
|
1059
1130
|
/** 与 src/core/state.ts 的 ALIAS_TTL_MS 一致 */
|
|
@@ -1370,6 +1441,33 @@ function superseded(cur, eventAt) {
|
|
|
1370
1441
|
return ahead > 0 && ahead < SUPERSEDE_MAX_MS
|
|
1371
1442
|
}
|
|
1372
1443
|
|
|
1444
|
+
/**
|
|
1445
|
+
* 被更晚发生的事件抢先写了状态(superseded):状态不覆盖,只把这次事件该补的补到那份上 ——
|
|
1446
|
+
* 首条指令(标题),以及一轮开始的计数(Codex 的 async hook 里 UserPromptSubmit 常比 Stop 晚写完,不补就漏一轮)。
|
|
1447
|
+
* 返回是否补了轮数:补了要立刻再报一次 —— 抢先写的那个 Stop 已经带着旧轮数报过 done,不报就要等下一次跃迁
|
|
1448
|
+
*/
|
|
1449
|
+
function patchSuperseded(cur, firstPrompt, isTurn) {
|
|
1450
|
+
const patch = {}
|
|
1451
|
+
if (firstPrompt && !cur.firstPrompt) patch.firstPrompt = firstPrompt
|
|
1452
|
+
if (isTurn) patch.turns = (turnsOf(cur) || 0) + 1
|
|
1453
|
+
if (Object.keys(patch).length > 0) writeState(Object.assign({}, cur, patch))
|
|
1454
|
+
return isTurn
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
/**
|
|
1458
|
+
* 轮数以写盘前刚读到的磁盘状态为准(不是开头读的 prev):中间查进程 / 取标题可能隔了几百毫秒,
|
|
1459
|
+
* 期间并发的 UserPromptSubmit 已经加过 1;daemon 后台 -p 续跑的 hook 也是在窗口会话的计数上加 1,不会盖掉它。
|
|
1460
|
+
* 状态没有了(新会话 / 已结束后 --resume)就接已结束记录里的数;都没有:一轮开始记 1,其它事件不带
|
|
1461
|
+
*/
|
|
1462
|
+
function setTurns(next, disk, prev, isTurn, sessionId) {
|
|
1463
|
+
let base = turnsOf(disk)
|
|
1464
|
+
if (base === undefined) base = turnsOf(prev)
|
|
1465
|
+
if (base === undefined && !disk && !prev) base = endedTurns(sessionId)
|
|
1466
|
+
const v = isTurn ? (base || 0) + 1 : base
|
|
1467
|
+
if (v === undefined) delete next.turns
|
|
1468
|
+
else next.turns = v
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1373
1471
|
async function main(argv) {
|
|
1374
1472
|
// 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
|
|
1375
1473
|
const tool = (argv && argv[2]) || 'claude_code'
|
|
@@ -1454,17 +1552,26 @@ async function main(argv) {
|
|
|
1454
1552
|
const prev = readState(sessionId)
|
|
1455
1553
|
const now = Date.now()
|
|
1456
1554
|
if (event === 'UserPromptSubmit') recordDir(payload.cwd, now)
|
|
1555
|
+
// 窗口对话轮数(spec 2026-10-02 §2.1):一轮开始的事件加 1
|
|
1556
|
+
const isTurn = turnEvents(registry, tool).indexOf(event) >= 0
|
|
1457
1557
|
// 首条指令(标题来源 first-prompt):只记第一次
|
|
1458
1558
|
const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
|
|
1459
1559
|
if (superseded(prev, eventAt)) {
|
|
1460
1560
|
// 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
|
|
1461
|
-
if (
|
|
1561
|
+
if (patchSuperseded(prev, firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
|
|
1462
1562
|
return
|
|
1463
1563
|
}
|
|
1464
1564
|
// 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
|
|
1465
1565
|
// 不能拿死掉的 -p 进程的 interactive=false 挡不住窗口
|
|
1466
1566
|
const prevAlive = !!(prev && prev.pid && pidExists(prev.pid))
|
|
1467
1567
|
const pid = (prevAlive && prev.pid) || resolveAgentPid(process.pid, agentNames(registry, tool)) || undefined
|
|
1568
|
+
// 两个判定沿用规则相同:进程还活着且上次判过就沿用,否则查一次命令行(只查一次)
|
|
1569
|
+
let host = null
|
|
1570
|
+
const hostFlag = (k) => {
|
|
1571
|
+
if (prevAlive && typeof prev[k] === 'boolean') return prev[k]
|
|
1572
|
+
if (!host) host = detectHost(pid)
|
|
1573
|
+
return host[k]
|
|
1574
|
+
}
|
|
1468
1575
|
const next = {
|
|
1469
1576
|
sessionId: sessionId,
|
|
1470
1577
|
tool: tool,
|
|
@@ -1485,7 +1592,9 @@ async function main(argv) {
|
|
|
1485
1592
|
pid: pid,
|
|
1486
1593
|
pidName: pid ? (prevAlive && prev.pidName) || processName(pid) || undefined : undefined,
|
|
1487
1594
|
// 二期 P1:窗口开着的会话,设备不能再往里下指令(两个进程会互相吞掉对话)。每个会话只判一次
|
|
1488
|
-
interactive: windowId ? true :
|
|
1595
|
+
interactive: windowId ? true : hostFlag('interactive'),
|
|
1596
|
+
// 嵌入宿主(插件 / 桌面版,见 isEmbeddedArgs):daemon 据此按用户窗口处理设备回复。只落本机、不上报
|
|
1597
|
+
embedded: hostFlag('embedded'),
|
|
1489
1598
|
// 会话开在 DeskBuddy 窗口里(spec 2026-09-30 §3.2):服务端据此放行 8348,指令由包装层写进窗口
|
|
1490
1599
|
// Codex(切片 B):会话加载在 daemon 连着的 app-server 里 = 窗口开着且能往里发
|
|
1491
1600
|
remoteWindow: windowId || (tool === 'codex' && codexRemoteHas(sessionId)) ? true : undefined,
|
|
@@ -1508,9 +1617,10 @@ async function main(argv) {
|
|
|
1508
1617
|
// 并发的 UserPromptSubmit 已把 watch 写盘并上报了,Stop 若按 done→done 静默落盘,设备收到的最后一份就是 watch
|
|
1509
1618
|
const latest = readState(sessionId)
|
|
1510
1619
|
if (superseded(latest, eventAt)) {
|
|
1511
|
-
if (next.firstPrompt
|
|
1620
|
+
if (patchSuperseded(latest, next.firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
|
|
1512
1621
|
return
|
|
1513
1622
|
}
|
|
1623
|
+
setTurns(next, latest, prev, isTurn, sessionId)
|
|
1514
1624
|
if (!windowChanged && !shouldReport(latest || prev, next, now)) {
|
|
1515
1625
|
writeState(next)
|
|
1516
1626
|
return
|
|
@@ -1533,9 +1643,10 @@ async function main(argv) {
|
|
|
1533
1643
|
// 取标题 / 查进程期间可能已有更晚发生的事件写完了:那份更新,这次既不落盘也不上报
|
|
1534
1644
|
const last = readState(sessionId)
|
|
1535
1645
|
if (superseded(last, eventAt)) {
|
|
1536
|
-
if (next.firstPrompt
|
|
1646
|
+
if (patchSuperseded(last, next.firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
|
|
1537
1647
|
return
|
|
1538
1648
|
}
|
|
1649
|
+
setTurns(next, last, prev, isTurn, sessionId)
|
|
1539
1650
|
writeState(next)
|
|
1540
1651
|
|
|
1541
1652
|
await reportIfAuthorized(tool, now)
|
|
@@ -1602,6 +1713,8 @@ module.exports = {
|
|
|
1602
1713
|
APPENDED_SYNC_TOOLS,
|
|
1603
1714
|
RUNNING_MESSAGE,
|
|
1604
1715
|
isInteractiveArgs,
|
|
1716
|
+
isEmbeddedArgs,
|
|
1717
|
+
detectHost,
|
|
1605
1718
|
recordDir,
|
|
1606
1719
|
markClear,
|
|
1607
1720
|
pairClear,
|
|
@@ -1616,6 +1729,11 @@ module.exports = {
|
|
|
1616
1729
|
winSplitArgs,
|
|
1617
1730
|
parseWinProcJson,
|
|
1618
1731
|
CAPS,
|
|
1732
|
+
turnEvents,
|
|
1733
|
+
turnsOf,
|
|
1734
|
+
setTurns,
|
|
1735
|
+
patchSuperseded,
|
|
1736
|
+
endedTurns,
|
|
1619
1737
|
WINDOW_EVENTS,
|
|
1620
1738
|
noteWindow,
|
|
1621
1739
|
readWindow,
|
package/dist/opencode.mjs
CHANGED
|
@@ -107,6 +107,18 @@ export function askToCc(props) {
|
|
|
107
107
|
return { tool_name: String((props && props.permission) || 'permission'), tool_input: { path: patterns } }
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 授权请求里 opencode 自己给的「以后都允许」模式(permission.asked 的 always,如 bash 的 `npm *`、edit 的 `*`)
|
|
112
|
+
* → 一条 session 级 addRules 建议(与 Claude Code 的 permission_suggestions 同形),daemon 据此在设备上给「允许并记住」。
|
|
113
|
+
* 设备选了记住,gate 输出带 updatedPermissions,插件回 always(opencode 在本次运行期间记住这些模式)。没有 always 返回 undefined
|
|
114
|
+
*/
|
|
115
|
+
export function alwaysSuggestions(props) {
|
|
116
|
+
const perm = String((props && props.permission) || '').trim()
|
|
117
|
+
const always = props && Array.isArray(props.always) ? props.always.filter((x) => typeof x === 'string' && x.trim() !== '') : []
|
|
118
|
+
if (!perm || always.length === 0) return undefined
|
|
119
|
+
return [{ type: 'addRules', rules: always.map((x) => ({ toolName: perm, ruleContent: `${perm} ${x.trim()}` })), behavior: 'allow', destination: 'session' }]
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
/**
|
|
111
123
|
* 远程模式:关卡改由 tool.execute.before 把,放开权限。🔴 必须原地改(整对象替换不生效,O8)。
|
|
112
124
|
* - OC_GATED_KEYS 是 daemon 压成 deny 的,放成 allow(用户原来在这些键上写的值已被覆盖,找不回来 —— 每一步都要过设备)
|
|
@@ -288,7 +300,8 @@ export function make(opts = {}) {
|
|
|
288
300
|
await reply(sessionID, id, 'once')
|
|
289
301
|
return
|
|
290
302
|
}
|
|
291
|
-
const
|
|
303
|
+
const sugg = alwaysSuggestions(p)
|
|
304
|
+
const input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc, ...(sugg ? { permission_suggestions: sugg } : {}) }
|
|
292
305
|
const { child, done } = startChild(node, GATE, ['--window', '--tool', TOOL], input)
|
|
293
306
|
if (child) pending.set(id, child)
|
|
294
307
|
const r = await done
|
|
@@ -304,7 +317,8 @@ export function make(opts = {}) {
|
|
|
304
317
|
if (!d) return
|
|
305
318
|
if (d.behavior === 'allow') {
|
|
306
319
|
if (callID && call) approvedCalls.set(callID, Date.now())
|
|
307
|
-
|
|
320
|
+
// 设备上选了「允许并记住」:回 always,opencode 记住这次请求的 always 模式
|
|
321
|
+
await reply(sessionID, id, Array.isArray(d.updatedPermissions) && d.updatedPermissions.length > 0 ? 'always' : 'once')
|
|
308
322
|
} else if (d.behavior === 'deny') {
|
|
309
323
|
await reply(sessionID, id, 'reject')
|
|
310
324
|
} else return
|
package/dist/wake.cjs
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// ② 除了「认证过的 daemon 发来、且逐条 HMAC 校验通过的指令」这一种情况,一律 exit 0、绝不写 stderr:
|
|
12
12
|
// exit 2 会唤醒窗口,stderr 的内容会被模型当成用户输入执行。通道(Windows 命名管道 / tmp 下的 socket)
|
|
13
13
|
// 别的进程也能连、能抢注,所以先双向认证(密钥 ~/.deskbuddy/wake/secret,daemon 每次启动重新生成)。
|
|
14
|
-
// ③ 非交互的 `claude -p`
|
|
15
|
-
//
|
|
14
|
+
// ③ 非交互的 `claude -p` 和 `--input-format stream-json` 宿主(IDE 插件 / 桌面版)里 asyncRewake 会被当成**同步** hook
|
|
15
|
+
// 执行(源码 `!isInteractive || hasStreamingInput`),等在那儿就把人家卡住:插件里 SessionStart 挂着不退,
|
|
16
|
+
// 60 秒后报「Subprocess initialization did not complete」会话被杀(2026-10-02 Trae CN 内置 Claude Code 插件 2.1.281 必现)。
|
|
17
|
+
// 只对交互 CLI 窗口放行,认不出宿主一律立刻退出。插件 / 桌面版的会话靠 daemon 后台续跑接设备回复。
|
|
16
18
|
// ④ 窗口关了(智能体进程没了)就退出:Claude Code 被强杀时不会替我们收尸。
|
|
17
19
|
//
|
|
18
20
|
// ⚠️ 通道地址 / 密钥 / HMAC / 标记 / transcript 分析与 src/core/wake.ts、src/daemon/wake.ts 同构,
|
|
@@ -193,8 +195,9 @@ function entrypointAllows(ep) {
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
/**
|
|
196
|
-
*
|
|
197
|
-
* 带 -p / --print
|
|
198
|
+
* 宿主命令行判定(环境变量里没有入口时才用):只有交互窗口可以。
|
|
199
|
+
* 带 -p / --print 的一次性调用、`--input-format stream-json` 的插件 / 桌面版宿主里 asyncRewake 都是同步的,绝不能等(见铁律③)。
|
|
200
|
+
* 🔴 拿不到命令行 → false
|
|
198
201
|
*/
|
|
199
202
|
function canWake(args) {
|
|
200
203
|
const all = String(args || '').trim()
|
|
@@ -202,7 +205,7 @@ function canWake(args) {
|
|
|
202
205
|
const tokens = all.split(/\s+/).slice(1)
|
|
203
206
|
for (let i = 0; i < tokens.length; i++) {
|
|
204
207
|
const t = tokens[i]
|
|
205
|
-
if (t === '--input-format=stream-json' || (t === '--input-format' && tokens[i + 1] === 'stream-json')) return
|
|
208
|
+
if (t === '--input-format=stream-json' || (t === '--input-format' && tokens[i + 1] === 'stream-json')) return false
|
|
206
209
|
}
|
|
207
210
|
return !tokens.some((t) => t === '--print' || /^-[a-zA-Z]*p[a-zA-Z]*$/.test(t))
|
|
208
211
|
}
|