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/hook/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
- sessionId: s.sessionId,
833
- title: s.title,
834
- // 隐私:只带目录名,不带全路径——全路径会暴露公司目录结构与用户名
835
- project: path.basename(s.project || ''),
836
- state: s.state,
837
- startedAt: s.startedAt,
838
- updatedAt: s.updatedAt,
839
- interactive: s.interactive === true,
840
- remoteWindow: remote ? remote.has(s.sessionId) : s.remoteWindow === true,
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
- function detectInteractive(pid) {
1041
- if (!pid) return false
1100
+ /** 智能体进程的命令行(Windows 见 winproc 的规范化参数串)。取不到返回 '' */
1101
+ function agentArgs(pid) {
1102
+ if (!pid) return ''
1042
1103
  if (IS_WIN) {
1043
1104
  const p = winProc(pid)
1044
- return !!p && p.args !== '' && isInteractiveArgs(p.args)
1105
+ return p ? p.args : ''
1045
1106
  }
1046
1107
  try {
1047
- const args = execFileSync('ps', ['-ww', '-o', 'args=', '-p', String(pid)], {
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 false
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 (firstPrompt && !prev.firstPrompt) writeState(Object.assign({}, prev, { firstPrompt: firstPrompt }))
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 : prevAlive && typeof prev.interactive === 'boolean' ? prev.interactive : detectInteractive(pid),
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 && !latest.firstPrompt) writeState(Object.assign({}, latest, { firstPrompt: 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 && !last.firstPrompt) writeState(Object.assign({}, last, { firstPrompt: 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,
@@ -8,3 +8,4 @@ export function userText(output: unknown): string
8
8
  export function rememberTitle(titles: Map<string, string>, info: unknown): void
9
9
  export function make(opts?: { node?: string }): (ctx: unknown) => Promise<Record<string, unknown>>
10
10
  export function injectAppended(output: unknown, raw: string): string
11
+ export function alwaysSuggestions(props: unknown): Array<Record<string, unknown>> | undefined
package/hook/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 input = { hook_event_name: 'PermissionRequest', session_id: sessionID, cwd, ...cc }
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
- await reply(sessionID, id, 'once')
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/hook/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` 里 asyncRewake 会被当成**同步** hook 执行(源码 `!isInteractive || hasStreamingInput`),
15
- // 等在那儿就把人家的 -p 卡住。只对交互 CLI / VS Code 插件 / 桌面版放行,认不出宿主一律立刻退出。
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
- * 宿主命令行判定(环境变量里没有入口时才用):交互窗口可以;`--input-format stream-json` 是插件/桌面版那种异步宿主;
197
- * 带 -p / --print 的是一次性调用,asyncRewake 在那里是同步的,绝不能等。🔴 拿不到命令行 → false
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 true
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deskbuddy",
3
- "version": "0.2.0-beta.10",
3
+ "version": "0.2.0-beta.13",
4
4
  "description": "把 Claude Code、Codex 等 AI 编码智能体每个会话窗口的状态,实时同步到桌面硬件屏幕",
5
5
  "keywords": [
6
6
  "ai",