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.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.zh.md +132 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifacts.js +35 -0
- package/lib/attention-native.js +93 -0
- package/lib/attention-worker.cs +194 -0
- package/lib/attention.js +140 -0
- package/lib/audit.js +30 -0
- package/lib/batch.js +52 -0
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +7 -0
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +7 -0
- package/lib/core/compile-worker.ps1 +58 -0
- package/lib/core/index.js +844 -0
- package/lib/core/native-runtime.js +97 -0
- package/lib/core/worker.cs +6375 -0
- package/lib/cycle.js +314 -0
- package/lib/exposure.js +91 -0
- package/lib/feedback.js +35 -0
- package/lib/image-output.js +92 -0
- package/lib/index.js +1715 -0
- package/lib/observations.js +78 -0
- package/lib/policy.js +109 -0
- package/lib/recovery.js +8 -0
- package/lib/skill-gate.js +68 -0
- package/lib/tools.js +717 -0
- package/mcp/package.json +12 -0
- package/mcp/src/index.js +335 -0
- package/package.json +88 -0
- package/scripts/build-native.mjs +7 -0
- package/scripts/smoke-native.mjs +38 -0
- package/scripts/verify-package.mjs +28 -0
- package/skills/computer-use/SKILL.md +74 -0
- package/skills/computer-use/references/lifecycle.md +180 -0
- package/skills/computer-use/references/maintenance.md +56 -0
- package/skills/computer-use/references/precision.md +297 -0
- package/skills/computer-use/references/progressive-control.md +30 -0
- 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
|
+
}
|