deskbuddy 0.2.0-beta.11 → 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/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
 
@@ -1397,6 +1441,33 @@ function superseded(cur, eventAt) {
1397
1441
  return ahead > 0 && ahead < SUPERSEDE_MAX_MS
1398
1442
  }
1399
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
+
1400
1471
  async function main(argv) {
1401
1472
  // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
1402
1473
  const tool = (argv && argv[2]) || 'claude_code'
@@ -1481,11 +1552,13 @@ async function main(argv) {
1481
1552
  const prev = readState(sessionId)
1482
1553
  const now = Date.now()
1483
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
1484
1557
  // 首条指令(标题来源 first-prompt):只记第一次
1485
1558
  const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
1486
1559
  if (superseded(prev, eventAt)) {
1487
1560
  // 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
1488
- if (firstPrompt && !prev.firstPrompt) writeState(Object.assign({}, prev, { firstPrompt: firstPrompt }))
1561
+ if (patchSuperseded(prev, firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
1489
1562
  return
1490
1563
  }
1491
1564
  // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
@@ -1544,9 +1617,10 @@ async function main(argv) {
1544
1617
  // 并发的 UserPromptSubmit 已把 watch 写盘并上报了,Stop 若按 done→done 静默落盘,设备收到的最后一份就是 watch
1545
1618
  const latest = readState(sessionId)
1546
1619
  if (superseded(latest, eventAt)) {
1547
- if (next.firstPrompt && !latest.firstPrompt) writeState(Object.assign({}, latest, { firstPrompt: next.firstPrompt }))
1620
+ if (patchSuperseded(latest, next.firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
1548
1621
  return
1549
1622
  }
1623
+ setTurns(next, latest, prev, isTurn, sessionId)
1550
1624
  if (!windowChanged && !shouldReport(latest || prev, next, now)) {
1551
1625
  writeState(next)
1552
1626
  return
@@ -1569,9 +1643,10 @@ async function main(argv) {
1569
1643
  // 取标题 / 查进程期间可能已有更晚发生的事件写完了:那份更新,这次既不落盘也不上报
1570
1644
  const last = readState(sessionId)
1571
1645
  if (superseded(last, eventAt)) {
1572
- if (next.firstPrompt && !last.firstPrompt) writeState(Object.assign({}, last, { firstPrompt: next.firstPrompt }))
1646
+ if (patchSuperseded(last, next.firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
1573
1647
  return
1574
1648
  }
1649
+ setTurns(next, last, prev, isTurn, sessionId)
1575
1650
  writeState(next)
1576
1651
 
1577
1652
  await reportIfAuthorized(tool, now)
@@ -1654,6 +1729,11 @@ module.exports = {
1654
1729
  winSplitArgs,
1655
1730
  parseWinProcJson,
1656
1731
  CAPS,
1732
+ turnEvents,
1733
+ turnsOf,
1734
+ setTurns,
1735
+ patchSuperseded,
1736
+ endedTurns,
1657
1737
  WINDOW_EVENTS,
1658
1738
  noteWindow,
1659
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 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/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
 
@@ -1397,6 +1441,33 @@ function superseded(cur, eventAt) {
1397
1441
  return ahead > 0 && ahead < SUPERSEDE_MAX_MS
1398
1442
  }
1399
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
+
1400
1471
  async function main(argv) {
1401
1472
  // 同一份 hook 被 Claude Code 与 Codex 复用,靠命令行参数区分是哪个工具
1402
1473
  const tool = (argv && argv[2]) || 'claude_code'
@@ -1481,11 +1552,13 @@ async function main(argv) {
1481
1552
  const prev = readState(sessionId)
1482
1553
  const now = Date.now()
1483
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
1484
1557
  // 首条指令(标题来源 first-prompt):只记第一次
1485
1558
  const firstPrompt = (prev && prev.firstPrompt) || (event === 'UserPromptSubmit' ? cleanPrompt(payload.prompt) : '') || undefined
1486
1559
  if (superseded(prev, eventAt)) {
1487
1560
  // 被并发的更晚事件(多半是同一轮的 Stop)抢先写了:状态不覆盖,但首条指令要补上,不然标题就成了第二条指令
1488
- if (firstPrompt && !prev.firstPrompt) writeState(Object.assign({}, prev, { firstPrompt: firstPrompt }))
1561
+ if (patchSuperseded(prev, firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
1489
1562
  return
1490
1563
  }
1491
1564
  // 状态里记的进程还活着才沿用(评审):先远程 -p 跑过、后又在窗口 resume 同一会话时,
@@ -1544,9 +1617,10 @@ async function main(argv) {
1544
1617
  // 并发的 UserPromptSubmit 已把 watch 写盘并上报了,Stop 若按 done→done 静默落盘,设备收到的最后一份就是 watch
1545
1618
  const latest = readState(sessionId)
1546
1619
  if (superseded(latest, eventAt)) {
1547
- if (next.firstPrompt && !latest.firstPrompt) writeState(Object.assign({}, latest, { firstPrompt: next.firstPrompt }))
1620
+ if (patchSuperseded(latest, next.firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
1548
1621
  return
1549
1622
  }
1623
+ setTurns(next, latest, prev, isTurn, sessionId)
1550
1624
  if (!windowChanged && !shouldReport(latest || prev, next, now)) {
1551
1625
  writeState(next)
1552
1626
  return
@@ -1569,9 +1643,10 @@ async function main(argv) {
1569
1643
  // 取标题 / 查进程期间可能已有更晚发生的事件写完了:那份更新,这次既不落盘也不上报
1570
1644
  const last = readState(sessionId)
1571
1645
  if (superseded(last, eventAt)) {
1572
- if (next.firstPrompt && !last.firstPrompt) writeState(Object.assign({}, last, { firstPrompt: next.firstPrompt }))
1646
+ if (patchSuperseded(last, next.firstPrompt, isTurn)) await reportIfAuthorized(tool, Date.now())
1573
1647
  return
1574
1648
  }
1649
+ setTurns(next, last, prev, isTurn, sessionId)
1575
1650
  writeState(next)
1576
1651
 
1577
1652
  await reportIfAuthorized(tool, now)
@@ -1654,6 +1729,11 @@ module.exports = {
1654
1729
  winSplitArgs,
1655
1730
  parseWinProcJson,
1656
1731
  CAPS,
1732
+ turnEvents,
1733
+ turnsOf,
1734
+ setTurns,
1735
+ patchSuperseded,
1736
+ endedTurns,
1657
1737
  WINDOW_EVENTS,
1658
1738
  noteWindow,
1659
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deskbuddy",
3
- "version": "0.2.0-beta.11",
3
+ "version": "0.2.0-beta.13",
4
4
  "description": "把 Claude Code、Codex 等 AI 编码智能体每个会话窗口的状态,实时同步到桌面硬件屏幕",
5
5
  "keywords": [
6
6
  "ai",