dsh-codex-style-computer-use 1.2.1 → 1.3.0
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/README.md +16 -2
- package/README.zh.md +18 -2
- package/lib/approval-attribution.js +39 -0
- package/lib/batch.js +21 -5
- package/lib/continuation.js +14 -0
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
- package/lib/core/index.js +72 -17
- package/lib/core/worker.cs +495 -246
- package/lib/cycle-lifecycle.js +66 -0
- package/lib/cycle.js +99 -9
- package/lib/index.js +52 -617
- package/lib/policy.js +1 -0
- package/lib/skill-gate.js +1 -1
- package/lib/terminal-cleanup.js +57 -0
- package/lib/tools.js +18 -4
- package/mcp/src/execution.js +22 -0
- package/mcp/src/index.js +36 -13
- package/package.json +1 -1
- package/scripts/patch-approval-attribution.mjs +86 -0
- package/scripts/verify-package.mjs +1 -1
- package/skills/computer-use/SKILL.md +8 -3
- package/skills/computer-use/references/lifecycle.md +23 -12
package/lib/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installApprovalAttribution } from './approval-attribution.js'
|
|
1
2
|
import { installHumanAttention } from './attention.js'
|
|
2
3
|
// dsh-computer-use — DSH Cordis plugin
|
|
3
4
|
//
|
|
@@ -36,23 +37,10 @@ import { installToolExposure, ACTIVATION_TOOL } from './exposure.js'
|
|
|
36
37
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
|
37
38
|
import { createCycleGate, agentKeyOf } from './cycle.js'
|
|
38
39
|
import { recoveryProblem } from './recovery.js'
|
|
40
|
+
import { createCycleLifecycle } from './cycle-lifecycle.js'
|
|
41
|
+
import { createTerminalCleanup } from './terminal-cleanup.js'
|
|
39
42
|
import fs from 'node:fs'
|
|
40
43
|
|
|
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
44
|
/**
|
|
57
45
|
* Build a REAL user message for `agent.steer()` / `agent.inject()`.
|
|
58
46
|
*
|
|
@@ -114,10 +102,6 @@ import { fileURLToPath } from 'node:url'
|
|
|
114
102
|
// never capturing it early — so a `ctx.inject(['userQuestions'], …)` callback that lands after this
|
|
115
103
|
// module was applied is still picked up.
|
|
116
104
|
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
105
|
|
|
122
106
|
/**
|
|
123
107
|
* Put ONE question on the shared client-UI card and wait for the human, with no countdown.
|
|
@@ -412,23 +396,11 @@ export const Config = Schema.object({
|
|
|
412
396
|
annotateMarks: Schema.boolean().default(true).description('draw SoM boxes on screenshots'),
|
|
413
397
|
// Compatibility with saved settings only. Elapsed time never ends a computer-use cycle.
|
|
414
398
|
calmAfterMs: Schema.number().default(0).description('legacy setting; ignored — computer-use cycles have no idle timeout'),
|
|
415
|
-
//
|
|
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.
|
|
399
|
+
// Compatibility with saved settings; automatic completion has no question or timer.
|
|
428
400
|
turnEndTimeoutAction: Schema.union([
|
|
429
|
-
Schema.const('stop').description('
|
|
430
|
-
Schema.const('recommended').description('
|
|
431
|
-
]).default('stop').description('
|
|
401
|
+
Schema.const('stop').description('legacy value; ignored'),
|
|
402
|
+
Schema.const('recommended').description('legacy value; ignored'),
|
|
403
|
+
]).default('stop').description('legacy setting; ignored — ordinary completion releases computer use automatically'),
|
|
432
404
|
})
|
|
433
405
|
|
|
434
406
|
export function apply (ctx, config) {
|
|
@@ -438,41 +410,17 @@ export function apply (ctx, config) {
|
|
|
438
410
|
return
|
|
439
411
|
}
|
|
440
412
|
if (!config.enabled) return
|
|
413
|
+
installApprovalAttribution(ctx, log)
|
|
441
414
|
registerBundledSkill(ctx, log)
|
|
442
|
-
|
|
415
|
+
enableHumanAttention(ctx, log)
|
|
443
416
|
// THE QUESTION SERVICE, INJECTED LAZILY (merge-design.md §5.3 item 16 — defence in depth).
|
|
444
417
|
// `inject` above already guarantees the service before `apply` runs; going through `ctx.inject` as
|
|
445
418
|
// well means the tool does not depend on which of the two orderings the host happened to pick, and
|
|
446
419
|
// that a service arriving later still lands. `enableHumanAttention` uses the same pattern for
|
|
447
420
|
// `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
421
|
ctx.inject(['userQuestions'], (qctx) => {
|
|
459
422
|
asker = qctx.userQuestions
|
|
460
|
-
agents = qctx.get('agents')
|
|
461
423
|
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
424
|
})
|
|
477
425
|
|
|
478
426
|
// settings card (restart-applies) — same shape dsh-pocket/dsh-browser use
|
|
@@ -513,93 +461,6 @@ export function apply (ctx, config) {
|
|
|
513
461
|
if (k) return String(k)
|
|
514
462
|
return '<' + ((s.constructor && s.constructor.name) || typeof s) + '>'
|
|
515
463
|
}
|
|
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
464
|
let inFlight = 0
|
|
604
465
|
// Ctrl+Alt+Q cancels the turn ON PURPOSE. The abort that follows must never be mistaken for
|
|
605
466
|
// the human hitting the stop button — that rule below re-armed the brake and left the red
|
|
@@ -681,6 +542,7 @@ export function apply (ctx, config) {
|
|
|
681
542
|
return gate.claim({ session: s, agent: exec && exec.agent })
|
|
682
543
|
}
|
|
683
544
|
|
|
545
|
+
const callOwners = new WeakMap()
|
|
684
546
|
const ensureCycle = async (exec) => {
|
|
685
547
|
const cl = claimOf(exec)
|
|
686
548
|
// `gate.open` RESERVES ownership synchronously and THROWS for a foreign claim, so reaching the
|
|
@@ -707,6 +569,7 @@ export function apply (ctx, config) {
|
|
|
707
569
|
log.warn('a computer-use exit record is still present after resume — a newer exit stands; the call that follows will be refused')
|
|
708
570
|
return { opened: false, reason: 'a newer exit record stands' }
|
|
709
571
|
})
|
|
572
|
+
if (exec && typeof exec === 'object') callOwners.set(exec, { claim: cl, cycleId: gate.snapshot().id })
|
|
710
573
|
// THE MACHINE'S MAILBOX BELONGS TO THE OWNER (integration bug, 2026-09-13). Binding it in
|
|
711
574
|
// `tools/pre-execute` meant EVERY caller rebound it — including one the gate was about to refuse
|
|
712
575
|
// as foreign — so a Ctrl+Alt+Q could cancel the wrong session while the real owner kept working
|
|
@@ -719,125 +582,40 @@ export function apply (ctx, config) {
|
|
|
719
582
|
return pending
|
|
720
583
|
}
|
|
721
584
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
-
}
|
|
585
|
+
const lifecycle = createCycleLifecycle({
|
|
586
|
+
gate, cu, claimOf, owner: () => ({ session: bus.driverSession, agent: bus.agent }),
|
|
587
|
+
paused: pausedOrAsking, inFlight: () => inFlight,
|
|
588
|
+
ownershipOf: exec => exec && typeof exec === 'object' ? callOwners.get(exec) : null,
|
|
589
|
+
})
|
|
590
|
+
cu.on('handoff', () => {
|
|
591
|
+
lifecycle.settle()?.catch(error => log.warn(`computer-use handback completion failed: ${error.message}`))
|
|
592
|
+
})
|
|
808
593
|
|
|
594
|
+
const terminalCleanup = createTerminalCleanup({
|
|
595
|
+
gate, cu, claimOf, owner: () => ({ session: bus.driverSession, agent: bus.agent }),
|
|
596
|
+
paused: pausedOrAsking, report: error => log.warn(`computer-use terminal cleanup failed: ${error.message}`),
|
|
597
|
+
})
|
|
809
598
|
const hooks = {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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 */ }
|
|
599
|
+
assertOwnership: (exec) => {
|
|
600
|
+
const owner = exec && typeof exec === 'object' ? callOwners.get(exec) : null
|
|
601
|
+
if (!owner) {
|
|
602
|
+
const error = new Error('COMPUTER_OWNER_REQUIRED: this call has no computer-use ownership claim; no input was dispatched.')
|
|
603
|
+
error.code = 'COMPUTER_OWNER_REQUIRED'; error.outcome = 'not-dispatched'; error.dshCycleRefusal = true
|
|
604
|
+
throw error
|
|
605
|
+
}
|
|
606
|
+
gate.assertOwnership(owner.claim, owner.cycleId)
|
|
607
|
+
},
|
|
608
|
+
releaseCycle: (exec, why) => lifecycle.release(exec, why),
|
|
609
|
+
onStart: (exec) => {
|
|
610
|
+
inFlight++
|
|
611
|
+
if (exec?.name === 'computer_ctrl' && exec.arguments?.action === 'release') {
|
|
612
|
+
callOwners.set(exec, { claim: claimOf(exec), cycleId: gate.snapshot().id })
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
onEnd: () => {
|
|
616
|
+
inFlight = Math.max(0, inFlight - 1)
|
|
617
|
+
lifecycle.settle()?.catch(error => log.warn(`computer-use deferred completion failed: ${error.message}`))
|
|
838
618
|
},
|
|
839
|
-
onStart: () => { inFlight++ },
|
|
840
|
-
onEnd: () => { inFlight = Math.max(0, inFlight - 1) },
|
|
841
619
|
// ---- THE WRAP-UP DIRECTIVE (2026-09-15) --------------------------------------------------------
|
|
842
620
|
// WHAT WENT WRONG. A human brake (ESC, a mouse shake, the wheel, typing outside the host) engaged
|
|
843
621
|
// the brake and steered a "WRAP UP NOW" notice into the turn — but `agent.steer` only delivers at a
|
|
@@ -1071,6 +849,7 @@ export function apply (ctx, config) {
|
|
|
1071
849
|
if (r && r.exited) { gate.mark('ended', { why: 'the ask window ended in an exit' }); return }
|
|
1072
850
|
if (!r || r.stopped) { gate.mark('paused', { why: 'the ask window ended with a stop standing' }); return }
|
|
1073
851
|
gate.mark('open', { why: 'the ask window closed with no brake (no human input)' })
|
|
852
|
+
terminalCleanup.resumePending()
|
|
1074
853
|
} catch { /* never break the host */ }
|
|
1075
854
|
})
|
|
1076
855
|
|
|
@@ -1089,6 +868,7 @@ export function apply (ctx, config) {
|
|
|
1089
868
|
return
|
|
1090
869
|
}
|
|
1091
870
|
brakeWhy = '' // the brake is off, so there is no reason left to hand to a refused call
|
|
871
|
+
if (terminalCleanup.resumePending()) return
|
|
1092
872
|
const version = gate.exitVersion()
|
|
1093
873
|
notice(
|
|
1094
874
|
'继续 — the human re-armed computer use with Ctrl+Alt+R: the brake is cleared and the red border ' +
|
|
@@ -1109,7 +889,7 @@ export function apply (ctx, config) {
|
|
|
1109
889
|
// late callback, not a queued call in this same turn — may move this id back to a live state; a
|
|
1110
890
|
// new cycle needs a new turn's first real call and gets a new id (lib/cycle.js).
|
|
1111
891
|
gate.mark('ended', { why: (e && e.why) || 'exit event' })
|
|
1112
|
-
|
|
892
|
+
terminalCleanup.forget() // native exit already performed cleanup; never send a late calm
|
|
1113
893
|
selfAbortUntil = Date.now() + 8000
|
|
1114
894
|
// CAPTURE THE RECIPIENT BEFORE THE FIRST `await` (integration bug, 2026-09-13). The notice below
|
|
1115
895
|
// awaits message construction and delivery, and anything that rebinds `bus.agent` during that
|
|
@@ -1188,8 +968,7 @@ export function apply (ctx, config) {
|
|
|
1188
968
|
log.info(`agent/status idle while ${gate.state()} — the cycle stays alive (Ctrl+Alt+R resumes it)`)
|
|
1189
969
|
return
|
|
1190
970
|
}
|
|
1191
|
-
|
|
1192
|
-
cu.calm().catch(() => {})
|
|
971
|
+
lifecycle.idle(payload.agent)?.catch(error => log.warn(`computer-use idle release failed: ${error.message}`))
|
|
1193
972
|
} catch { /* the indicator must never break the host */ }
|
|
1194
973
|
})
|
|
1195
974
|
|
|
@@ -1201,7 +980,7 @@ export function apply (ctx, config) {
|
|
|
1201
980
|
const whoKey = agentKeyOf(payload && payload.agent)
|
|
1202
981
|
if (whoKey && gate.ownsAgent(whoKey)) {
|
|
1203
982
|
log.info(`owner agent disposed (${whoKey}) — releasing the machine for the next real call`)
|
|
1204
|
-
|
|
983
|
+
terminalCleanup.request('owner agent disposed')
|
|
1205
984
|
}
|
|
1206
985
|
} catch { /* never break the host */ }
|
|
1207
986
|
})
|
|
@@ -1230,17 +1009,6 @@ export function apply (ctx, config) {
|
|
|
1230
1009
|
// session's generation is all a turn boundary is allowed to do — and it is what gives a
|
|
1231
1010
|
// brand-new session its qualification, since it never has to match a remembered driver.
|
|
1232
1011
|
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
1012
|
const owner = gate.ownerSessionKey()
|
|
1245
1013
|
if (!owner || !session || sameSession(session, owner)) nudgedTurn = false
|
|
1246
1014
|
// NOTHING IS OPENED HERE (A/B). The opener is the first real call of the turn — the only
|
|
@@ -1266,357 +1034,24 @@ export function apply (ctx, config) {
|
|
|
1266
1034
|
if (selfAbortUntil && Date.now() < selfAbortUntil) {
|
|
1267
1035
|
selfAbortUntil = 0
|
|
1268
1036
|
log.warn(`turn ended ${kind} — caused by our own Ctrl+Alt+Q exit; NOT re-engaging the brake`)
|
|
1269
|
-
cu.calm().catch(() => {})
|
|
1270
1037
|
return
|
|
1271
1038
|
}
|
|
1272
1039
|
// Host cancellation is per request/turn; only the physical Ctrl+Esc path
|
|
1273
1040
|
// raises an input-triggered persistent brake.
|
|
1274
1041
|
log.warn(`turn ended ${kind} in a computer-use session — cancelling only its input`)
|
|
1275
1042
|
recoveryEpoch++; temporaryPause = null
|
|
1276
|
-
cu.cancelInputs?.()
|
|
1277
1043
|
// An explicit Ctrl+Esc pause remains latched. Ordinary Host cancellation ends
|
|
1278
1044
|
// this turn and its queued input without inventing a persistent human brake.
|
|
1279
|
-
|
|
1280
|
-
gate.mark('ended', { why: `owner turn ${kind}` })
|
|
1281
|
-
cu.calm().catch(() => {})
|
|
1282
|
-
}
|
|
1045
|
+
terminalCleanup.request(`owner turn ${kind}`)
|
|
1283
1046
|
return
|
|
1284
1047
|
}
|
|
1285
|
-
//
|
|
1286
|
-
//
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
}
|
|
1048
|
+
// Ordinary completion releases computer use immediately. No question card,
|
|
1049
|
+
// no recommendation timer, and no new conversation turn is created.
|
|
1050
|
+
const completion = lifecycle.completeTurn(session, kind || 'completed')
|
|
1051
|
+
completion?.catch(error => log.warn(`computer-use completion failed: ${error.message}`))
|
|
1354
1052
|
} catch { /* never break the host */ }
|
|
1355
1053
|
})
|
|
1356
1054
|
|
|
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
1055
|
// approval gate — every computer_* tool call passes through the policy
|
|
1621
1056
|
// pure function; its {kind: allow|deny|ask} rides the tools/pre-execute
|
|
1622
1057
|
// waterfall into the harness approval UI (fail-closed, one-shot grants).
|