dsh-codex-style-computer-use 1.2.0-rc.3

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/README.zh.md +132 -0
  4. package/cordis.patch.yml +17 -0
  5. package/lib/artifacts.js +35 -0
  6. package/lib/attention-native.js +93 -0
  7. package/lib/attention-worker.cs +194 -0
  8. package/lib/attention.js +140 -0
  9. package/lib/audit.js +30 -0
  10. package/lib/batch.js +52 -0
  11. package/lib/bin/attention-worker.exe +0 -0
  12. package/lib/bin/attention-worker.exe.json +7 -0
  13. package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
  14. package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
  15. package/lib/core/compile-worker.ps1 +58 -0
  16. package/lib/core/index.js +844 -0
  17. package/lib/core/native-runtime.js +97 -0
  18. package/lib/core/worker.cs +6375 -0
  19. package/lib/cycle.js +314 -0
  20. package/lib/exposure.js +91 -0
  21. package/lib/feedback.js +35 -0
  22. package/lib/image-output.js +92 -0
  23. package/lib/index.js +1715 -0
  24. package/lib/observations.js +78 -0
  25. package/lib/policy.js +109 -0
  26. package/lib/recovery.js +8 -0
  27. package/lib/skill-gate.js +68 -0
  28. package/lib/tools.js +717 -0
  29. package/mcp/package.json +12 -0
  30. package/mcp/src/index.js +335 -0
  31. package/package.json +88 -0
  32. package/scripts/build-native.mjs +7 -0
  33. package/scripts/smoke-native.mjs +38 -0
  34. package/scripts/verify-package.mjs +28 -0
  35. package/skills/computer-use/SKILL.md +74 -0
  36. package/skills/computer-use/references/lifecycle.md +180 -0
  37. package/skills/computer-use/references/maintenance.md +56 -0
  38. package/skills/computer-use/references/precision.md +297 -0
  39. package/skills/computer-use/references/progressive-control.md +30 -0
  40. package/skills/computer-use/references/shared-input.md +27 -0
package/lib/index.js ADDED
@@ -0,0 +1,1715 @@
1
+ import { installHumanAttention } from './attention.js'
2
+ // dsh-computer-use — DSH Cordis plugin
3
+ //
4
+ // Native DSH integration for computer use. Mirrors the @anweat/dsh-browser
5
+ // plugin pattern: settings registration, a pure-function policy gate hooked
6
+ // into tools/pre-execute, and defineTool() registrations from
7
+ // @deepseek-ai/dsh-tools. All heavy lifting lives in
8
+ // @dsh/computer-use-core (native worker + SoM + audit).
9
+ //
10
+ // Approval mapping (see lib/policy.js):
11
+ // read-only → observation only, actuation denied
12
+ // standard → observation allowed, every actuation asks (harness approval UI)
13
+ // autonomous → actuation allowed except high-risk ops (still asks)
14
+ // unrestricted → everything allowed (worker failsafe corner + rate limit remain)
15
+ //
16
+ // Two out-of-band behaviours live here rather than in the tool layer, because both are
17
+ // triggered by things that happen while the MODEL is not calling anything:
18
+ //
19
+ // 1. THE BRAKE IS ANNOUNCED INTO THE SESSION. The native worker's low-level keyboard hook
20
+ // fires the instant the human hits ESC / Ctrl+Alt+Q and writes an unsolicited NDJSON
21
+ // event; that event is steered into the running session, so the model learns it was
22
+ // stopped mid-turn instead of on the human's NEXT message (2026-09-13 requirement).
23
+ // It cannot go through `tools/pre-execute`: PreToolDecision is {kind:'allow'|'deny'|'ask'}
24
+ // only — that hook has no message channel at all.
25
+ // 2. THE ACTIVITY BORDER IS CUT INSTANTLY WHEN THE TURN ENDS (`agent/status` → idle), so a
26
+ // lingering blue border can never claim the machine is still being driven.
27
+
28
+ import { defineTool } from '@deepseek-ai/dsh-tools'
29
+ import Schema from '@deepseek-ai/schemastery'
30
+ import { ComputerUse } from './core/index.js'
31
+ import { policyDecision, HIGH_RISK_NOTE } from './policy.js'
32
+ import { acknowledge, isAcked, ACK_HINT, isDrivingCall } from './skill-gate.js'
33
+ import { mkDataDir, snapshotPath, appendAudit } from './audit.js'
34
+ import { registerTools } from './tools.js'
35
+ import { installToolExposure, ACTIVATION_TOOL } from './exposure.js'
36
+ import { scopeOf } from '@deepseek-ai/dsh-scope'
37
+ import { createCycleGate, agentKeyOf } from './cycle.js'
38
+ import { recoveryProblem } from './recovery.js'
39
+ import fs from 'node:fs'
40
+
41
+ /**
42
+ * Record the turn-end decision path where it can be INSPECTED AFTER THE FACT.
43
+ *
44
+ * The human asked twice "我还是没亲眼看到触发", and there is no way to read the host's own log from
45
+ * here — so a feature whose whole job is to make itself visible had no way to be debugged. Both
46
+ * outcomes are recorded, because "it did not fire" is the answer that actually needs a reason.
47
+ * A diagnostic that can throw would be worse than none: it must never break a turn.
48
+ */
49
+ function diagTurnEnd (what) {
50
+ try {
51
+ const p = path.join(os.homedir(), '.dsh', 'data', 'computer-use', 'turn-end-diag.log')
52
+ fs.appendFileSync(p, `${new Date().toISOString()} ${what}\n`)
53
+ } catch { /* never break the turn for a log line */ }
54
+ }
55
+
56
+ /**
57
+ * Build a REAL user message for `agent.steer()` / `agent.inject()`.
58
+ *
59
+ * WHY THIS IS A MODULE-LEVEL FUNCTION AND NOT AN INLINE OBJECT (measured crash, 2026-09-15). The
60
+ * turn-end card's 继续 / custom-text branches used to call `recipient.steer({ text: '继续' })` — a bare
61
+ * object with no `source`. `steer` puts the message straight into the agent's inbox, and the harness's
62
+ * own listeners read it as a UserMessage:
63
+ *
64
+ * `@deepseek-ai/dsh-tool-jobs/lib/index.js:175`
65
+ * if (delivery === "wakeup") ctx.on("agent/inbox/claimed", ({ agent, message }) => {
66
+ * if (message.source.kind === "user") spentWakes.delete(agent);
67
+ * });
68
+ *
69
+ * With `source` undefined that line throws, the delivery dies, and the human sees the round fail —
70
+ * measured, twice, immediately after selecting 继续:
71
+ * `[session-store] session/event listener threw: TypeError: Cannot read properties of undefined (reading 'kind')`
72
+ * `[agent-loop] agent event "agent/inbox/claimed" listener threw: TypeError: ... reading 'kind'`
73
+ * Both were swallowed by this plugin's own `try`, so the ONLY symptom was "选1之后崩溃".
74
+ *
75
+ * The shape is the one the harness itself uses (`createUserMessage({content, source})` → a message with
76
+ * `{id, role:'user', content, source}`); the dynamic import is tried first so the real constructor owns
77
+ * identity and freezing, and the hand-built fallback keeps the SAME four fields — the field that must
78
+ * never be missing is `source.kind`. This function was previously nested inside `apply()` and used only
79
+ * by `notice()`; it moved here so the card's branches cannot drift back to a bare object.
80
+ */
81
+ async function buildUserMessage (text, form, detail) {
82
+ // THE PRODUCER-OWNED V4 KIND (2026-09-30). Session format v4 REFUSES the retired plugin wrapper —
83
+ // native admission throws `SessionFormatError: format v4 message requires a producer-owned source kind`
84
+ // for `kind === 'plugin'`, which killed every round that steered a notice ("本轮运行失败"). The v3→v4
85
+ // migrator lifts exactly this record with `rewritePluginSource()`: `producerKind()` maps a plugin to
86
+ // `plugin:<name>` and the `plugin` field is DROPPED. Keep this literal in step with that function.
87
+ const source = { kind: 'plugin:dsh-computer-use', form, ...(detail ? { summary: detail } : {}) }
88
+ const content = [{ type: 'text', text }]
89
+ try {
90
+ const { createUserMessage } = await import('@deepseek-ai/dsh-llm')
91
+ if (typeof createUserMessage === 'function') return createUserMessage({ content, source })
92
+ } catch { /* the host may not resolve the package for plugins; the fallback below is equivalent */ }
93
+ return { id: `cua-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, role: 'user', content, source }
94
+ }
95
+ import os from 'node:os'
96
+ import path from 'node:path'
97
+ import { fileURLToPath } from 'node:url'
98
+
99
+ // isDrivingCall lives in skill-gate.js (not here) so the guard can IMPORT it and test the
100
+ // classification behaviourally — including the rule that a batch is driving only if something inside
101
+ // it drives, which a text match could never check.
102
+
103
+ // ---- THE ONE QUESTION PATH (merge-design.md §1, §5.3 items 16-17) -------------------------------
104
+ //
105
+ // Before this, `computer_ask` and `ask_user_question` were two different questions: the first
106
+ // blocked the NATIVE worker in a clock-driven wait loop (and a caller-side timeout could kill that
107
+ // worker while the brake was still up — the measured leak), the second put a card in the chat. Now
108
+ // the worker only ENGAGES the brake (`beginAsk`) and RELEASES it (`endAsk`) — no clock, no loop, no
109
+ // wait — and the waiting happens on the client-UI card, with the release guaranteed by the `finally`
110
+ // in `ComputerUse.ask`. `ask_user_question` is untouched: both surfaces render the same card.
111
+ //
112
+ // THE ANSWER CHANNEL, AND WHERE IT LIVES. `askHuman` is the ONE orchestration both tool surfaces
113
+ // share (`computer_ask` and `computer_batch`). It reads the lazily-injected service at CALL time —
114
+ // never capturing it early — so a `ctx.inject(['userQuestions'], …)` callback that lands after this
115
+ // module was applied is still picked up.
116
+ let asker = null
117
+ let agents = null
118
+ // The verified Desktop-raiser, handed out by `enableHumanAttention` (see the return there). Used by
119
+ // the turn-end decision card: a card the human never sees is the same as no card.
120
+ let raiseHuman = null
121
+
122
+ /**
123
+ * Put ONE question on the shared client-UI card and wait for the human, with no countdown.
124
+ *
125
+ * @returns {Promise<{answered:boolean, keepPause:boolean, selected?:string[], custom?:string, reason?:string}>}
126
+ * `keepPause` is the HUMAN's decision and the only thing that decides whether the brake stands:
127
+ * true when they chose `① 让你停`, false for every other ending (②, a typed custom answer, a
128
+ * dismissed card, an aborted turn, the tool deadline, a failed question channel).
129
+ */
130
+ async function askOne (question, exec) {
131
+ if (!asker || typeof asker.ask !== 'function') {
132
+ throw new Error('the question UI is unavailable (userQuestions not injected)')
133
+ }
134
+ // CAPTURE BOTH SYNCHRONOUSLY, BEFORE ANY await — the host requires the exact live agent object,
135
+ // and reading it after an await can hand the service an agent that has already moved on.
136
+ const agent = exec && exec.agent
137
+ const signal = exec && exec.signal
138
+ // D3: the card carries the two choices as ORDINARY OPTIONS. Which one is listed first and which one
139
+ // the client badges "(Recommended)" is the human's decision, recorded with the labels below. (This
140
+ // comment used to assert "① first and badged" — it was left behind by the reordering and contradicted
141
+ // the code underneath it, which is exactly the kind of stale prose a reader trusts instead of reading on.)
142
+ //
143
+ // THE HUMAN SELECTS AN OPTION AND THEN CONFIRMS — this is NOT a one-click answer. Measured in
144
+ // `dsh-client-ui-user-questions/lib/client.js` (v of this deploy): an option is a `role="radio"`
145
+ // in a `role="radiogroup"` (`:648`,`:655`) whose click only calls `choose(label)` (`:659-661`) —
146
+ // a DRAFT edit; the answer is sent by the footer primary button (`:776-781` → `continueFlow`
147
+ // `:559-570` → `submitDrafts` `:530-557`), or by Enter in the custom box once the draft is
148
+ // complete (`:580-584`,`:662-666`). The one-click path in that file belongs to the `plan-review`
149
+ // presentation (`:264-325`), which we do not use. Consequence for the labels below: they have to
150
+ // read as a CHOICE THE HUMAN CONFIRMS, not as a button that acts on its own.
151
+ // D3, AS DECIDED BY THE HUMAN (2026-09-15): **② 放你走 is listed FIRST and carries the
152
+ // `(Recommended)` badge.** The client numbers options by POSITION (`client.js:671-673`) and badges
153
+ // whichever label ends `(Recommended)` (`:651,683`), so "② first" and "the recommended one is badged"
154
+ // can only both hold on the SAME option — that is this option, not ①. The human was offered both
155
+ // readings and chose: keep driving is the promoted choice; keeping the brake (①) is the deliberate act.
156
+ // Consequence: the number the card shows beside this option is 1 while its label still says ②. The
157
+ // LABEL is what the answer carries back and what `keepPause` is keyed on below, so the mismatch is
158
+ // cosmetic — do not "fix" it by renumbering or by reordering the array.
159
+ const STOP_LABEL = '① 让你停(机器停住,等你 Ctrl+Alt+R 才继续)'
160
+ const GO_LABEL = '② 放你走(放开刹车,我继续干)(Recommended)'
161
+ const release = (extra) => ({ answered: false, keepPause: false, ...extra })
162
+
163
+ // THE ANSWER MUST BE ABLE TO END WHEN THE HOST ABORTS THE CALL — and THIS RACE IS LOAD-BEARING,
164
+ // NOT BELT-AND-BRACES. Do not "simplify" it away.
165
+ //
166
+ // MEASURED (merge-abort-evidence.md §0.2, V1/V2 both YES): `ctx.userQuestions.ask()` does NOT race
167
+ // the signal itself. `dsh-user-questions/lib/index.js:53` pre-checks `signal.aborted` ONCE at entry
168
+ // and `:73-78` only RE-LABELS a rejection afterwards — the service never aborts a pending
169
+ // waterfall. "A cancelled turn ⇒ ASK_ABORTED" is therefore a property of the forwarded client
170
+ // answerer and its transport, not of the seam: the host-side abort becomes a `{type:"cancel",
171
+ // eventId}` frame (dsh-api-gateway/lib/index.js:649-673) that makes the client abort a locally
172
+ // created controller the card listens to (dsh-api-gateway/lib/client.js:622-633). V1 confirms the
173
+ // other half: the fused `exec.signal` really is aborted by a turn cancel
174
+ // (dsh-api-session-controller:872-877 → dsh-agent-loop:798-804 → :1118 → :517-523 →
175
+ // dsh-tools:3180-3192). So WITHOUT this race a cancelled turn would leave the brake up until the
176
+ // human pressed Ctrl+Alt+R — the exact leak this merge removes. Whichever settles first wins.
177
+ let onAbort = null
178
+ const aborted = new Promise((resolve) => {
179
+ if (!signal) return
180
+ if (signal.aborted) { resolve(release({ reason: 'aborted' })); return }
181
+ onAbort = () => resolve(release({ reason: 'aborted' }))
182
+ try { signal.addEventListener('abort', onAbort, { once: true }) } catch { onAbort = null }
183
+ })
184
+ try {
185
+ const reply = await Promise.race([
186
+ asker.ask({
187
+ questions: [{
188
+ id: 'computer_ask',
189
+ question,
190
+ header: 'computer-use 正在提问',
191
+ // ORDER IS SIGNIFICANT: ② first, per the human's D3 choice. The client renders in array
192
+ // order and numbers by position, so this is what puts ② (with its badge) at the top.
193
+ options: [{ label: GO_LABEL, description: 'The brake is released and the agent keeps driving.' },
194
+ { label: STOP_LABEL, description: 'The machine stays stopped until you press Ctrl+Alt+R.' }],
195
+ }],
196
+ ...(agent !== undefined ? { agent } : {}),
197
+ ...(signal !== undefined ? { signal } : {}),
198
+ }),
199
+ aborted,
200
+ ])
201
+ // An abort can win the race with a `undefined`-shaped reply; treat anything without `answers`
202
+ // as "no answer came back" rather than reading a field off nothing.
203
+ const answer = reply && Array.isArray(reply.answers) ? reply.answers[0] : null
204
+ if (!answer) return release({ reason: 'aborted' })
205
+ const selected = Array.isArray(answer.selected) ? answer.selected : []
206
+ const custom = typeof answer.custom === 'string' && answer.custom.trim() !== '' ? answer.custom : undefined
207
+ // THE PAUSE DECISION IS "IS THE ① LABEL IN `selected`" — never "did anything come back".
208
+ // Three ways an answer arrives that are NOT a choice between ① and ②, and all three RELEASE:
209
+ // * a TYPED custom answer — client.js:546 makes a custom answer supersede `selected` in
210
+ // single-select, so `selected` is `[]` and the text is all there is. The human is talking to
211
+ // the agent; an unanswered brake would freeze the machine while they watch it.
212
+ // * a SKIP ("跳过此问题", client.js:585-595) — one click, and it auto-submits with
213
+ // `{selected: []}` and no custom text. An empty selection is not ①.
214
+ // * anything else with an empty selection, for the same reason: absent is not chosen.
215
+ return { answered: true, keepPause: selected.includes(STOP_LABEL), selected, ...(custom === undefined ? {} : { custom }) }
216
+ } catch (e) {
217
+ // Every rejection here is a NO-ANSWER ending, and every one of them RELEASES the brake (a
218
+ // dismissal is not an answer; the two explicit options are how the human chooses "stop"):
219
+ // * ASK_CANCELLED — the card's ✕ / 放弃整组问题 (client.js:136-142).
220
+ // * ASK_ABORTED — the request was aborted (client.js:102-107), or aborted before it started.
221
+ // * NO_PROVIDER — no answerer accepted the request in this profile.
222
+ // * DELEGATED_CALLER — THE CALLER IS AN OWNED CHILD AGENT. dsh-user-questions:56-60 refuses a
223
+ // question outright when the agent supplied is not a live runtime ROOT, and its own text says
224
+ // what to do instead: "human interaction is unavailable while the calling agent is owned by
225
+ // another live agent; include the unresolved question or decision in the child agent's final
226
+ // result". A subagent turn therefore CANNOT ask: we release the brake and hand back a reason
227
+ // that says so PLAINLY — never a silent `answered:false` the model could read as "the human
228
+ // ignored me". (`CALLER_NOT_LIVE` is mapped to the same sentence: it means the agent we were
229
+ // handed is not the live caller, so no human interaction is possible either.)
230
+ const code = (e && e.code) || 'ask-error'
231
+ return release({ reason: code === 'ASK_CANCELLED' ? 'cancelled'
232
+ : code === 'ASK_ABORTED' ? 'aborted'
233
+ : (code === 'DELEGATED_CALLER' || code === 'CALLER_NOT_LIVE')
234
+ ? 'delegated: human interaction is unavailable while the calling agent is owned by another live agent — a subagent CANNOT ask the human; put the unresolved question or decision in the child agent\'s own final result and let the parent ask'
235
+ : code })
236
+ } finally {
237
+ if (onAbort && signal) { try { signal.removeEventListener('abort', onAbort) } catch { /* already gone */ } }
238
+ }
239
+ }
240
+
241
+ // Ctrl+Alt+Q's latch deliberately OUTLIVES the process (every fresh worker adopts the marker on
242
+ // disk). That is what makes an exit hold across restarts — and it is exactly why the TURN BOUNDARY
243
+ // has to be the thing that clears it, and why nothing else may (a stray op used to do it).
244
+ function exitRecordPath () {
245
+ try {
246
+ // Same override the worker honours (DSH_COMPUTER_USE_EXIT_FILE), so a test can point the whole
247
+ // latch at an isolated directory and never touch the live record in %LOCALAPPDATA%.
248
+ const env = process.env.DSH_COMPUTER_USE_EXIT_FILE
249
+ if (env) return env
250
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
251
+ return path.join(base, 'dsh-computer-use', 'EXITED')
252
+ } catch { return null }
253
+ }
254
+
255
+ function exitedLatch () {
256
+ try {
257
+ const p = exitRecordPath()
258
+ return !!p && fs.existsSync(p)
259
+ } catch { return false }
260
+ }
261
+
262
+ // The RECORD ITSELF, not just its existence: the opener hands this text to the worker, which refuses
263
+ // to clear anything that no longer matches it. A Q that lands between this read and the worker's
264
+ // compare is therefore never silently undone (#6).
265
+ function exitRecordText () {
266
+ try {
267
+ const p = exitRecordPath()
268
+ return p && fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''
269
+ } catch { return '' }
270
+ }
271
+
272
+ // The OTHER latch, the one nothing may ever clear except the human: the brake the worker persists
273
+ // while `_engaged`/`_stopped` are up (ESC, the human-presence monitor, an interrupted turn, an ask).
274
+ // Read with the same precedence as the worker's own `ResolveStopPath()`, so the plugin cannot be
275
+ // looking at a different file than the process it is about to talk to. This is a READ ONLY — the
276
+ // plugin has no business writing or deleting a brake (see ensureCycle: it must refuse to run while
277
+ // one is up, and `resume` is the only op it may send).
278
+ function brakeLatch () {
279
+ try {
280
+ const env = process.env.DSH_COMPUTER_USE_STOP_FILE
281
+ if (env) return fs.existsSync(env)
282
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')
283
+ return fs.existsSync(path.join(base, 'dsh-computer-use', 'STOP'))
284
+ } catch { return false }
285
+ }
286
+
287
+ // EVERY pause that needs a human must put Desktop IN FRONT, not just the approval ones.
288
+ //
289
+ // WHY THIS WAS BROADENED (human report, 2026-09-14): "我的界面只能看到你卡了一样,但根本就不知道
290
+ // 原来是需要我输入东西". An `ask_user_question` card (and an approval card) is rendered in the DSH
291
+ // chat, but if the human is working in another app the window never comes forward — so from their
292
+ // side the agent simply FREEZES with no visible reason, and the turn stalls until they happen to
293
+ // look. A pending input is useless if it is behind another window.
294
+ //
295
+ // COVERAGE — every surface where an agent turn blocks on a human:
296
+ // * `user-questions/request` — the waterfall `ctx.userQuestions.ask()` runs; this is what
297
+ // `ask_user_question` blocks on, and it is the ONLY path a question card can take (read from
298
+ // @deepseek-ai/dsh-user-questions). Fires for every question surface, whatever the UI.
299
+ // * `approval/asked` — a Host tool approval, the pre-existing case. Kept unchanged.
300
+ // Both listeners are installed on the ROOT ctx AND inside the desktopRuntime-injected child,
301
+ // because a scoped waterfall (`scopeTarget(agent, agent)`) is delivered to the matching scope:
302
+ // whichever of the two sees it raises the window, and a dedupe window makes the pair idempotent.
303
+ //
304
+ // HOW: the launcher's own `desktopRuntime.show()` path (restore + show + focus). It is called ONLY
305
+ // when Desktop is not already the foreground window, so a human already looking at the chat is
306
+ // never yanked around — the point is to make a queue visible, not to steal focus on every card.
307
+ // This function NEVER answers, resumes, approves, or rejects anything: it only raises a window.
308
+ function enableHumanAttention (ctx, log) {
309
+ return installHumanAttention(ctx, log)
310
+ }
311
+
312
+ // THE BUNDLED SKILL IS REGISTERED WITH THE HOST, NOT MERELY SHIPPED (2026-10-01).
313
+ //
314
+ // WHY THIS EXISTS: `skill-gate.js` refuses every DRIVING call until the session produces the receipt
315
+ // phrase that exists only inside `skills/computer-use/SKILL.md`. Shipping that file inside the
316
+ // package does NOT make it readable: the host's only discovery path is
317
+ // `@deepseek-ai/dsh-skill-filesystem`, which scans `<project>/.dsh/skills`,
318
+ // `<project>/.agents/skills`, configured custom dirs, `<dshHome>/skills` and `<agentsHome>/skills` —
319
+ // never `node_modules`. So a user who installs this plugin without ALSO copying the skill into
320
+ // `<dshHome>/skills` gets driving tools locked by a receipt from a manual that nothing serves, and
321
+ // the gate's own hint points at a path no provider can read. That is a release-breaking trap, and the
322
+ // host has a documented seam for exactly this case: `ctx.skills.register()` contributes an in-memory
323
+ // ("embedded") skill whose body is served from this package.
324
+ //
325
+ // WHY NOT IN `inject`: a service listed in `inject` that never resolves leaves the whole fiber
326
+ // INACTIVE and `apply` never runs at all (see the long note under `export const inject` below). The
327
+ // skill is an enhancement, never a precondition for the tools, so it goes through the same lazy
328
+ // `ctx.inject` seam the question service uses and degrades to one log line when the service is absent.
329
+ //
330
+ // A FILESYSTEM COPY STILL WINS WHERE ONE EXISTS: the registry resolves one name per layer by rank, so
331
+ // an existing `<dshHome>/skills/computer-use` keeps serving whatever the user has there and this
332
+ // registration only fills the gap where nothing else serves it. The body is re-read from disk on every
333
+ // activation, so the registered manual and the shipped file cannot drift apart.
334
+ const SKILL_DIR_URL = new URL('../skills/computer-use/', import.meta.url)
335
+
336
+ /** Split a SKILL.md's YAML frontmatter from its body, reading the flat scalar keys this needs. */
337
+ function splitSkillFile (raw) {
338
+ const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(raw)
339
+ if (!match) return { data: {}, body: raw }
340
+ const data = {}
341
+ for (const line of match[1].split(/\r?\n/)) {
342
+ const kv = /^([A-Za-z][A-Za-z0-9_-]*):[ \t]*(.*)$/.exec(line)
343
+ // An empty value is a nested block (mapping or sequence), not a scalar this reader needs.
344
+ if (kv && kv[2] !== '') data[kv[1]] = kv[2].trim().replace(/^(['"])([\s\S]*)\1$/, '$2')
345
+ }
346
+ return { data, body: raw.slice(match[0].length) }
347
+ }
348
+
349
+ /**
350
+ * Contribute the bundled `computer-use` skill to the host's skill registry when one is mounted.
351
+ * Never throws into activation: a missing registry, an unreadable file or absent frontmatter leaves
352
+ * the tools exactly as they were and says so in the log.
353
+ */
354
+ function registerBundledSkill (ctx, log) {
355
+ ctx.inject(['skills'], (sctx) => {
356
+ const skills = sctx.skills
357
+ if (!skills || typeof skills.register !== 'function') {
358
+ log.warn('skills service is mounted but exposes no register() — the bundled computer-use skill stays unregistered; copy skills/computer-use into $DSH_HOME/skills to serve it from disk')
359
+ return
360
+ }
361
+ const skillFile = fileURLToPath(new URL('SKILL.md', SKILL_DIR_URL))
362
+ let raw
363
+ try {
364
+ raw = fs.readFileSync(skillFile, 'utf8')
365
+ } catch (e) {
366
+ log.warn(`bundled skill unreadable at ${skillFile} (${e.message}) — copy skills/computer-use into $DSH_HOME/skills to serve it from disk`)
367
+ return
368
+ }
369
+ const { data, body } = splitSkillFile(raw)
370
+ if (!data.name || !data.description) {
371
+ log.warn(`bundled skill ${skillFile} lacks name/description frontmatter — not registered`)
372
+ return
373
+ }
374
+ try {
375
+ skills.register({
376
+ name: data.name,
377
+ description: data.description,
378
+ ...(data.whenToUse ? { whenToUse: data.whenToUse } : {}),
379
+ source: 'bundled',
380
+ content: body,
381
+ resourceBase: { kind: 'directory', path: fileURLToPath(SKILL_DIR_URL) },
382
+ })
383
+ log.info(`registered bundled skill '${data.name}' from ${fileURLToPath(SKILL_DIR_URL)}`)
384
+ } catch (e) {
385
+ log.warn(`skill registration refused for '${data.name}': ${e.message}`)
386
+ }
387
+ })
388
+ }
389
+
390
+ export const name = 'dsh-computer-use'
391
+ // `userQuestions` is the client-UI capability seam the question CARD comes from — the same one
392
+ // `ask_user_question` blocks on (merge-design.md §5.3 item 16). It is declared as an unconditional
393
+ // service by `@deepseek-ai/dsh-base` (`dsh-base/cordis.patch.yml`), which every profile this plugin
394
+ // is deployed to lists in `dsh.profile.bundles`, so injecting it cannot fail to resolve. Registering
395
+ // the tool through it is what makes `computer_ask`'s question appear on the SAME card, answered by
396
+ // the same UI, with the same no-countdown wait.
397
+ export const inject = ['tools', 'userQuestions']
398
+ export const Config = Schema.object({
399
+ enabled: Schema.boolean().default(true).description('enable the computer-use toolset'),
400
+ progressiveTools: Schema.boolean().default(true).description('expose desktop execution tools only to Agents that load/activate computer-use; brakes remain available'),
401
+ actionFeedback: Schema.boolean().default(true).description('post-input feedback for tested GLM 5.3 Flash / DeepSeek Flash routes; GLM 5.3 uses UIA state until its vision is verified'),
402
+ automationMode: Schema.union([
403
+ Schema.const('read-only').description('observation only'),
404
+ Schema.const('standard').description('actuations require approval'),
405
+ Schema.const('autonomous').description('actuations allowed, high-risk still asks'),
406
+ Schema.const('unrestricted').description('everything allowed'),
407
+ ]).default('standard').description('automation mode (mirrors dsh-browser)'),
408
+ dryRun: Schema.boolean().default(false).description('simulate actuations, log only'),
409
+ maxActionsPerMinute: Schema.number().default(60).description('core-level actuation rate limit'),
410
+ workerExe: Schema.string().default('').description('explicit worker.exe path; empty = autodiscover/compile'),
411
+ snapshotDir: Schema.string().default('').description('screenshot dir; empty = $DSH_HOME/data/computer-use/shots'),
412
+ annotateMarks: Schema.boolean().default(true).description('draw SoM boxes on screenshots'),
413
+ // Compatibility with saved settings only. Elapsed time never ends a computer-use cycle.
414
+ calmAfterMs: Schema.number().default(0).description('legacy setting; ignored — computer-use cycles have no idle timeout'),
415
+ // ---- WHAT THE 60 s TIMER DOES WHEN NOBODY ANSWERS (2026-09-15, the human's own design words) -------
416
+ // "如果60s没回复,超时之后自动进行分支判断,是否必须终结还是按照你自己的理解继续" — the timer is meant
417
+ // to BRANCH, not just stop. The shipped code implemented only the stop half and said so in a comment;
418
+ // the human asked why. It is a setting because the judgement is theirs to set up ONCE, rather than the
419
+ // agent improvising it at every timeout.
420
+ // 'stop' — the timer makes NO decision: the machine stays with the human, the cycle stays open.
421
+ // 'recommended' — the timer executes the choice the CARD DECLARED as its recommendation. Note this is
422
+ // a judgement about the AGENT's next turn, never about the machine: it does not close
423
+ // the cycle and it does not release a human's brake.
424
+ // WHETHER THERE IS A RECOMMENDATION IS NOT CONFIGURED — IT IS READ FROM THE TASK (2026-09-15).
425
+ // A `turnEndAutoContinueMax` counter existed here for one revision; the human rejected it ("由且仅由问题
426
+ // 任务的情形决定,而不是几次 … 不应该这样硬编码设计"). The branch is now decided per turn by whether a real
427
+ // actuation reached the worker in that turn (see `turnSituation`), so there is no budget to configure.
428
+ turnEndTimeoutAction: Schema.union([
429
+ Schema.const('stop').description('no decision — wait for the human (default)'),
430
+ Schema.const('recommended').description('execute the recommendation the card declared'),
431
+ ]).default('stop').description('what the 60 s turn-end timer does when nobody answers'),
432
+ })
433
+
434
+ export function apply (ctx, config) {
435
+ const log = ctx.logger('dsh-computer-use')
436
+ if (process.env.DSH_COMPUTER_USE_KILL === '1') {
437
+ log.warn('DSH_COMPUTER_USE_KILL=1 — plugin disabled by kill switch')
438
+ return
439
+ }
440
+ if (!config.enabled) return
441
+ registerBundledSkill(ctx, log)
442
+ raiseHuman = enableHumanAttention(ctx, log)
443
+ // THE QUESTION SERVICE, INJECTED LAZILY (merge-design.md §5.3 item 16 — defence in depth).
444
+ // `inject` above already guarantees the service before `apply` runs; going through `ctx.inject` as
445
+ // well means the tool does not depend on which of the two orderings the host happened to pick, and
446
+ // that a service arriving later still lands. `enableHumanAttention` uses the same pattern for
447
+ // `desktopRuntime` just above.
448
+ // `agents` IS READ NON-FATALLY, AND DELIBERATELY NOT IN THE INJECT LIST (review finding 4, 2026-09-15).
449
+ // Cordis semantics, verified in the library (`cordis/lib/index.js:1316-1328` `_refresh` → `_setEpoch`)
450
+ // and `Inject.resolve` (:1490-1498): a service listed in `inject` that is NOT available leaves the
451
+ // fiber's epoch INACTIVE and the callback NEVER RUNS — no call, no `undefined`, no error. Listing
452
+ // `agents` there would therefore couple the PRE-EXISTING `computer_ask` (which needs only
453
+ // `userQuestions`) to an unrelated service: one row-level override that disables the agents
454
+ // registry would silently leave `asker` null and make a working tool throw. Both services ship in
455
+ // the same base bundle today, so that is a hazard rather than a live break — and it is free to
456
+ // remove. `ctx.get` reads a service without declaring it (`cordis/lib/index.js:754-764`), which is
457
+ // exactly the "use it if it is there" this needs; the resolver degrades honestly without it.
458
+ ctx.inject(['userQuestions'], (qctx) => {
459
+ asker = qctx.userQuestions
460
+ agents = qctx.get('agents')
461
+ if (!asker || typeof asker.ask !== 'function') log.warn('userQuestions injected but exposes no ask() — computer_ask cannot put a question on the card')
462
+ if (!agents || typeof agents.roots !== 'function') log.warn('agents registry unavailable — a turn-end card cannot verify its caller; it will log the refusal instead of raising a card in the wrong chat')
463
+ })
464
+ // ---- AND A SECOND, INDEPENDENT INJECTION FOR THE REGISTRY (review re-audit E1, 2026-09-15) --------
465
+ // `ctx.get('agents')` above is evaluated ONCE, when the userQuestions callback runs. If the registry
466
+ // was not active at that instant — dsh-base mounts `user-questions` before `agent`
467
+ // (`dsh-base/cordis.patch.yml:64` vs `:67`), so the ordering is a race this plugin does not control —
468
+ // or if it is re-registered later (plugin reload, a row re-enabled from the plugins UI), `agents`
469
+ // would stay null for the process lifetime and EVERY turn-end card would be refused silently.
470
+ // This assignment-only injection self-heals that: when the registry appears, cordis runs this
471
+ // callback and `agents` is set. It CANNOT hurt `asker`, because a listed service that never appears
472
+ // simply leaves this fiber's callback unrun — the coupling finding 4 was about is not reintroduced.
473
+ ctx.inject(['agents'], (actx) => {
474
+ agents = actx.agents
475
+ log.info('agents registry became available — turn-end cards can verify their caller')
476
+ })
477
+
478
+ // settings card (restart-applies) — same shape dsh-pocket/dsh-browser use
479
+ ctx.inject(['settings'], (ctx2) => {
480
+ ctx2.settings.register('computer-use', Config, { applies: 'restart' })
481
+ })
482
+
483
+ const dataDir = mkDataDir(config.snapshotDir)
484
+ const cu = new ComputerUse({
485
+ workerExe: config.workerExe || undefined,
486
+ dryRun: config.dryRun,
487
+ kickoffRequired: config.automationMode !== 'read-only',
488
+ rateLimit: { max: config.maxActionsPerMinute, windowMs: 60_000 },
489
+ log: (m) => log.info(m),
490
+ })
491
+ cu.on('action', (rec) => appendAudit(dataDir, rec))
492
+
493
+ // ---- the live agent of the session that drives this machine ------------------------------
494
+ // Captured from the tool execution itself (`exec.agent` is the live Agent object); a session
495
+ // only ever gets here by having called a computer_* tool.
496
+ const bus = { agent: null, driverSession: null }
497
+
498
+ // Telling sessions apart without assuming the host's shape: an id may be the session object
499
+ // itself, a string, or a field on it. We only ever see it through these helpers.
500
+ const sessionKey = (s) => {
501
+ if (!s) return null
502
+ if (typeof s === 'string') return s
503
+ return s.id || s.sessionId || s.key || null
504
+ }
505
+ const sameSession = (a, b) => {
506
+ if (a === b) return true
507
+ const ka = sessionKey(a), kb = sessionKey(b)
508
+ return ka !== null && kb !== null && ka === kb
509
+ }
510
+ const describeSession = (s) => {
511
+ if (!s) return 'unknown'
512
+ const k = sessionKey(s)
513
+ if (k) return String(k)
514
+ return '<' + ((s.constructor && s.constructor.name) || typeof s) + '>'
515
+ }
516
+ // ---- THE AGENT EVERY QUESTION MUST NAME (2026-09-15) -----------------------------------------
517
+ // MEASURED FAILURE, not a theory. The turn-end card was dispatched and the host answered
518
+ // 22:57:16.836 turn-end card: NO ANSWER (NO_PROVIDER) — nothing steered, no choice was made
519
+ // and no card was ever rendered — while the raiser itself worked fine (two seconds later it logged
520
+ // "Desktop already in front for pending question (1 question(s): computer_use_turn_end)").
521
+ //
522
+ // CAUSE (`@deepseek-ai/dsh-api-remotes/lib/index.js:115-124`): the remote forwarder that carries a
523
+ // question to the browser's card is a WATERFALL listener that begins
524
+ // const carrierAgent = carrierKeyOf(this); if (carrierAgent === void 0) return next();
525
+ // so an agent-LESS request is never forwarded to the client's answerer
526
+ // (`dsh-client-ui-user-questions/lib/client.js:880`). Nobody accepts, and the service rejects with
527
+ // NO_PROVIDER. `dsh-user-questions/README.zh.md:41` says the same thing in words: "Web 回答者只接收带
528
+ // Agent scope 的请求;不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人
529
+ // 接受则以 NO_PROVIDER 失败." The host's own `ask_user_question` always passes `agent: exec.agent`.
530
+ //
531
+ // WHY NOT SIMPLY PASS `bus.agent`: `ask()` validates it (`lib/index.js:56-60`) — it must be the
532
+ // registry's EXACT live instance (`agents.get(a.id) === a`, else CALLER_NOT_LIVE) AND a live ROOT
533
+ // (`agents.roots().includes(a)`, else DELEGATED_CALLER). `bus.agent` is the last tool caller, which
534
+ // is right in practice but unverified, and a wrong agent fails in exactly the same silent way this
535
+ // whole bug did. So the caller is verified FIRST and only replaced when the registry disapproves —
536
+ // and every replacement is logged, so a fallback can never masquerade as the normal path.
537
+ //
538
+ // "LIVE ROOT" IS NOT ENOUGH — IT MUST BE THE ROOT OF THIS SESSION (found by RUNNING this function,
539
+ // 2026-09-15, not by reading it). Liveness and rootness say nothing about WHICH chat the card is
540
+ // for: another session's live root passed both checks and was returned as `verified`, so a turn that
541
+ // ended in THIS chat could raise its card in another one. `session` is therefore part of the test,
542
+ // and a candidate that is a live root of some OTHER session is treated as rejected — never adopted
543
+ // merely because the registry does not object.
544
+ const rootAgentFor = (session, candidate) => {
545
+ try {
546
+ if (!agents || typeof agents.roots !== 'function') return { agent: candidate, ask: false, reason: 'no-registry' }
547
+ const roots = agents.roots()
548
+ const live = (a) => !!a && !!agents.get && agents.get(a.id) === a
549
+ const ofSession = (a) => !!a && session !== null && session !== undefined &&
550
+ sameSession(a.session || a.sessionId, session)
551
+ const isRoot = (a) => !!a && roots.includes(a)
552
+ // ---- EXACTLY ONE ROOT OWNS THIS SESSION; THAT ROOT IS THE CORRECT CALLER ------------------
553
+ // `roots()` is store-derived, so a fresh lookup cannot be stale; re-running `live` keeps the
554
+ // "exact live instance" guarantee explicit rather than inferred from the filter.
555
+ const same = roots.filter(ofSession)
556
+ if (live(candidate) && isRoot(candidate) && ofSession(candidate)) {
557
+ return { agent: candidate, ask: true, reason: 'verified' }
558
+ }
559
+ if (same.length === 1 && live(same[0])) {
560
+ return { agent: same[0], ask: true, reason: live(candidate) && isRoot(candidate) ? 'other-session-root' : 'registry-root-by-session' }
561
+ }
562
+ // ---- NOTHING VERIFIED: SAY SO, AND DO NOT LET THE CALLER ASK WITH THIS AGENT ---------------
563
+ // `ask:false` IS THE FIX, NOT A LABEL (review finding 2, 2026-09-15). The previous version
564
+ // returned the raw candidate here with `verified` withheld — but `ask()` accepts a live root of
565
+ // ANY session, so the card was raised in the WRONG CHAT. And a same-name note could not carry
566
+ // the distinction: 'other-session-root' is also the CORRECT substitution just above. So the
567
+ // decision travels as its own boolean, and `ask` remains a value only for the message.
568
+ let reason
569
+ if (live(candidate) && isRoot(candidate)) reason = 'other-session-root'
570
+ else if (!candidate) reason = 'no-candidate'
571
+ else if (!live(candidate)) reason = 'not-live-instance'
572
+ else if (!isRoot(candidate)) reason = 'not-a-root'
573
+ else reason = 'session-mismatch'
574
+ return { agent: candidate, ask: false, reason: `${reason}/roots=${roots.length}/match=${same.length}` }
575
+ } catch (e) {
576
+ return { agent: candidate, ask: false, reason: 'resolve-threw:' + ((e && e.message) || 'unknown') }
577
+ }
578
+ }
579
+
580
+ // ---- THE "CARD LOOP" HAD A DIFFERENT CAUSE, AND A SUPPRESSION FLAG WAS THE WRONG FIX -----------
581
+ // Measured at 23:22: selecting 继续 produced the card again 2 s later, and again 5 s after that, until
582
+ // the human cancelled it (`NO ANSWER (ASK_CANCELLED)`). A `suppressCardForTurn` flag was added for
583
+ // that — and the re-audit showed it was BACKWARDS, from the turn-end diagnostic file:
584
+ //
585
+ // 15:22:12.724Z CARD … ask=yes via=verified ← the card the human answered
586
+ // 15:22:23.039 TypeError … reading 'kind' ← the steer's bare object crashed the round
587
+ // 15:22:23.048Z FIRED kind=error / CARD … ← 10 ms later — an ERRORED turn, not a new one
588
+ // 15:22:30.588Z FIRED kind=error / CARD …
589
+ //
590
+ // There was never an echo: 继续 → crash → the turn ended as `kind=error` → a card for the errored turn.
591
+ // The flag could not have suppressed that anyway — `steer` wakes the driver, the loop appends
592
+ // `turn/start` immediately, and the `turn/start` handler cleared the flag before the card ever read it.
593
+ // So it suppressed the case it was not meant to touch (a normal card-steered turn, where the human DOES
594
+ // want to be asked) and could never fire for the case that actually looped. It is REMOVED rather than
595
+ // repaired: the driver of that loop was the message-shape crash, and that is fixed at its source.
596
+ //
597
+ // WHAT DOES REMAIN TRUE: an errored turn raises the same decision card. That is deliberate — the human
598
+ // should still decide what happens to the machine — but the card now SAYS the turn errored, so it is
599
+ // not mistaken for an ordinary completion.
600
+ // THERE IS NO machine-wide suppression flag here. If a future measurement shows a genuine echo, it must
601
+ // be bound to (sessionKey, turn) and justified by evidence of that shape — not by this one.
602
+
603
+ let inFlight = 0
604
+ // Ctrl+Alt+Q cancels the turn ON PURPOSE. The abort that follows must never be mistaken for
605
+ // the human hitting the stop button — that rule below re-armed the brake and left the red
606
+ // border burning on screen after an exit (reported 2026-09-12).
607
+ let selfAbortUntil = 0
608
+ // Did THIS TURN already receive the "read the procedure" nudge? Reset at turn/start — the manual
609
+ // is per-turn knowledge, not per-session state.
610
+ let nudgedTurn = false
611
+ // Did THIS TURN already open a computer-use cycle? Reset at turn/start, next to nudgedTurn —
612
+ // same reason, same axis: a cycle belongs to a turn, not to a session.
613
+ // NOTE (2026-09-13): the module-wide `cycleOpenedThisTurn` boolean is GONE. It was reset by ANY
614
+ // session's turn/start (a host-wide event) and never set on the normal path, so it both leaked
615
+ // across sessions and failed to stop a same-turn resurrection. lib/cycle.js now keeps the
616
+ // qualification per (session, agent, turn) and consumes it on the first call of that turn.
617
+
618
+ // ---- OPENING THE CYCLE WHERE THE CALL ACTUALLY HAPPENS ----------------------------------------
619
+ //
620
+ // THE BUG THIS EXISTS FOR (measured 2026-09-13, host log dsh-2026-09-13.log): a machine-wide
621
+ // `%LOCALAPPDATA%\dsh-computer-use\EXITED` record outlives every process, and exactly ONE thing
622
+ // clears it — the worker op `resume`. The plugin used to send that op only from the `turn/start`
623
+ // branch below, gated on the turn belonging to `bus.driverSession`, which is only ever set by a
624
+ // PREVIOUS session's tool call. So a BRAND-NEW session's first turn always failed `mine`:
625
+ // 01:17:44.287 EXITED written (Ctrl+Alt+Q)
626
+ // 01:19:51.641 a new session started its first turn — bus.driverSession still named the OLD
627
+ // session → mine === false → resume() never sent
628
+ // 01:19:55.033 its first computer_* call finally set bus.driverSession (too late: the boundary
629
+ // had already passed)
630
+ // 01:21:34.627 its first DRIVING call was refused: CYCLE-ENDED — and the agent then told the
631
+ // human "you pressed Ctrl+Alt+Q".
632
+ // A new session could therefore NEVER re-open computer use, and the turn boundary — a host-wide
633
+ // event that says nothing about who is about to touch this machine — was the wrong place to decide.
634
+ //
635
+ // The right place is the call that is about to reach the worker. `ensureCycle` is awaited from the
636
+ // ONE dispatch point every computer_* invocation passes through exactly once (lib/tools.js, the
637
+ // `reg()` wrapper around every tool's execute), BEFORE the op is written to the worker's stdin, so
638
+ // the ordering is enforced by an `await` in the same async chain rather than by a race between a
639
+ // host event and a tool call. The latch makes it once per turn.
640
+ //
641
+ // A BRAKE IS NOT AN EXITED LATCH (hard rule — a human's brake must never be cleared by this):
642
+ // * only `resume` may be sent, and the worker's `resume` is `Panic.AgentCalling()`, which
643
+ // touches the EXITED latch ONLY — it does not clear `_engaged`/`_stopped` and does not delete
644
+ // the STOP file, so Ctrl+Alt+R stays the human's alone;
645
+ // * and this refuses to run at all while a brake is up (`cu.stopped` from the worker's `panic`
646
+ // event, or the persisted STOP latch), because the core's `resume()` also mirrors the state
647
+ // locally (`this.stopped = false`) — calling it under a brake would drift the plugin's view
648
+ // from the worker's while the worker still refused every actuation.
649
+ // THE CYCLE GATE (2026-09-13, A+B from the Codex review). lib/cycle.js holds identity, ownership
650
+ // and the per-turn qualification; this file only feeds it events and asks it, at the ONE dispatch
651
+ // point every computer_* call passes through, whether this call may drive.
652
+ const gate = createCycleGate({ sessionKey, agentKey: agentKeyOf, now: () => Date.now() })
653
+ const pendingApprovals = new Set()
654
+ let unknownApproval = false, recoveryEpoch = 0, temporaryPause = null
655
+ // WHY THE BRAKE IS UP, kept so a refused call can say it (see `hooks.askBlocked` below). The worker
656
+ // already sends the reason with the `panic` event; this holds it from that moment until the brake is
657
+ // released, because by the time a call is refused the event itself is long gone.
658
+ let brakeWhy = ''
659
+ const recoveryBlocked = (exec) => recoveryProblem(temporaryPause, claimOf(exec), gate.snapshot(),
660
+ pendingApprovals.size > 0 || unknownApproval, cu.exited || exitedLatch())
661
+
662
+ // Is the machine PAUSED or ASKING right now? Three independent witnesses, because any one of them
663
+ // can be missing: the core's mirror of the worker's brake (`cu.stopped`), the persisted STOP latch
664
+ // every process obeys, and the gate's own state derived from the panic/ask events. A pause is the
665
+ // human holding the machine: the cycle, its owner and the red border stay until they resume or end
666
+ // it, so nothing that "cleans up idleness" may touch it (F1 — both the idle signal and its
667
+ // watchdog used to).
668
+ const pausedOrAsking = () => {
669
+ try {
670
+ const st = gate.state()
671
+ return st === 'paused' || st === 'asking' || cu.stopped === true || brakeLatch()
672
+ } catch { return false }
673
+ }
674
+
675
+ // The claim a call brings — its OWN session and agent. NOT `bus.driverSession`: that is written
676
+ // only by a previous call, and gating on it is exactly why a brand-new session could never open a
677
+ // cycle (measured 01:19:51: `mine === false`, resume never sent, its first driving call refused).
678
+ const claimOf = (exec) => {
679
+ const s = (exec && (exec.session || exec.sessionId)) ||
680
+ (exec && exec.agent && (exec.agent.session || exec.agent.sessionId)) || null
681
+ return gate.claim({ session: s, agent: exec && exec.agent })
682
+ }
683
+
684
+ const ensureCycle = async (exec) => {
685
+ const cl = claimOf(exec)
686
+ // `gate.open` RESERVES ownership synchronously and THROWS for a foreign claim, so reaching the
687
+ // line below means this call is the owner — and only then is the machine's mailbox rebound.
688
+ const pending = gate.open(cl, async () => {
689
+ if (!cu.exited && !exitedLatch()) return { opened: false, reason: 'nothing to open' }
690
+ if (cu.stopped || brakeLatch()) {
691
+ // A BRAKE IS NOT AN EXITED LATCH: the plugin must never clear a human's brake, and the
692
+ // core's resume() would drift `cu.stopped` while the worker still refused every actuation.
693
+ log.warn('not opening a cycle: a brake is engaged (only the human\'s Ctrl+Alt+R releases it)')
694
+ return { opened: false, reason: 'brake' }
695
+ }
696
+ // The record we are about to clear, quoted exactly, so the worker can refuse if a NEWER one
697
+ // appeared while this op was on its way (an exit during a worker spawn or a stdin write).
698
+ const expect = exitRecordText()
699
+ // Rejections ride the SHARED qualification promise: every later call in this turn awaits the
700
+ // same outcome instead of retrying it into a resurrection.
701
+ await cu.resume(expect)
702
+ if (!cu.exited && !exitedLatch()) {
703
+ log.info('opened a new computer-use cycle for this turn (cleared the Ctrl+Alt+Q record left by an earlier cycle)')
704
+ return { opened: true }
705
+ }
706
+ // Still a record after a resume that reported success: someone wrote a new one in between.
707
+ log.warn('a computer-use exit record is still present after resume — a newer exit stands; the call that follows will be refused')
708
+ return { opened: false, reason: 'a newer exit record stands' }
709
+ })
710
+ // THE MACHINE'S MAILBOX BELONGS TO THE OWNER (integration bug, 2026-09-13). Binding it in
711
+ // `tools/pre-execute` meant EVERY caller rebound it — including one the gate was about to refuse
712
+ // as foreign — so a Ctrl+Alt+Q could cancel the wrong session while the real owner kept working
713
+ // (they also got the pause/ask/R messages). It is rebound here, where ownership is actually
714
+ // taken, and nowhere else; caller-scoped prompts name their own agent explicitly.
715
+ if (gate.owns(cl)) {
716
+ if (exec && exec.agent) bus.agent = exec.agent
717
+ if (cl.sessionKey) bus.driverSession = cl.sessionKey
718
+ }
719
+ return pending
720
+ }
721
+
722
+ // Keep only in-flight accounting for owner lifecycle events. No idle timer: approval waits,
723
+ // thinking, and pauses keep the cycle until an explicit lifecycle event ends it.
724
+ // ---- WHAT THE CARD RECOMMENDS, AND WHAT THE TIMER THEREFORE DOES (2026-09-15) ---------------------
725
+ // ONE place decides both, so the card's text and the timer's behaviour cannot disagree — which is the
726
+ // defect the human caught: the card promised "I will not start an unattended turn" while the design
727
+ // words they remembered promised a branch.
728
+ //
729
+ // ---- AND THE BRANCH IS DECIDED BY THE TASK SITUATION, NOT BY A COUNTER ---------------------------
730
+ // The first implementation capped consecutive automatic continuations with a config number (default 2).
731
+ // The human rejected that design in one sentence: "由且仅由问题任务的情形决定,而不是几次。你现在刚我说
732
+ // 是两次,不应该这样硬编码设计." They are right, and the counter also solved the wrong problem: it rationed
733
+ // the TIMER, when what decides the branch is whether the work is still going.
734
+ //
735
+ // SO THE SITUATION IS READ FROM THE TASK, ONCE PER TURN:
736
+ // * `turnSituation.action = true` — an actuation really reached the worker during this turn, so the task
737
+ // was mid-motion when the turn ended. There IS somewhere to continue to.
738
+ // * nothing was dispatched — the turn was observation, an error, or a wrap-up. There is nothing to
739
+ // continue TO, so the timer makes no decision. That is the human's own rule, not a cap.
740
+ // It is written by the `onDispatch` hook (see `hooks`), which fires at the last statement before the tool
741
+ // body in `tools.js` — after every refusal has thrown — so "dispatched" is a fact, not an intention.
742
+ //
743
+ // ---- AND IT BELONGS TO ONE SESSION, NOT TO THE MACHINE (2026-09-16) ------------------------------
744
+ // `turn/start` is a HOST-WIDE event, so a turn starting in another chat would clear this value — and the
745
+ // mirror case is worse: chat A drives the machine, chat B starts a turn, chat A's turn ends, and A's card
746
+ // carries a recommendation read from a machine-wide flag. `owner` names the session the situation
747
+ // describes, and the timer only trusts it for that session.
748
+ //
749
+ // DECLARED BEFORE `hooks` ON PURPOSE: `hooks.onDispatch` closes over this object, and a body that reads a
750
+ // `const` from its temporal dead zone is a run-time error waiting for the first driving call.
751
+ const RECOMMEND_CONTINUE = '继续(按你自己的理解接着干)'
752
+ const RECOMMEND_STOP = null // "no recommendation" is the honest value when the task says nothing
753
+ const turnSituation = { action: false, reason: '(no actuation reached the worker this turn)', owner: null, seq: [] }
754
+ /** Does the recorded situation describe THIS session's turn? */
755
+ const situationIsMine = (session) => !!turnSituation.owner && turnSituation.owner === sessionKey(session)
756
+
757
+ // ---- THE STAGNATION SIGNATURE: A COMPARISON, NOT A COUNT (2026-09-16) -----------------------------
758
+ // The human rejected a counter ("由且仅由问题任务的情形决定,而不是几次"). The remaining risk is a task that
759
+ // drives on EVERY turn — a poll or retry loop — which no situation gate can stop, because each turn does
760
+ // something. So the guard is a COMPARISON between two situations:
761
+ // * `lastDrive` is the tool sequence that justified the last automatic continuation;
762
+ // * if the next turn's sequence is IDENTICAL and the human has not answered in between, continuing is
763
+ // not progress — the same thing was done again — so the timer stops and says so.
764
+ // It accumulates nothing, hardcodes no number, and any real progress changes the sequence. Its honest
765
+ // weakness: a task that legitimately repeats one identical step would stop one iteration early — which
766
+ // is the conservative direction, because the card is still raised and the human decides.
767
+
768
+ let lastDrive = null // { seq: string[] } — the sequence behind the last timer-driven continuation
769
+ let humanAnsweredSince = false
770
+ // ---- AND THE OBSERVATION-ONLY CASE (point 2, 2026-09-16) -----------------------------------------
771
+ // A turn that only looked (a screenshot, a UIA read) has no dispatch, so the situation gate above said
772
+ // "no recommendation" even when the work was plainly mid-task. `lastTurnDrove` carries the previous turn's
773
+ // state forward for exactly one turn: an observation turn that FOLLOWS a driving turn is still mid-task,
774
+ // so it may recommend once — and if the agent then keeps observing without driving, the same similarity
775
+ // check stops it (the sequence would repeat).
776
+ let lastTurnDrove = false
777
+ const stalledNow = (session) => !!lastDrive && !humanAnsweredSince && situationIsMine(session) &&
778
+ lastDrive.seq.length > 0 && lastDrive.seq.join('>') === turnSituation.seq.join('>')
779
+ /** Record the situation that justified a continuation, so the NEXT identical one can be recognised. */
780
+ const noteDrive = () => { lastDrive = { seq: [...turnSituation.seq] } }
781
+
782
+ const recommendContinueNow = (kind, session) => {
783
+ if (config.turnEndTimeoutAction !== 'recommended') return RECOMMEND_STOP
784
+ if ((kind || 'completed') !== 'completed') return RECOMMEND_STOP // an errored turn is never continued
785
+ if (cu.stopped || brakeLatch()) return RECOMMEND_STOP // a brake outranks a timer
786
+ // A SITUATION THAT DOES NOT BELONG TO THIS TURN IS NO SITUATION: without this, an actuation in another
787
+ // chat could recommend continuing in this one.
788
+ if (!situationIsMine(session)) return RECOMMEND_STOP
789
+ if (turnSituation.action) return continueOrStop(session)
790
+ // Observation-only turn: still mid-task if the PREVIOUS turn drove, and only when that situation is not
791
+ // itself a repetition of what the last automatic continuation already acted on.
792
+ if (lastTurnDrove && turnSituation.seq.length > 0) return continueOrStop(session)
793
+ return RECOMMEND_STOP
794
+ }
795
+ /**
796
+ * The ONE mapping from "there is work to continue" to the recommendation. The polarity lives here and
797
+ * nowhere else: an inverted ternary at a call site would continue exactly when the task did nothing, and
798
+ * the reviewer showed that regression can keep every other assertion green.
799
+ */
800
+ const continueOrStop = (session) => (stalledNow(session) ? RECOMMEND_STOP : RECOMMEND_CONTINUE)
801
+ /** Why the recommendation came out the way it did — for the log, which must not have to guess. */
802
+ const recommendationReason = (session) => {
803
+ if (stalledNow(session)) return '(the same sequence of tools was already continued for — repeating it is not progress)'
804
+ if (turnSituation.action) return turnSituation.reason
805
+ if (lastTurnDrove && turnSituation.seq.length > 0) return `(the previous turn drove the machine and this one only observed: ${turnSituation.seq.join('>')})`
806
+ return turnSituation.reason
807
+ }
808
+
809
+ const hooks = {
810
+ // ---- THE TASK SITUATION IS WRITTEN AT THE DISPATCH DOOR, NOT AT THE POLICY DOOR (2026-09-16) -----
811
+ // `tools/pre-execute` runs BEFORE every refusal — the ownership verdict, the voided opening, the
812
+ // cancellation, and the policy/brake gates inside `reg`. Marking the situation there meant a call that
813
+ // was denied or refused could claim the task was in motion (and, worse in the other direction, a
814
+ // HIGH-RISK actuation the human had just APPROVED looks like `kind: 'ask'` at the policy door, so the
815
+ // timer would silently never recommend exactly when a dangerous action had just been authorised).
816
+ // `tools.js` calls this immediately before `inner(args, exec)` — the last statement before the tool
817
+ // body, after every refusal has already thrown. So this is the one place where "it was dispatched" is
818
+ // a fact rather than an intention, which is what the recommendation is supposed to rest on.
819
+ onDispatch: (exec) => {
820
+ try {
821
+ if (!exec || typeof exec.name !== 'string' || !exec.name.startsWith('computer_')) return
822
+ // ---- EVERY computer_* DISPATCH IS RECORDED, AND THIS LINE WAS MISSING (defect, 2026-09-16) -----
823
+ // The comment that used to sit here claimed "observation calls are recorded too". The code did the
824
+ // opposite: it returned early for a non-driving call, so `seq` held DRIVING calls only. Two
825
+ // consequences, both silent:
826
+ // * the observation-only rule in `recommendContinueNow` (`lastTurnDrove && seq.length > 0`) could
827
+ // never fire — `seq` was empty by construction on exactly the turn it was written for, so the
828
+ // feature was dead code that read as working;
829
+ // * `seq` could not serve as "this turn touched the machine at all", which is the fact the
830
+ // turn-end gate now needs (a billed, immediately-failing turn must not raise a card).
831
+ // `seq` is the turn's SIGNATURE (which tools ran, in order); `action` stays the narrower claim
832
+ // (something was ACTUATED) because the recommendation's honesty rests on that distinction.
833
+ turnSituation.seq.push(`${exec.name}`)
834
+ if (!isDrivingCall(exec.name, exec.arguments || {})) return
835
+ turnSituation.action = true
836
+ turnSituation.reason = `(${exec.name} actuated the machine during this turn)`
837
+ } catch { /* a recommendation must never break a dispatch */ }
838
+ },
839
+ onStart: () => { inFlight++ },
840
+ onEnd: () => { inFlight = Math.max(0, inFlight - 1) },
841
+ // ---- THE WRAP-UP DIRECTIVE (2026-09-15) --------------------------------------------------------
842
+ // WHAT WENT WRONG. A human brake (ESC, a mouse shake, the wheel, typing outside the host) engaged
843
+ // the brake and steered a "WRAP UP NOW" notice into the turn — but `agent.steer` only delivers at a
844
+ // STEP BOUNDARY, so a turn already inside a long or in-flight call never read it, and OBSERVATION
845
+ // calls kept succeeding while paused (`worker.cs:2401`: "the brake stops CONTROL, not
846
+ // OBSERVATION"). The result the human actually saw, twice, in their own words: "卡停就是让你停住,
847
+ // 关键是你没停啊? 你还在思考中啊" — the brake stopped the MACHINE while the agent kept walking,
848
+ // because there was always one more legal step to take.
849
+ //
850
+ // THE FIX IS NOT TO CANCEL THE TURN (that was offered and rejected): the human's design is
851
+ // "让我在知道我触发红框暂停之后快速收尾" — wrap up QUICKLY, not die silently. So every computer_*
852
+ // call made while the brake is up is refused AT THE ONLY DOOR THEY ALL USE (registerTools' `reg`),
853
+ // and the refusal itself carries the wrap-up directive. The instruction therefore arrives as the
854
+ // RESULT of the call the model is already making — no step boundary to wait for, nothing to miss.
855
+ //
856
+ // Observations are refused too, deliberately. Leaving them open is what made "wrap up" optional:
857
+ // the model could always take one more look, and did. The cost is real and accepted — the agent
858
+ // cannot inspect the frozen screen to describe it — so the refusal names the brake's reason, which
859
+ // is the one fact it would have gone looking for.
860
+ //
861
+ // `asking` is NOT refused: a question is the safety direction and the card is the answer surface.
862
+ // `ended` is left to the cycle gate, which already owns that verdict.
863
+ // SAFETY-DIRECTION EXEMPTIONS, and they are NOT optional: `computer_ctrl`, `computer_ask` and
864
+ // `computer_batch` all go through the SAME `reg` wrapper, so a blanket refusal while paused would
865
+ // have blocked the very tools the human needs in a stop — `computer_ctrl`'s `exit` (Ctrl+Alt+Q's
866
+ // twin), its `stop`, and the `acknowledge` read-receipt — plus the question card itself. The skill
867
+ // states the rule plainly: observation may be gated, the safety direction never is.
868
+ askBlocked: (toolName) => {
869
+ // SAFETY-DIRECTION TOOLS ARE NEVER GATED: `computer_ctrl` carries exit/stop/acknowledge/recover
870
+ // and `computer_ask` is the question card. All three of these (plus `computer_batch`, which
871
+ // reaches the same HANDLERS map) go through the one `reg` door, so a blanket refusal would have
872
+ // blocked exactly what must keep working while stopped — the skill states the rule plainly.
873
+ if (toolName === 'computer_ctrl' || toolName === 'computer_ask') return null
874
+ // ONLY DRIVING IS REFUSED — the allowlist is the OBSERVATION tools, and everything else falls
875
+ // through to be refused. (First shipped as "refuse everything", which was a REGRESSION the
876
+ // independent audit caught: the AGENT'S OWN temporary pause also lands here, and `recover`
877
+ // requires a successful observation first — `worker.cs` `RecoveryPermit.Observed` is set only by
878
+ // windows/uia/capture/uiaFromPoint, and the worker answers `read the current target state after
879
+ // this pause first` without it. Refusing observation turned `computer_ctrl{temporary:true}` into
880
+ // a one-way door whose only exit was the human's Ctrl+Alt+R, while the text blamed the human.)
881
+ // `computer_wait` and a clipboard READ are not observation ops in the worker's sense, but they
882
+ // actuate nothing either, so they are deliberately left legal: refusing them would add no safety
883
+ // and would hide the reason the agent is stopped.
884
+ const OBSERVATION = toolName === 'computer_shot' || toolName === 'computer_state' ||
885
+ toolName === 'computer_marks' || toolName === 'computer_uia'
886
+ const st = gate.state()
887
+ if (st !== 'paused') return null
888
+ const agentOwnPause = !!temporaryPause
889
+ if (agentOwnPause) {
890
+ // An agent-owned diagnostic pause: observation must stay legal (it is how `recover` becomes
891
+ // reachable), and actuation stays refused. The exit route is named because it is a credential,
892
+ // and losing it really does leave only the human's Ctrl+Alt+R.
893
+ if (OBSERVATION) return null
894
+ return 'this is YOUR OWN temporary pause (computer_ctrl{temporary:true}) — actuation is refused ' +
895
+ 'until you release it. Observe the target, confirm the blocker is gone, then call ' +
896
+ "computer_ctrl{action:'recover', pauseId:'<the credential your stop returned>'}. " +
897
+ 'Only a human Ctrl+Alt+R can release it if that credential is lost.'
898
+ }
899
+ if (OBSERVATION) {
900
+ return 'the human has STOPPED this machine — ' + (brakeWhy || 'a brake is engaged') + '. ' +
901
+ 'Nothing will be dispatched, not even a look at the screen, so do not try. ' +
902
+ 'WRAP UP NOW in one or two sentences: what was interrupted and the exact state you left it in. ' +
903
+ 'Then stop and wait. Do NOT retry, do NOT start anything new, do NOT end the session, and do ' +
904
+ 'not ask the human to resume — Ctrl+Alt+R is theirs and it will wake you with "继续".'
905
+ }
906
+ return 'the human has STOPPED this machine — ' + (brakeWhy || 'a brake is engaged') + '. ' +
907
+ 'Nothing that drives it will be dispatched, so do not try. ' +
908
+ 'WRAP UP NOW in one or two sentences: what was interrupted and the exact state you left it in. ' +
909
+ 'Then stop and wait. Do NOT retry, do NOT start anything new, do NOT end the session, and do ' +
910
+ 'not ask the human to resume — Ctrl+Alt+R is theirs and it will wake you with "继续".'
911
+ },
912
+ // THE ANSWER CHANNEL (merge-design.md §5.4 item 23). It must exist BEFORE the first
913
+ // `<ctx.tools.register>` call, because `computer_ask`'s handler reaches it — so it is assigned
914
+ // here, where `hooks` is built, exactly like `temporaryStop`/`recover` below. It reads the
915
+ // lazily-injected `asker` at CALL time (inside askOne), never at build time, so a
916
+ // `ctx.inject(['userQuestions'])` callback that lands later is still picked up.
917
+ askHuman: (question, info, exec) => askOne(question, exec),
918
+ temporaryStop: async (exec, why) => {
919
+ const claim = claimOf(exec), cycle = gate.snapshot(), epoch = recoveryEpoch
920
+ if (!claim.sessionKey || !claim.agentKey || !gate.owns(claim) || cycle.state !== 'open' ||
921
+ pendingApprovals.size || unknownApproval || exec?.signal?.aborted) {
922
+ throw new Error('temporary pause requires the active owner and no pending approval; use ordinary stop for a handover')
923
+ }
924
+ const result = await cu.panic(why, true)
925
+ if (result.pauseId && epoch === recoveryEpoch && gate.owns(claim) && gate.snapshot().id === cycle.id) {
926
+ temporaryPause = { pauseId: result.pauseId, cycleId: cycle.id, ownerIdent: claim.ident }
927
+ }
928
+ return result
929
+ },
930
+ recover: async (exec, pauseId) => {
931
+ const problem = recoveryBlocked(exec)
932
+ if (problem || pauseId !== temporaryPause?.pauseId || exec?.signal?.aborted) {
933
+ return { recovered: false, reason: problem || 'stale credential or cancelled call' }
934
+ }
935
+ const epoch = recoveryEpoch
936
+ const result = await cu.recover(pauseId)
937
+ if (result.recovered) {
938
+ if (epoch !== recoveryEpoch || recoveryBlocked(exec) || exec?.signal?.aborted) {
939
+ // An approval or Host interruption arrived while the native reply was in flight.
940
+ if (!cu.exited && !exitedLatch()) await cu.panic('recovery superseded by a newer Host state')
941
+ return { recovered: false, reason: 'a newer Host state kept the machine paused' }
942
+ }
943
+ gate.mark('open', { why: 'verified recovery of the owner agent temporary pause' })
944
+ temporaryPause = null
945
+ }
946
+ return result
947
+ },
948
+ }
949
+
950
+ // The brake must look and behave like a brake even if nothing is being driven right now.
951
+ // The message SHAPE lives in `buildUserMessage` at module scope, because getting it wrong is a
952
+ // measured crash (see that function): `agent.steer` publishes into the agent inbox, and the harness's
953
+ // own listeners read `message.source.kind`.
954
+ const buildMessage = (text, summary) => buildUserMessage(text, 'notice', summary)
955
+
956
+ // Coalesce notices: the same kind at most once every 4 s. A burst of cards in the conversation
957
+ // is itself a failure mode (reported 2026-09-12: seven injected cards in a row, "非常混乱") —
958
+ // ESC, Ctrl+Alt+Q and the turn-cancel each emit an event, and the human often presses several.
959
+ let lastNoticeKey = ''
960
+ let lastNoticeAt = 0
961
+ const notice = async (text, summary, opts) => {
962
+ const nowMs = Date.now()
963
+ if (summary === lastNoticeKey && nowMs - lastNoticeAt < 4000) {
964
+ log.info(`notice coalesced (${summary})`)
965
+ return
966
+ }
967
+ lastNoticeKey = summary
968
+ lastNoticeAt = nowMs
969
+ // WHO RECEIVES IT (integration bug, 2026-09-13). `bus.agent` is the MACHINE's event recipient and
970
+ // is bound to the cycle's OWNER; a caller-scoped message (the procedure nudge, the acknowledge
971
+ // receipt) passes its own agent explicitly, because those are addressed to the caller — but they
972
+ // must never move the machine's mailbox. `opts.agent` is that explicit address, and it is read
973
+ // here, synchronously, so a later await cannot re-route a message already being delivered.
974
+ const agent = (opts && opts.agent) || bus.agent
975
+ if (!agent) { log.info(`brake notice (no live agent captured yet): ${summary}`); return }
976
+ const msg = await buildMessage(text, summary)
977
+ const wake = !!(opts && opts.wake)
978
+ // THE LAST ASYNC BOUNDARY BEFORE DELIVERY (boundary bug, 2026-09-13). `buildMessage` awaits a
979
+ // dynamic import, so a caller's `valid` predicate is re-checked HERE — after every await and
980
+ // immediately before the steer/inject. A Ctrl+Alt+Q that lands while a "继续" is being built must
981
+ // invalidate it: otherwise the human ends the session and the agent is woken anyway by the wake
982
+ // that was already in flight (measured: steer called once with the gate correctly `ended`).
983
+ if (opts && typeof opts.valid === 'function') {
984
+ let ok = false
985
+ try { ok = !!opts.valid() } catch { ok = false }
986
+ if (!ok) { log.info(`notice dropped at delivery: the state changed while it was being built (${summary})`); return }
987
+ }
988
+ try {
989
+ // Shape copied from the in-tree `dsh-tool-jobs` notifier: a RUNNING agent is steered (it
990
+ // is heading for a step boundary anyway, so the notice is read at the next step of the
991
+ // SAME turn, without a human message); an IDLE agent gets a silent inject, because
992
+ // steering an idle agent wakes the driver and would start — and bill — a whole new turn.
993
+ //
994
+ // `wake` INVERTS that default on purpose, and only Ctrl+Alt+R uses it. After a brake the
995
+ // turn is over — the agent wrapped up and stopped — so a silent inject left the human
996
+ // stuck: the red border went away, nothing was running, and the only way to get the agent
997
+ // moving again was to type a message by hand. The human's spec (2026-09-12): "我
998
+ // CTRL+ALT+R 之后,相当于是在和你的对话框里直接输'继续'两个字". Steering an idle agent is
999
+ // EXACTLY that — it starts a turn — so R now wakes the agent instead of merely informing it.
1000
+ if (wake || agent.status === 'running') { agent.steer(msg); log.info(`steered${wake ? ' (wake)' : ''}: ${summary}`) }
1001
+ else { agent.inject(msg); log.info(`injected (idle): ${summary}`) }
1002
+ } catch (e) { log.warn(`could not deliver the brake notice: ${e.message}`) }
1003
+ }
1004
+
1005
+ // The brake. WHO caused it is carried by `why` — do not name a key here: the same brake also
1006
+ // is reserved for a durable manual/explicit pause. Ordinary physical activity
1007
+ // travels through the core handoff state and never injects a wrap-up notice.
1008
+ cu.on('panic', (e) => {
1009
+ // NO calm() HERE (F1, 2026-09-13). calm in the worker is `CycleLit=false; Disarm(); Glow.Kill()`:
1010
+ // on a PAUSE it ended the cycle the pause is supposed to keep alive, dropped the monitors, and
1011
+ // made `probe` report a dead cycle — while the red border survived anyway, because Engage had
1012
+ // already set `_stoppedMode` and the render loop derives want=4 from it. A pause keeps the
1013
+ // cycle, its ownership and the human's Ctrl+Alt+R; it is not a cycle end.
1014
+ gate.mark('paused', { why: (e && e.why) || 'panic' })
1015
+ // REMEMBER THE REASON for `hooks.askBlocked`: the panic event is consumed here and cannot be
1016
+ // replayed when a later call is refused, and the brake's reason is the one thing a wrapping-up
1017
+ // agent would otherwise have made a (now-refused) observation to find out.
1018
+ brakeWhy = (e && e.why) || 'the human stopped the machine'
1019
+ if (e && e.pauseId) {
1020
+ // This was requested by the active agent; its tool result carries the credential.
1021
+ // Do not inject WRAP UP / wait-for-human instructions into its diagnostic work.
1022
+ return
1023
+ }
1024
+ recoveryEpoch++; temporaryPause = null
1025
+ notice(
1026
+ `COMPUTER USE STOPPED — ${(e && e.why) || 'unknown reason'}. ` +
1027
+ 'Every actuation is refused and the screen border is red until a human resumes with Ctrl+Alt+R. ' +
1028
+ 'WRAP UP NOW in one or two sentences: what was interrupted and the exact state you left it in. ' +
1029
+ 'Then stop working and wait. Do not retry, do not start anything new, and do not call resume yourself. ' +
1030
+ 'Write that state line so it can be resumed FROM — Ctrl+Alt+R will wake you with "继续". ' +
1031
+ '(The human can also press Ctrl+Alt+Q to end the session outright.)',
1032
+ // The summary is a COALESCING KEY and a card title, not a diagnosis: name the EVENT, never
1033
+ // a key. It read "computer-use aborted (ESC)" until 2026-09-13, when the human's own log
1034
+ // showed a wall of abort cards labelled ESC whose real reason was `vk 0xA4` (left Alt, i.e.
1035
+ // their Ctrl+Alt+R re-arm) — the label had been lying about the mouse monitor and the
1036
+ // keyboard monitor for a whole session, exactly what the comment above warns against.
1037
+ 'computer-use aborted')
1038
+ })
1039
+ // Ctrl+Alt+R is the human's 继续 key: it must WAKE the agent, not merely clear the border.
1040
+ // Before 2026-09-12 it only cleared the border, so after a mouse-shake brake the human was left
1041
+ // holding a dead turn and had to type a message by hand to get the agent moving again — their
1042
+ // own report: "再按 CTRL+ALT+R 只是消除红框,没有办法继续注入 prompt 激活你了".
1043
+ // A wake that costs one turn is the POINT here; everything else still gets the silent inject.
1044
+ // THE AGENT ASKED A QUESTION (2026-09-12). The worker has stopped the machine and raised the
1045
+ // fast-breathing red border; the QUESTION itself is no longer injected here as a notice — the
1046
+ // client-UI CARD is the answer surface now (merge-design.md §5.3 item 19). It carries the two
1047
+ // choices and waits with no countdown, and the notice that used to live here promised a clock and
1048
+ // duplicated the question ("if you touch nothing, the agent decides alone"), which stopped being
1049
+ // true the moment the worker lost its clock. A card and a chat notice showing the same question is
1050
+ // exactly the "非常混乱" burst the notice coalescer exists to avoid. What stays is the STATE this
1051
+ // event carries, which the cycle gate needs to hold the owner while the question is open.
1052
+ cu.on('ask', () => {
1053
+ recoveryEpoch++; temporaryPause = null
1054
+ // ASKING IS NOT EXITING (C). The worker has paused the machine INSIDE a live cycle (red,
1055
+ // fast-breathing, no countdown); the cycle keeps its owner so the answer resumes it.
1056
+ // (`e.why` still carries the question text and is deliberately unused here — the card shows it.)
1057
+ gate.mark('asking', { why: 'ask' })
1058
+ })
1059
+
1060
+ // THE ASK WINDOW CLOSED — FINISH THE `asking` STATE (lifecycle bug, 2026-09-13). Only a human
1061
+ // Ctrl+Alt+R used to move the gate off `asking`, and an unanswered ask never emits `resume` on
1062
+ // purpose, so the machine stayed "asking" for good: the owner's idle and its normal turn/end were
1063
+ // both refused as if the human were still holding it, the light never closed, and EVERY other
1064
+ // session was refused forever. This is not a resume and must never be reported as one: no resume
1065
+ // event, no brake cleared. It only settles the state the gate was holding open, from the truth the
1066
+ // core mirrored AFTER the window closed — so a late exit (state `ended`, which the gate refuses to
1067
+ // leave) or a newer stop (mirrored as stopped) keeps whatever is really there.
1068
+ cu.on('ask-ended', (r) => {
1069
+ try {
1070
+ if (gate.state() !== 'asking') return // a newer state already moved on: not ours to finish
1071
+ if (r && r.exited) { gate.mark('ended', { why: 'the ask window ended in an exit' }); return }
1072
+ if (!r || r.stopped) { gate.mark('paused', { why: 'the ask window ended with a stop standing' }); return }
1073
+ gate.mark('open', { why: 'the ask window closed with no brake (no human input)' })
1074
+ } catch { /* never break the host */ }
1075
+ })
1076
+
1077
+ // Fires ONLY for the human's own Ctrl+Alt+R: the worker notifies 'resume' from Clear() alone,
1078
+ // and the plugin's turn-boundary `resume` op is silent by design (it must not wake the model).
1079
+ cu.on('resume', (e) => {
1080
+ // The HUMAN re-armed (the worker notifies `resume` from Clear() alone): the pause is over, the
1081
+ // cycle continues with the same id and the same owner — R is a resume, never a new cycle.
1082
+ //
1083
+ // ONLY A VALID CURRENT CYCLE MAY WAKE ANYONE (boundary bug, 2026-09-13). The return value of
1084
+ // `mark` used to be ignored, so a resume arriving after an exit still steered "继续" into an idle
1085
+ // agent while the gate correctly stayed `ended` — the flag said "over" and the agent was woken
1086
+ // anyway. A LEGITIMATE R after a pause is untouched: that is the one case `mark('open')` accepts.
1087
+ if (!gate.mark('open', { why: 'human re-armed (Ctrl+Alt+R)' })) {
1088
+ log.info('resume ignored: the cycle is ended (Ctrl+Alt+Q stands) — nobody is woken')
1089
+ return
1090
+ }
1091
+ brakeWhy = '' // the brake is off, so there is no reason left to hand to a refused call
1092
+ const version = gate.exitVersion()
1093
+ notice(
1094
+ '继续 — the human re-armed computer use with Ctrl+Alt+R: the brake is cleared and the red border ' +
1095
+ 'is gone. Actuation works again. Resume from the break point in your own last wrap-up: do not ' +
1096
+ 'restart the task from scratch, and do not re-do what was already verified.',
1097
+ 'computer-use resumed', {
1098
+ wake: true,
1099
+ // Re-checked at the last boundary: a Q during message construction makes this wake stale, and
1100
+ // a stale wake must never start a new turn on a session the human just ended.
1101
+ valid: () => gate.state() === 'open' && gate.exitVersion() === version && !cu.exited,
1102
+ })
1103
+ })
1104
+ // Ctrl+Alt+Q = **EXIT** (彻底退出). The cycle is over: deliver the worker's own record AND cut the
1105
+ // turn off immediately — a notice alone is not enough, because the model would simply keep working
1106
+ // (observed 2026-09-13: it read the stop notice and went on to run a diagnostic command).
1107
+ cu.on('exit', async (e) => {
1108
+ // TERMINAL FOR THIS CYCLE ID (design: "Q终结当期周期"). Nothing — not R, not a mouse event, not a
1109
+ // late callback, not a queued call in this same turn — may move this id back to a live state; a
1110
+ // new cycle needs a new turn's first real call and gets a new id (lib/cycle.js).
1111
+ gate.mark('ended', { why: (e && e.why) || 'exit event' })
1112
+ cu.calm().catch(() => {})
1113
+ selfAbortUntil = Date.now() + 8000
1114
+ // CAPTURE THE RECIPIENT BEFORE THE FIRST `await` (integration bug, 2026-09-13). The notice below
1115
+ // awaits message construction and delivery, and anything that rebinds `bus.agent` during that
1116
+ // window would move the turn cancel to a DIFFERENT session than the one whose cycle just ended —
1117
+ // the owner would keep working while a bystander got cancelled. The owner is read once, here.
1118
+ const recipient = bus.agent
1119
+ // `e.why` is a MEASUREMENT the worker recorded when its detector fired, not a verdict about the
1120
+ // human — e.g. "Ctrl+Alt+Q chord detected at 2026-09-13 01:17:44 (source=hook) [vk=0x51 extra=0x0
1121
+ // trackedCtrl=1 trackedAlt=1 asyncCtrl=1 asyncAlt=1 fgPid=… fg=MarkPilot]". It is quoted
1122
+ // VERBATIM, because it is the only evidence anyone has.
1123
+ //
1124
+ // The text this replaced hard-coded an accusation — it named the human as the author of the key
1125
+ // press — and an agent duly repeated it back to them ("you pressed Ctrl+Alt+Q") for a chord the
1126
+ // record showed had been detected minutes earlier, in a session that had already ended, possibly
1127
+ // by nobody's hand at all. The worker's own refusal message carries the same correction.
1128
+ const record = (e && e.why) || 'the worker sent no record with this event'
1129
+ await notice(
1130
+ `computer-use exited — the worker's record, quoted verbatim: ${record}\n` +
1131
+ 'computer use is over for this session: do not continue the task, do not start anything new.\n' +
1132
+ 'THAT IS A RECORD, NOT AN ACCUSATION. It is a line the worker\'s own detector wrote at the ' +
1133
+ 'moment it fired: it may carry the timestamp it measured, the witness that saw it (source=…) and ' +
1134
+ 'the raw fields it read — a record written by an older build may carry only the sentence, which ' +
1135
+ 'is less evidence still. Either way it does NOT prove the human pressed anything: the detector ' +
1136
+ 'cannot see whose keys those were, and its timestamp may be long before this turn began. Do NOT ' +
1137
+ 'tell the human that they pressed Ctrl+Alt+Q. Report it as measured: quote the line, with its ' +
1138
+ 'time and its witness if it has them, and say plainly that it is history rather than something ' +
1139
+ 'that just happened.',
1140
+ 'computer-use exited', { agent: recipient })
1141
+ const agent = recipient
1142
+ if (agent && typeof agent.cancel === 'function') {
1143
+ // keepInbox: the notice above must survive the abort so it is still there next time.
1144
+ setTimeout(() => {
1145
+ try {
1146
+ agent.cancel({ kind: 'hook', reason: 'computer-use exit (Ctrl+Alt+Q)' }, { keepInbox: true })
1147
+ // NOT "the human exited": that is the same unmeasured claim the notice above was just
1148
+ // corrected for. The worker recorded a chord; who pressed it is not in evidence.
1149
+ log.warn('computer-use exit record received — turn cancelled')
1150
+ } catch (err) { log.warn(`cancel failed: ${err.message}`) }
1151
+ }, 120)
1152
+ }
1153
+ // NOTHING is resumed here — on purpose. An earlier version called cu.resume() 900 ms after the
1154
+ // exit "so the red border is gone", and that single call WAS the bug: resume is the only thing
1155
+ // that un-latches EXITED, so the plugin itself resurrected the cycle it had just killed, and
1156
+ // the next stray op lit the border again. The exit already leaves no brake behind (worker
1157
+ // Panic.Exit clears _engaged/_stopped and every visual flag, and kills the overlay); if that
1158
+ // ever stops being true the honest fix is inside Panic.Exit, not a resurrection here.
1159
+ // Human's model, 2026-09-13: "Computer use 周期结束之后,所有跟 computer use 相关的监听,全部
1160
+ // 都要终结" — and the next cycle is opened by the PLUGIN when a computer_* call actually reaches
1161
+ // the worker (ensureCycle above, once per turn), never by this handler and never by a stray op.
1162
+ })
1163
+
1164
+ // Precise "the agent stopped driving" signal: a turn ended, so cut the border NOW (no fade).
1165
+ //
1166
+ // IDENTITY (2026-09-13, B from the review): this event is host-wide and carries `agent`; a
1167
+ // background subagent — or any other session — going idle used to call calm() on the MACHINE's
1168
+ // cycle, which is `CycleLit=false; Disarm(); Glow.Kill()` in the worker: the border went dark and
1169
+ // the ESC/Q/R keyboard hooks stayed but the monitors dropped, for a turn that had nothing to do
1170
+ // with this machine. Only the OWNER's idle may cut the owner's light.
1171
+ //
1172
+ // PAUSE/ASK (F1): an idle while the machine is PAUSED or ASKING is not "the task finished" — the
1173
+ // cycle is alive and its red border must stay until the human answers or re-arms. calm() would end
1174
+ // it (CycleLit=false), which is exactly the state lie we are fixing.
1175
+ ctx.on('agent/status', (payload) => {
1176
+ try {
1177
+ const st = payload && (payload.status || payload.state)
1178
+ if (st !== 'idle' || inFlight !== 0) return
1179
+ const whoKey = agentKeyOf(payload && payload.agent)
1180
+ // Another agent's idle is not our business. When we cannot identify the agent, stay silent
1181
+ // rather than guess: an unlit border on someone else's turn is the bug we are fixing.
1182
+ if (!whoKey || !gate.ownsAgent(whoKey)) {
1183
+ log.info(`agent/status idle ignored (${whoKey ? 'another agent' : 'no agent identity on the event'})`)
1184
+ return
1185
+ }
1186
+ const paused = pausedOrAsking()
1187
+ if (paused) {
1188
+ log.info(`agent/status idle while ${gate.state()} — the cycle stays alive (Ctrl+Alt+R resumes it)`)
1189
+ return
1190
+ }
1191
+ gate.mark('ended', { why: 'owner agent idle', session: gate.ownerSessionKey(), agent: payload && payload.agent })
1192
+ cu.calm().catch(() => {})
1193
+ } catch { /* the indicator must never break the host */ }
1194
+ })
1195
+
1196
+ // Ownership is released by FACTS, never by elapsed time. A disposed owner agent is such a fact:
1197
+ // without it a session that died mid-turn would own the machine until the host restarted, and no
1198
+ // timer may take a PAUSED cycle away (the human's rule: "暂停只能由人的恢复/终结解除,时间不是授权").
1199
+ ctx.on('agent/disposed', (payload) => {
1200
+ try {
1201
+ const whoKey = agentKeyOf(payload && payload.agent)
1202
+ if (whoKey && gate.ownsAgent(whoKey)) {
1203
+ log.info(`owner agent disposed (${whoKey}) — releasing the machine for the next real call`)
1204
+ gate.release('owner agent disposed')
1205
+ }
1206
+ } catch { /* never break the host */ }
1207
+ })
1208
+
1209
+ // Host cancellation stops the owning turn's input immediately. It is separate from
1210
+ // Ctrl+Esc, which is the physical keyboard path to a persistent manual pause.
1211
+ ctx.on('session/event', (session, event) => {
1212
+ try {
1213
+ if (event?.type === 'approval/asked') {
1214
+ recoveryEpoch++
1215
+ if (event.data?.id) pendingApprovals.add(event.data.id)
1216
+ else unknownApproval = true
1217
+ } else if (event?.type === 'approval/decided' && event.data?.id) pendingApprovals.delete(event.data.id)
1218
+ if (!event || !bus.agent) return
1219
+ const reason = event.data && event.data.reason
1220
+ const kind = reason && reason.kind
1221
+ // WHOSE turn? This event is host-wide, and a background subagent starting a turn is not this
1222
+ // session deciding to drive this machine.
1223
+ // THE ONE PLACE A DEAD CYCLE MAY COME BACK. Human's model (2026-09-13): "Computer use 周期
1224
+ // 结束之后,必须得在下一个 session 开启之后才可能会再拉起新周期." So the PLUGIN opens it,
1225
+ // once per turn — never an op, because an op carries no idea which turn it belongs to, which
1226
+ // is precisely how a stray call from a finished turn used to resurrect the border.
1227
+ if (event.type === 'turn/start') {
1228
+ // RENEWAL IS PER SESSION (B). This event is host-wide; bumping a machine-wide latch here is
1229
+ // what let another session's turn re-open a cycle that had already been ended. Bumping THIS
1230
+ // session's generation is all a turn boundary is allowed to do — and it is what gives a
1231
+ // brand-new session its qualification, since it never has to match a remembered driver.
1232
+ if (session) gate.noteTurnStart(session)
1233
+ // A NEW TURN CLEARS THE TASK SITUATION. Per turn, never accumulated: the recommendation the timer
1234
+ // may execute is about THIS turn's work, so the flag, the sequence and the owner all start empty.
1235
+ // `lastTurnDrove` CARRIES THE PREVIOUS TURN FORWARD ON PURPOSE (the observation-only case), and the
1236
+ // stagnation signature (`lastDrive`) is history, not per-turn state — resetting either here would
1237
+ // silently disable both guards.
1238
+ lastTurnDrove = turnSituation.action
1239
+ turnSituation.action = false
1240
+ turnSituation.reason = '(no actuation reached the worker this turn)'
1241
+ turnSituation.owner = sessionKey(session)
1242
+ turnSituation.seq = []
1243
+ declaredRecommendation = undefined
1244
+ const owner = gate.ownerSessionKey()
1245
+ if (!owner || !session || sameSession(session, owner)) nudgedTurn = false
1246
+ // NOTHING IS OPENED HERE (A/B). The opener is the first real call of the turn — the only
1247
+ // place the qualification is consumed, and the only place that may touch the exit record.
1248
+ // A `resume` sent from a turn boundary is a resurrection path with no call to justify it,
1249
+ // which is exactly how the border came back on a session that had ended.
1250
+ return
1251
+ }
1252
+ if (event.type !== 'turn/end') return
1253
+ // WHOSE turn? This event is host-wide. An aborted turn in another session — a background
1254
+ // subagent the human stopped, another chat — is not "the human grabbing this machine", and
1255
+ // braking for it produced a red border out of nowhere. The cycle's own owner is the strongest
1256
+ // identity we have; `bus.driverSession` is only the fallback for when nothing owns the machine.
1257
+ const ownerSk = gate.ownerSessionKey()
1258
+ const foreign = ownerSk
1259
+ ? (session && !sameSession(session, ownerSk))
1260
+ : (bus.driverSession && session && !sameSession(session, bus.driverSession))
1261
+ if (foreign) {
1262
+ log.info(`ignoring ${kind} turn/end from another session (${describeSession(session)})`)
1263
+ return
1264
+ }
1265
+ if (kind === 'aborted' || kind === 'interrupted') {
1266
+ if (selfAbortUntil && Date.now() < selfAbortUntil) {
1267
+ selfAbortUntil = 0
1268
+ log.warn(`turn ended ${kind} — caused by our own Ctrl+Alt+Q exit; NOT re-engaging the brake`)
1269
+ cu.calm().catch(() => {})
1270
+ return
1271
+ }
1272
+ // Host cancellation is per request/turn; only the physical Ctrl+Esc path
1273
+ // raises an input-triggered persistent brake.
1274
+ log.warn(`turn ended ${kind} in a computer-use session — cancelling only its input`)
1275
+ recoveryEpoch++; temporaryPause = null
1276
+ cu.cancelInputs?.()
1277
+ // An explicit Ctrl+Esc pause remains latched. Ordinary Host cancellation ends
1278
+ // this turn and its queued input without inventing a persistent human brake.
1279
+ if (!cu.stopped) {
1280
+ gate.mark('ended', { why: `owner turn ${kind}` })
1281
+ cu.calm().catch(() => {})
1282
+ }
1283
+ return
1284
+ }
1285
+ // ---- THE TURN ENDS; THE DECISION ABOUT THE CYCLE BELONGS TO THE CARD (2026-09-15) -----------
1286
+ // This branch used to CLOSE the cycle first (`gate.mark('ended')`) and only then raise the card.
1287
+ // `'ended'` is irreversible, so the FIRST turn end that reached here ended the cycle — and from
1288
+ // the second turn onward `gate.ownsSession(...)` was false, the whole block was skipped, and the
1289
+ // card could never fire again. Measured: one card at 22:30, then nothing for five turns while
1290
+ // the log filled with `agent/status idle ignored (another agent)`. The human's report was exactly
1291
+ // that: "我还是没亲眼看到触发".
1292
+ //
1293
+ // The human chose 甲: a completed turn NO LONGER closes the cycle on its own. The card decides,
1294
+ // and "关闭周期" is one of its branches — which is also what they asked for in words ("你决定之后
1295
+ // 再说,关闭整个周期,还是继续"). The old worry that motivated auto-closing ("不能让正常完成一律保留成
1296
+ // 永远活着") is now handled by the card: an explicit branch, or the timeout default, ends it.
1297
+ // A pause/ask still keeps the cycle alive for Ctrl+Alt+R, unchanged.
1298
+ // ---- A TURN THAT NEVER TOUCHED THE MACHINE IS NOT A COMPUTER-USE SUSPENSION (defect, 2026-09-16) --
1299
+ // THE DEATH LOOP THIS CLOSES, in the human's own words: "你卡进死循环了 不断弹卡片". Measured in
1300
+ // `turn-end-diag.log`: eleven cards in two and a half minutes (02:38:03 → 02:40:40), every one of them
1301
+ // `kind=error`, and the session transcript says why —
1302
+ // turn 146…156 reason={"kind":"error","error":{"message":"Insufficient Balance","code":"QUOTA"}}
1303
+ // The account was out of credit, so EVERY turn died at step 1. Each death ended a turn, the turn-end
1304
+ // gate asked for a decision about a MACHINE THAT HAD NOT BEEN TOUCHED, the human answered, and their
1305
+ // answer started another turn that died the same way. The card was not wrong about the machine; it was
1306
+ // answering a question nobody had asked, about a machine that was idle, forever.
1307
+ // The honest predicate is already on hand: `turnSituation.seq` is the turn's own dispatch signature
1308
+ // (every `computer_*` call the turn actually dispatched — see `hooks.onDispatch`). Empty means this
1309
+ // turn never reached the machine, so there is no cycle decision to make and the card must not fire. A
1310
+ // billing error, a provider outage or a chat-only turn is reported by the host's own "本轮运行失败";
1311
+ // dressing it as a machine decision is what produced the loop.
1312
+ // It also keeps the human's standing rule intact: "一旦挂起,任何挂起,都必须进入卡片" — a suspension
1313
+ // OF THE MACHINE still always gets its card, and now a turn that suspended nothing no longer fakes one.
1314
+ //
1315
+ // THE RESIDUAL RISK, STATED RATHER THAN HIDDEN: `turnSituation` is ONE slot and `turn/start` is a
1316
+ // host-wide event, so if another session (a background subagent, another chat) starts a turn between
1317
+ // this turn's last dispatch and its end, that start resets `seq` and this gate then suppresses a card
1318
+ // the owner had earned. The failure direction is the safe one — no card, the cycle stays open, and the
1319
+ // machine was never touched by anyone in the meantime — and `recommendContinueNow` has depended on the
1320
+ // same single slot since it was written. Making it exact means keying the situation BY SESSION, which
1321
+ // is a change to the recommendation's own input and is not something to smuggle in behind a bug fix.
1322
+ const touched = situationIsMine(session) && turnSituation.seq.length > 0
1323
+ if (gate.ownsSession(sessionKey(session)) &&
1324
+ gate.state() !== 'paused' && gate.state() !== 'asking' &&
1325
+ !cu.stopped && !brakeLatch() && touched) {
1326
+ diagTurnEnd(`FIRED kind=${kind || 'completed'} asker=${!!asker} raiseHuman=${!!raiseHuman} ` +
1327
+ `seq=${turnSituation.seq.length} session=${String(sessionKey(session)).slice(0, 12)}`)
1328
+ // ---- A COMPUTER-USE TURN MUST NOT END SILENTLY (2026-09-15, the human's standing rule) ----
1329
+ // "一旦挂起,任何挂起,都必须进入卡片… 如果60s没回复,超时之后自动进行分支判断". Until now a
1330
+ // turn end produced a taskbar toast and nothing else: the design's RAISE + CARD + 60 s branch
1331
+ // was never implemented on this path. The 60 s clock that DID exist lived in the worker's
1332
+ // `computer_ask` (`worker.cs:5430`) and was removed, correctly, by the ask merge — and the
1333
+ // plugin-side branch was never added back.
1334
+ //
1335
+ // WHY A DETOUR RATHER THAN A CANCELLABLE CARD: the harness has no way to withdraw a pending
1336
+ // card (checked during the merge — it ends by being answered, dismissed, or by the turn being
1337
+ // torndown). So the card is NOT cancelled. A local timer simply stops waiting on it, which is
1338
+ // the same thing the human asked for and needs no host API. The card may linger in the chat;
1339
+ // answering it later changes nothing, because the race below has already settled.
1340
+ //
1341
+ // AUTO-DECISION: on timeout the agent DOES NOT continue driving on its own. A turn that ended
1342
+ // by itself leaves the machine free and the human absent — starting a new turn unattended is
1343
+ // the one outcome that cannot be undone. The human's own words allow either branch ("是否必须
1344
+ // 终结还是按照你自己的理解继续"); stopping is the reversible one.
1345
+ void askAtTurnEnd(session, kind)
1346
+ } else {
1347
+ // THE ANSWER THAT ACTUALLY NEEDS A REASON. Without this line "the card did not fire" has no
1348
+ // explanation at all, which is exactly where this feature was stuck.
1349
+ diagTurnEnd(`SKIPPED kind=${kind || 'completed'} state=${gate.state()} ` +
1350
+ `owns=${gate.ownsSession(sessionKey(session))} stopped=${!!cu.stopped} latch=${!!brakeLatch()} ` +
1351
+ `touched=${touched} seq=${turnSituation.seq.length} ` +
1352
+ `session=${String(sessionKey(session)).slice(0, 12)}`)
1353
+ }
1354
+ } catch { /* never break the host */ }
1355
+ })
1356
+
1357
+ /**
1358
+ * Raise the Desktop, show the decision card, and stop waiting after the timeout.
1359
+ *
1360
+ * Returns a promise that settles with `{answered, selected, custom, timedOut}` — never rejects.
1361
+ * The card is deliberately left in place on timeout; see the call site for why that is the design
1362
+ * and not an oversight.
1363
+ */
1364
+
1365
+ async function askAtTurnEnd (session, kind) {
1366
+ try {
1367
+ if (!asker || typeof asker.ask !== 'function') return { answered: false, reason: 'no-asker' }
1368
+ // ---- RESOLVE, RECORD, GUARD, AND ONLY THEN RAISE (review re-audit B, 2026-09-15) -------------
1369
+ // The raise used to happen first, so a REFUSED card still restored and focused Desktop for a
1370
+ // question that would never exist — the exact opposite of this file's own rule ("only raise when
1371
+ // a human input is actually pending; never yank the human", see `enableHumanAttention`). Nothing
1372
+ // is lost by reordering: `rootAgentFor` is fully synchronous, so the card loses zero latency, and
1373
+ // the 60 s clock still starts only after the voucher exists (the old version armed a timer whose
1374
+ // promise nobody would ever await).
1375
+ const rp = rootAgentFor(session, bus.agent)
1376
+ // The recommendation is fixed HERE, once, before the card is built — the timer later executes what
1377
+ // the card said, never a fresh judgement made while nobody is watching.
1378
+ const recommend = recommendContinueNow(kind, session)
1379
+ // THE REASON IS CAPTURED HERE, WITH THE DECISION (review E4). `turnSituation` is MUTABLE and a
1380
+ // `turn/start` can land during the 60 s wait, so logging `turnSituation.reason` at fire time could
1381
+ // print "recommended because (no actuation reached the worker this turn)" — a sentence that
1382
+ // contradicts itself. The reason that justified THIS card is the one recorded with it.
1383
+ const recommendReason = recommendationReason(session)
1384
+ // AND THE STALL VERDICT IS ALSO FIXED HERE, WITH THE DECISION. It was computed inside
1385
+ // `recommendContinueNow`, and re-reading it 60 s later would compare against a `turnSituation` that a
1386
+ // `turn/start` may have already replaced — the same mutability trap as the reason above.
1387
+ const stalledAtDecision = stalledNow(session)
1388
+ diagTurnEnd(`CARD kind=${kind || 'completed'} rec=${recommend ? 'yes' : (config.turnEndTimeoutAction === 'recommended' ? 'no(situation/brake)' : 'setting=stop')} ` +
1389
+ `ask=${rp.ask ? 'yes' : 'NO'} via=${rp.reason} ` +
1390
+ `agentId=${rp.agent && rp.agent.id ? String(rp.agent.id).slice(0, 8) : '-'}`)
1391
+ // ---- NEVER ASK WITH AN AGENT THE REGISTRY DID NOT VOUCH FOR --------------------------------
1392
+ // `ask:false` means the resolver could NOT prove this agent is the live root of the session whose
1393
+ // turn just ended. Two ways that ends badly, both worse than no card:
1394
+ // * a live root of ANOTHER session is accepted by `ask()` — the card would appear in a
1395
+ // different chat, asking the human about a turn they are not looking at (review finding 2);
1396
+ // * an agent-less request is dropped by the forwarder (`api-remotes/lib/index.js:117`), so the
1397
+ // ask can only ever come back NO_PROVIDER — a guaranteed-useless round trip that already
1398
+ // cost one debugging round (review finding 3).
1399
+ // The refusal is recorded in the turn-end diagnostic file BEFORE this return, so "the card did
1400
+ // not fire" still has a written reason. `computer_ask` is unaffected: it asks through the
1401
+ // worker's own path.
1402
+ //
1403
+ // THE INDICATOR IS NOT TOUCHED HERE, DELIBERATELY (review re-audit E2). A refusal marks nothing,
1404
+ // so the cycle survives to the owner-idle edge, which is what cuts the border. If that edge has
1405
+ // already passed, the indicator can linger until the next call or exit — the same outcome the
1406
+ // pre-fix NO_PROVIDER produced, so this is not a regression, and guessing a closer here would
1407
+ // mean a card-less turn silently ending a cycle the human may still want.
1408
+ if (!rp.ask || !rp.agent) {
1409
+ log.warn(`turn-end card: NOT raised — the registry did not vouch for a caller of this session (${rp.reason}); ` +
1410
+ 'no card is better than a card in the wrong chat (the cycle is left to the owner-idle edge)')
1411
+ return { answered: false, reason: rp.reason || 'unverified-agent', timedOut: false }
1412
+ }
1413
+ if (rp.reason !== 'verified') log.warn(`turn-end card: the captured caller was not usable (${rp.reason}); naming ${String(rp.agent.id).slice(0, 8)} instead`)
1414
+ // RAISE, THEN ASK — and do not await the raise: `raiseFor` only has to start the reveal, while
1415
+ // the card must appear whatever the window operation does. The OUTCOME is recorded, though: "the
1416
+ // human never saw the card" was the one fact nothing wrote down, and it took a human report
1417
+ // ("没有把DSH激活放到我面前") to find it (defect, 2026-09-16).
1418
+ Promise.resolve(raiseHuman?.(`turn-end:${Date.now()}`, `computer-use turn ended (${kind || 'completed'})`))
1419
+ .then((shown) => diagTurnEnd(`RAISE shown=${shown !== false} kind=${kind || 'completed'}`))
1420
+ .catch(() => {})
1421
+ // ---- WHO RECEIVES THE HUMAN'S ANSWER (review finding 5, 2026-09-15) ------------------------
1422
+ // The card is scoped to `rp.agent`, so the answer must go back to the SAME agent. Steering
1423
+ // `bus.agent` — a different object whenever the resolver substituted one — would deliver the
1424
+ // human's decision to a replaced or already-dead agent, inside a `try` that swallows the throw,
1425
+ // i.e. the answer would vanish with no trace. Resolved once, used by every branch below.
1426
+ const recipient = rp.agent || bus.agent
1427
+ const ANSWER_TIMEOUT_MS = 60_000
1428
+ let timer = null
1429
+ const timedOut = new Promise((resolve) => {
1430
+ timer = setTimeout(() => resolve({ answered: false, timedOut: true, reason: 'timeout' }), ANSWER_TIMEOUT_MS)
1431
+ })
1432
+ const asked = asker.ask({
1433
+ // THE AGENT IS NOT OPTIONAL. See `rootAgentFor` for the measured NO_PROVIDER this fixes.
1434
+ agent: rp.agent,
1435
+ questions: [{
1436
+ id: 'computer_use_turn_end',
1437
+ header: 'computer-use 回合结束',
1438
+ // THE CARD SAYS IT STAYS. The human's rule: "只要我没有任何反应,卡片就应该一直挂着,除非有
1439
+ // 这个60秒超时之后你取消它,你决定之后再说" — and they must be TOLD they can still change
1440
+ // their mind later, or the late-correction path is invisible to the only person who can use it.
1441
+ // THE TURN'S OUTCOME IS ON THE CARD (review finding E3, 2026-09-15). An errored turn raises the
1442
+ // same card as a completed one, which is right — the human must still decide what happens to the
1443
+ // machine — but at 23:22 the crash produced "本轮运行失败" immediately followed by "这一回合结束了",
1444
+ // with nothing saying the two were the same event. `kind` is already known here; saying it costs
1445
+ // one line and removes the confusion at its source.
1446
+ // ---- THE CARD SAYS WHAT THE TIMER WILL DO, AND THE TWO CANNOT DISAGREE (2026-09-15) --------
1447
+ // The human's rule, in their words: "这个问题超时的情况下,如果你有推荐选项就走推荐,如果没有
1448
+ // 推荐选项就停。而不是所有情况都一定有推荐选项,因为有些问题你确实是没有办法替我执行选择."
1449
+ // So there are two honest texts and NO third one. The previous text claimed the timer always
1450
+ // "按停处理", which was both wrong about the timer (it made no decision) and absent from the
1451
+ // design; and it promised "不会自己开新回合" even in the mode where it will.
1452
+ question: (kind === 'error'
1453
+ ? '**这一回合是以错误结束的**(不是正常做完),要我怎么走?\n\n'
1454
+ : '这一回合结束了。要我怎么走?\n\n') +
1455
+ '· **卡片会一直挂着**,你多久后回来点都算数,而且**以你为准**(我会比对偏差并改成你的选择)。\n' +
1456
+ (recommend
1457
+ ? `· **60 秒内你不回复**,我走标着「推荐」的那一项:**${recommend}**。`
1458
+ : '· **60 秒内你不回复**,我**不做决定、也不动机器**——这一题我没有替你选的立场,等你给策略。'),
1459
+ options: [
1460
+ { label: '继续(按你自己的理解接着干)', description: '等同于你按 Ctrl+Alt+R:我会开一个新回合继续。' },
1461
+ { label: '停(等我给策略)', description: '我不动,周期留着,等你在聊天里写下下一步。' },
1462
+ { label: '关闭周期(这一轮到此为止)', description: '收掉周期和指示器;下次要用 computer-use 需要新回合重新开。' },
1463
+ ].map((o) => (o.label === recommend ? { ...o, description: `【推荐】${o.description}` } : o)),
1464
+ }],
1465
+ }).then((reply) => {
1466
+ const a = reply && Array.isArray(reply.answers) ? reply.answers[0] : null
1467
+ const selected = a && Array.isArray(a.selected) ? a.selected : []
1468
+ const custom = a && typeof a.custom === 'string' && a.custom.trim() !== '' ? a.custom.trim() : undefined
1469
+ // ---- WHAT THIS LINE DOES AND DOES NOT PROVE (corrected by review finding 11, 2026-09-15) ---
1470
+ // It proves the ANSWERER ACCEPTED AND SETTLED the request — i.e. a browser answerer took it and
1471
+ // the round trip completed — which is exactly what a NO_PROVIDER never reaches, so its
1472
+ // presence/absence still separates "reached a card" from "reached nobody".
1473
+ // It does NOT prove a card was RENDERED BEFORE the human touched anything: the client resolves
1474
+ // this waterfall only on the answer (`dsh-client-ui-user-questions/lib/client.js:113-119`,
1475
+ // `await pending.result`). An earlier version of this comment claimed otherwise; the claim was
1476
+ // wrong, and a wrong witness is worse than none.
1477
+ log.info(`turn-end decision card answered: selected=${JSON.stringify(selected)}` +
1478
+ `${custom ? ` custom=${JSON.stringify(custom.slice(0, 40))}` : ''}`)
1479
+ return { answered: true, selected, custom, timedOut: false }
1480
+ }).catch((e) => ({ answered: false, reason: (e && e.code) || 'error', timedOut: false }))
1481
+ const out = await Promise.race([asked, timedOut])
1482
+ if (timer) clearTimeout(timer)
1483
+ // ---- THE LATE HANDLER MUST BE ABOUT A LATE ANSWER (measured defect, 2026-09-15 23:40) ---------
1484
+ // `asked.then(...)` used to be attached INSIDE this branch but unconditionally, so `asked` settling
1485
+ // by the ordinary path ALSO ran it. Host log, the human's own click:
1486
+ // 23:40:12.584 answered: selected=["继续…"] ← the real answer, on time
1487
+ // 23:40:12.585 turn-end card answered LATE … ← same millisecond, the duplicate
1488
+ // 23:40:14.846 the human SELECTED 继续 … ← and the ordinary branch, 2 s later
1489
+ // One click was handled TWICE. With the old bare-object steer both copies crashed; with the fixed
1490
+ // message shape the duplicate would silently steer the human's text a second time. The flag makes
1491
+ // the handler mean what its name and its log line say.
1492
+ const settledLate = !!out.timedOut
1493
+ if (out.timedOut) {
1494
+ // ---- THE TIMER BRANCHES (2026-09-15, restored from the human's own design words) -------------
1495
+ // "如果60s没回复,超时之后自动进行分支判断,是否必须终结还是按照你自己的理解继续", refined by them
1496
+ // later the same night: "如果你有推荐选项就走推荐,如果没有推荐选项就停。而不是所有情况都一定有推荐
1497
+ // 选项,因为有些问题你确实是没有办法替我执行选择."
1498
+ // So: a declared recommendation is EXECUTED; without one the timer makes NO decision and touches
1499
+ // nothing. Only the agent's next turn is ever at stake — never the cycle, never the brake.
1500
+ if (recommend) {
1501
+ // ---- THE GATE IS RE-CHECKED AT FIRE TIME, NOT ONLY WHEN THE CARD WAS BUILT (review A(ii)) --
1502
+ // `recommendContinueNow` ran 60 s ago. In between, a human ESC / presence panic sets `cu.stopped`
1503
+ // AND the STOP latch, and Ctrl+Alt+Q writes the EXITED record. Without this re-check the timer
1504
+ // would still steer 继续 and wake a turn whose every `computer_*` call the plugin's own gates
1505
+ // refuse — a billed turn that can only produce refusals, while the card had promised "我走标着
1506
+ // 「推荐」的那一项". The machine was never at risk (the timer touches no cycle and no brake), but
1507
+ // the promise would be false, and this file's rule is that a human act outranks a timer.
1508
+ if (cu.stopped || brakeLatch()) {
1509
+ log.warn('turn-end decision card timed out after 60 s, but the human braked during the wait — ' +
1510
+ 'the recommendation is WITHDRAWN (a timer never outranks a brake); nothing steered')
1511
+ } else if (stalledAtDecision) {
1512
+ log.warn('turn-end decision card timed out after 60 s — the situation has not changed since the last ' +
1513
+ 'automatic continuation (the same sequence of tools), so continuing is not progress; the timer ' +
1514
+ 'makes no decision and the human decides')
1515
+ } else {
1516
+ // THE SIGNATURE IS RECORDED WHEN THE CONTINUATION ACTUALLY HAPPENS, not when the card is built:
1517
+ // a card that is never answered from must not spend the stall check on a turn that never ran.
1518
+ noteDrive()
1519
+ log.info(`turn-end decision card timed out after 60 s — executing the RECOMMENDED option ` +
1520
+ `(${JSON.stringify(recommend)}); recommended because ${recommendReason}`)
1521
+ if (recipient) {
1522
+ void buildUserMessage(RECOMMEND_CONTINUE, 'turn-end-timeout', 'the 60 s timer executed the card\'s recommended option')
1523
+ .then((m) => recipient.steer(m))
1524
+ .catch((e) => log.warn(`the recommended option could not be delivered: ${(e && e.message) || 'unknown'}`))
1525
+ }
1526
+ }
1527
+ } else {
1528
+ log.info(`turn-end decision card timed out after 60 s — NO recommendation ${recommendReason}, ` +
1529
+ 'so the timer makes no decision and touches nothing: the machine stays with the human')
1530
+ }
1531
+ // ---- THE HUMAN MAY STILL ANSWER, AND THEIR ANSWER OUTRANKS THE TIMER (2026-09-15) ---------
1532
+ // Their standing rule: "卡片挂着也行,我要是后面回来有点击或回复,你直接立即以我的输入为最高
1533
+ // 优先,比对我不在期间的选择和最新输入是否有偏差". So the settled-by-timeout branch is NOT
1534
+ // final: the SAME promise is kept and awaited a second time, out of band. When it resolves we
1535
+ // compare what the timer chose against what they actually said, and report the deviation.
1536
+ //
1537
+ // WHY THIS CANNOT BE DONE AT THE CARD: the harness offers no way to withdraw a pending card,
1538
+ // so "the timer decided" and "the card is still answerable" are both true at once. Rather than
1539
+ // fight that, the late answer is treated as a CORRECTION — the truthful model of what it is.
1540
+ void asked.then((late) => {
1541
+ // Belt and braces: only a card that was still pending when the timer fired is "late".
1542
+ if (!settledLate) return
1543
+ if (!late || !late.answered) return
1544
+ const lateText = late.custom || (Array.isArray(late.selected) && late.selected[0]) || ''
1545
+ if (!lateText) return
1546
+ // THE TIMER'S OUTCOME IS "NO DECISION", NOT "CHOSE 停". Saying the timer "chose 停" would be
1547
+ // the same lie this branch was written to stop making: the timer decides only that the agent
1548
+ // will NOT continue unattended. The human's late word therefore never CONTRADICTS the timer —
1549
+ // it is the first decision anyone made, and it wins.
1550
+ const deviation = true
1551
+ log.warn(`turn-end card answered LATE (after the 60 s timer). The timer made NO decision ` +
1552
+ `(the agent simply did not continue unattended); the human now says: ${JSON.stringify(lateText)}. ` +
1553
+ (deviation
1554
+ ? 'ACTING ON THE HUMAN\'S INPUT — it takes precedence, and it is the only decision on record.'
1555
+ : 'No deviation.'))
1556
+ if (deviation && recipient) {
1557
+ // A REAL MESSAGE, NEVER A BARE OBJECT — see `buildUserMessage`. A raw `{text}` here crashes
1558
+ // the harness's own inbox listener (`message.source.kind`) and the human sees the round fail.
1559
+ void buildUserMessage(lateText, 'turn-end-late', 'the human answered the turn-end card late')
1560
+ .then((m) => recipient.steer(m))
1561
+ .catch((e) => log.warn(`late answer could not be delivered: ${(e && e.message) || 'unknown'}`))
1562
+ }
1563
+ }).catch(() => {})
1564
+ return out
1565
+ }
1566
+ // A STRATEGY IN THE HUMAN'S OWN WORDS OUTRANKS THE MENU, exactly as the design intends ("其中一个
1567
+ // 给出一个我的自由输入指令"): if they typed anything, that text is the instruction.
1568
+ //
1569
+ // DO NOT LOG A CHOICE NOBODY MADE (2026-09-15, caught by the human: "我啥都没按 笑死了 没有任何人工
1570
+ // 输入"). This branch used to fall through to `the human chose 停` for EVERY input-less ending —
1571
+ // a dismissed card, an aborted ask, an empty reply — so the log asserted a human decision that had
1572
+ // never happened, and the human read it back as evidence. That is the SAME defect this session
1573
+ // spent hours removing from the brake monitors ("a signal that cannot separate those two cases
1574
+ // must not drive a brake"), committed here in prose form. Each ending now names itself, and only
1575
+ // a REAL selection may be reported as a choice.
1576
+ if (!out.answered) {
1577
+ log.info(`turn-end card: NO ANSWER (${out.reason || 'unknown'}) — nothing steered, no choice was made`)
1578
+ return out
1579
+ }
1580
+ // A REAL ANSWER CLEARS THE STALL HISTORY: the similarity rule exists to stop the TIMER talking to
1581
+ // itself, never to overrule a human who is present and choosing.
1582
+ humanAnsweredSince = true
1583
+ const text = out.custom || (Array.isArray(out.selected) && out.selected[0]) || ''
1584
+ if (!text) {
1585
+ log.info('turn-end card: answered with an EMPTY selection (skipped or dismissed) — nothing steered, this is NOT a choice of 停')
1586
+ } else if (out.custom) {
1587
+ if (recipient) {
1588
+ void buildUserMessage(out.custom, 'turn-end-strategy', 'the human typed a strategy at the turn-end card')
1589
+ .then((m) => recipient.steer(m))
1590
+ .catch((e) => log.warn(`the human's strategy could not be delivered: ${(e && e.message) || 'unknown'}`))
1591
+ }
1592
+ log.info(`turn-end card: the human TYPED a strategy (${JSON.stringify(out.custom.slice(0, 60))}) — steered into a new turn`)
1593
+ } else if (/关闭周期/.test(text)) {
1594
+ // THE THIRD BRANCH the human asked for ("关闭整个周期,还是继续"). This is the ending that used
1595
+ // to happen automatically on every completed turn; now it is an explicit choice.
1596
+ if (gate.mark('ended', { why: 'the human chose to close the cycle at the turn-end card' })) {
1597
+ cu.calm().catch(() => {})
1598
+ log.info(`turn-end card: the human SELECTED 关闭周期 (${JSON.stringify(text.slice(0, 40))}) — cycle closed, indicator cut`)
1599
+ } else {
1600
+ log.info('turn-end card: 关闭周期 was selected but the cycle was already ended — nothing to do')
1601
+ }
1602
+ } else if (/继续/.test(text)) {
1603
+ // A REAL MESSAGE, NEVER A BARE OBJECT — this is the exact call the human's 继续 crashed on
1604
+ // (measured 23:22:23, `Cannot read properties of undefined (reading 'kind')`).
1605
+ if (recipient) {
1606
+ void buildUserMessage('继续', 'turn-end-card', 'the human chose 继续 at the turn-end card')
1607
+ .then((m) => recipient.steer(m))
1608
+ .catch((e) => log.warn(`继续 could not be delivered: ${(e && e.message) || 'unknown'}`))
1609
+ }
1610
+ log.info(`turn-end card: the human SELECTED 继续 (${JSON.stringify(text.slice(0, 40))}) — steered, a new turn will start`)
1611
+ } else if (/停/.test(text)) {
1612
+ log.info(`turn-end card: the human SELECTED 停 (${JSON.stringify(text.slice(0, 40))}) — nothing steered, the cycle is left open for you`)
1613
+ } else {
1614
+ log.info(`turn-end card: an unrecognised selection ${JSON.stringify(text.slice(0, 40))} — nothing steered`)
1615
+ }
1616
+ return out
1617
+ } catch { return { answered: false, reason: 'error' } }
1618
+ }
1619
+
1620
+ // approval gate — every computer_* tool call passes through the policy
1621
+ // pure function; its {kind: allow|deny|ask} rides the tools/pre-execute
1622
+ // waterfall into the harness approval UI (fail-closed, one-shot grants).
1623
+ ctx.on('tools/pre-execute', (exec, next) => {
1624
+ try {
1625
+ if (!exec || typeof exec.name !== 'string' || !exec.name.startsWith('computer_')) return next()
1626
+ if (exec.name === ACTIVATION_TOOL) return next() // capability discovery does not open a control cycle
1627
+ // NOT `bus.agent = exec.agent` (integration bug, 2026-09-13): this hook runs for EVERY
1628
+ // computer_* call, including one the cycle gate will refuse as foreign, so rebinding the
1629
+ // machine's notice/cancel recipient here handed a foreign caller the exit notice and the
1630
+ // Ctrl+Alt+Q cancel. The owner's mailbox is bound in ensureCycle, at the moment ownership is
1631
+ // taken. Before any cycle exists, a call still needs somewhere for machine events to go, so
1632
+ // this binds only when the machine is unowned.
1633
+ if (exec.agent && !gate.isLive()) bus.agent = exec.agent
1634
+ // ---- READ THE PROCEDURE FIRST -----------------------------------------------------------
1635
+ // The human's question, 2026-09-13: "为什么不先读 computer use skill, 直接上来就用". No tool
1636
+ // layer can force a model to read a document — but the plugin CAN say it at the one moment
1637
+ // that still changes the outcome: the first computer_* call of a turn. Once per turn (reset on
1638
+ // turn/start), steered into the live turn, and it names the three traps that actually cost
1639
+ // real time here instead of just pointing at a file.
1640
+ if (!nudgedTurn) {
1641
+ nudgedTurn = true
1642
+ notice(
1643
+ 'READ THE PROCEDURE BEFORE DRIVING — the `computer-use` skill (skills/computer-use/SKILL.md) ' +
1644
+ 'is the manual for these tools, and it is written against exactly the mistakes that cost real ' +
1645
+ 'time here: read `computer_state {windows:true}` BEFORE concluding a window is "blocked" ' +
1646
+ '(a MINIMIZED window needs `restore`, not `activate`); measure coordinates with ' +
1647
+ '`computer_uia {role, name}` instead of estimating them from a scaled screenshot; and never ' +
1648
+ 'tell the human a blocker you have not measured. Its opening moves are mandatory and come ' +
1649
+ 'before any pixel.',
1650
+ 'computer-use: read the skill first', { agent: exec.agent }).catch(() => {})
1651
+ }
1652
+ // ... and WHICH session is driving, because `session/event` is host-wide: without this the
1653
+ // "an interrupted turn is a brake" rule fired for ANY session's aborted turn. It did exactly
1654
+ // that — stopping a background SUBAGENT braked the computer-use session (STOP file 21:56:44.660).
1655
+ //
1656
+ // ONLY THE OWNER (or an unowned machine) may be recorded here (integration bug, 2026-09-13): a
1657
+ // call the gate is about to refuse as foreign must not become the session the host-wide turn
1658
+ // events are judged against, or a foreign caller would silence the owner's own brake handling.
1659
+ const drv = exec.session || exec.sessionId ||
1660
+ (exec.agent && (exec.agent.session || exec.agent.sessionId)) || null
1661
+ const ownerSk = gate.ownerSessionKey()
1662
+ const mayBind = !ownerSk || !drv || sameSession(drv, ownerSk)
1663
+ if (mayBind && drv && !sameSession(drv, bus.driverSession)) {
1664
+ bus.driverSession = drv
1665
+ log.info(`computer-use driver session: ${describeSession(drv)}`)
1666
+ }
1667
+ // ---- THE READ-THE-PROCEDURE GATE --------------------------------------------------------
1668
+ // Human's rule, 2026-09-13: "未先读 skill 时拒绝驱动类 op". A DRIVING call is refused until
1669
+ // THIS session has produced the receipt phrase that exists only inside the computer-use skill
1670
+ // (skill-gate.js explains why a phrase is the honest maximum). Two things are deliberately
1671
+ // NEVER gated, because a gate that can block them is worse than no gate at all: the safety
1672
+ // direction (computer_ask, computer_ctrl stop/exit), and OBSERVATION — an agent that cannot
1673
+ // look cannot even report why it is blocked.
1674
+ const gArgs = exec.arguments || {}
1675
+ const gAct = exec.name === 'computer_ctrl' ? String(gArgs.action || 'selftest') : ''
1676
+ if (exec.name === 'computer_ctrl' && gAct === 'acknowledge') {
1677
+ if (!acknowledge(gArgs.text, sessionKey(drv))) {
1678
+ return { kind: 'deny', reason: `acknowledge failed — pass the EXACT phrase, quoted from the skill. ${ACK_HINT}` }
1679
+ }
1680
+ log.info('computer-use: procedure acknowledged — driving open for this session')
1681
+ notice('✅ 已确认读过 computer-use skill 规程:本 session 的驱动类调用已解锁。', 'computer-use: acknowledged', { agent: exec.agent }).catch(() => {})
1682
+ return { kind: 'allow' }
1683
+ }
1684
+ if (!isAcked(sessionKey(drv)) && isDrivingCall(exec.name, gArgs)) {
1685
+ return { kind: 'deny', reason: `READ THE PROCEDURE FIRST. ${ACK_HINT}` }
1686
+ }
1687
+ const decision = policyDecision(exec.name, exec.arguments || {}, config.automationMode)
1688
+ if (decision.kind !== 'allow' && decision.reason) {
1689
+ log.info(`policy ${decision.kind}: ${exec.name} — ${decision.reason}`)
1690
+ }
1691
+ // RETURN the decision — do not hand it to next(). On this DSH build `next` is
1692
+ // `() => Promise<PreToolDecision>`: it takes NO arguments, so `next(decision)` silently
1693
+ // discarded the decision and the waterfall fell back to its {kind:'allow'} default. Every
1694
+ // policy decision — including the fail-closed deny for an unknown tool — was inert on the real
1695
+ // machine, while the hook itself ran (its side effects were visible, its verdict was not).
1696
+ // Found 2026-09-13 by A/B: a call that policy.js denies reached its HANDLER instead.
1697
+ return decision
1698
+ } catch (e) {
1699
+ // fail closed
1700
+ return { kind: 'deny', reason: `computer-use policy error: ${e.message}` }
1701
+ }
1702
+ })
1703
+
1704
+ ctx.effect(() => {
1705
+ const disposer = () => { cu.kill().catch(() => {}) }
1706
+ return disposer
1707
+ })
1708
+
1709
+ const definitions = []
1710
+ registerTools({ ctx, cu, config, dataDir, log, snapshotPath, defineTool, hooks, ensureCycle, register: d => definitions.push(d) })
1711
+ installToolExposure({ ctx, definitions, defineTool, scopeOf, log, enabled: config.progressiveTools !== false,
1712
+ skillPath: fileURLToPath(new URL('SKILL.md', SKILL_DIR_URL)),
1713
+ readSkill: () => fs.readFileSync(fileURLToPath(new URL('SKILL.md', SKILL_DIR_URL)), 'utf8') })
1714
+ log.info(`ready (mode=${config.automationMode}${config.dryRun ? ' dry-run' : ''}; cycle idle timeout disabled)`)
1715
+ }