dsh-codex-style-computer-use 1.2.3 → 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/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { installHumanAttention } from './attention.js'
2
1
  import { installApprovalAttribution } from './approval-attribution.js'
2
+ import { installHumanAttention } from './attention.js'
3
3
  // dsh-computer-use — DSH Cordis plugin
4
4
  //
5
5
  // Native DSH integration for computer use. Mirrors the @anweat/dsh-browser
@@ -37,23 +37,10 @@ import { installToolExposure, ACTIVATION_TOOL } from './exposure.js'
37
37
  import { scopeOf } from '@deepseek-ai/dsh-scope'
38
38
  import { createCycleGate, agentKeyOf } from './cycle.js'
39
39
  import { recoveryProblem } from './recovery.js'
40
+ import { createCycleLifecycle } from './cycle-lifecycle.js'
41
+ import { createTerminalCleanup } from './terminal-cleanup.js'
40
42
  import fs from 'node:fs'
41
43
 
42
- /**
43
- * Record the turn-end decision path where it can be INSPECTED AFTER THE FACT.
44
- *
45
- * The human asked twice "我还是没亲眼看到触发", and there is no way to read the host's own log from
46
- * here — so a feature whose whole job is to make itself visible had no way to be debugged. Both
47
- * outcomes are recorded, because "it did not fire" is the answer that actually needs a reason.
48
- * A diagnostic that can throw would be worse than none: it must never break a turn.
49
- */
50
- function diagTurnEnd (what) {
51
- try {
52
- const p = path.join(os.homedir(), '.dsh', 'data', 'computer-use', 'turn-end-diag.log')
53
- fs.appendFileSync(p, `${new Date().toISOString()} ${what}\n`)
54
- } catch { /* never break the turn for a log line */ }
55
- }
56
-
57
44
  /**
58
45
  * Build a REAL user message for `agent.steer()` / `agent.inject()`.
59
46
  *
@@ -115,10 +102,6 @@ import { fileURLToPath } from 'node:url'
115
102
  // never capturing it early — so a `ctx.inject(['userQuestions'], …)` callback that lands after this
116
103
  // module was applied is still picked up.
117
104
  let asker = null
118
- let agents = null
119
- // The verified Desktop-raiser, handed out by `enableHumanAttention` (see the return there). Used by
120
- // the turn-end decision card: a card the human never sees is the same as no card.
121
- let raiseHuman = null
122
105
 
123
106
  /**
124
107
  * Put ONE question on the shared client-UI card and wait for the human, with no countdown.
@@ -413,23 +396,11 @@ export const Config = Schema.object({
413
396
  annotateMarks: Schema.boolean().default(true).description('draw SoM boxes on screenshots'),
414
397
  // Compatibility with saved settings only. Elapsed time never ends a computer-use cycle.
415
398
  calmAfterMs: Schema.number().default(0).description('legacy setting; ignored — computer-use cycles have no idle timeout'),
416
- // ---- WHAT THE 60 s TIMER DOES WHEN NOBODY ANSWERS (2026-09-15, the human's own design words) -------
417
- // "如果60s没回复,超时之后自动进行分支判断,是否必须终结还是按照你自己的理解继续" — the timer is meant
418
- // to BRANCH, not just stop. The shipped code implemented only the stop half and said so in a comment;
419
- // the human asked why. It is a setting because the judgement is theirs to set up ONCE, rather than the
420
- // agent improvising it at every timeout.
421
- // 'stop' — the timer makes NO decision: the machine stays with the human, the cycle stays open.
422
- // 'recommended' — the timer executes the choice the CARD DECLARED as its recommendation. Note this is
423
- // a judgement about the AGENT's next turn, never about the machine: it does not close
424
- // the cycle and it does not release a human's brake.
425
- // WHETHER THERE IS A RECOMMENDATION IS NOT CONFIGURED — IT IS READ FROM THE TASK (2026-09-15).
426
- // A `turnEndAutoContinueMax` counter existed here for one revision; the human rejected it ("由且仅由问题
427
- // 任务的情形决定,而不是几次 … 不应该这样硬编码设计"). The branch is now decided per turn by whether a real
428
- // 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.
429
400
  turnEndTimeoutAction: Schema.union([
430
- Schema.const('stop').description('no decision — wait for the human (default)'),
431
- Schema.const('recommended').description('execute the recommendation the card declared'),
432
- ]).default('stop').description('what the 60 s turn-end timer does when nobody answers'),
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'),
433
404
  })
434
405
 
435
406
  export function apply (ctx, config) {
@@ -441,40 +412,15 @@ export function apply (ctx, config) {
441
412
  if (!config.enabled) return
442
413
  installApprovalAttribution(ctx, log)
443
414
  registerBundledSkill(ctx, log)
444
- raiseHuman = enableHumanAttention(ctx, log)
415
+ enableHumanAttention(ctx, log)
445
416
  // THE QUESTION SERVICE, INJECTED LAZILY (merge-design.md §5.3 item 16 — defence in depth).
446
417
  // `inject` above already guarantees the service before `apply` runs; going through `ctx.inject` as
447
418
  // well means the tool does not depend on which of the two orderings the host happened to pick, and
448
419
  // that a service arriving later still lands. `enableHumanAttention` uses the same pattern for
449
420
  // `desktopRuntime` just above.
450
- // `agents` IS READ NON-FATALLY, AND DELIBERATELY NOT IN THE INJECT LIST (review finding 4, 2026-09-15).
451
- // Cordis semantics, verified in the library (`cordis/lib/index.js:1316-1328` `_refresh` → `_setEpoch`)
452
- // and `Inject.resolve` (:1490-1498): a service listed in `inject` that is NOT available leaves the
453
- // fiber's epoch INACTIVE and the callback NEVER RUNS — no call, no `undefined`, no error. Listing
454
- // `agents` there would therefore couple the PRE-EXISTING `computer_ask` (which needs only
455
- // `userQuestions`) to an unrelated service: one row-level override that disables the agents
456
- // registry would silently leave `asker` null and make a working tool throw. Both services ship in
457
- // the same base bundle today, so that is a hazard rather than a live break — and it is free to
458
- // remove. `ctx.get` reads a service without declaring it (`cordis/lib/index.js:754-764`), which is
459
- // exactly the "use it if it is there" this needs; the resolver degrades honestly without it.
460
421
  ctx.inject(['userQuestions'], (qctx) => {
461
422
  asker = qctx.userQuestions
462
- agents = qctx.get('agents')
463
423
  if (!asker || typeof asker.ask !== 'function') log.warn('userQuestions injected but exposes no ask() — computer_ask cannot put a question on the card')
464
- 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')
465
- })
466
- // ---- AND A SECOND, INDEPENDENT INJECTION FOR THE REGISTRY (review re-audit E1, 2026-09-15) --------
467
- // `ctx.get('agents')` above is evaluated ONCE, when the userQuestions callback runs. If the registry
468
- // was not active at that instant — dsh-base mounts `user-questions` before `agent`
469
- // (`dsh-base/cordis.patch.yml:64` vs `:67`), so the ordering is a race this plugin does not control —
470
- // or if it is re-registered later (plugin reload, a row re-enabled from the plugins UI), `agents`
471
- // would stay null for the process lifetime and EVERY turn-end card would be refused silently.
472
- // This assignment-only injection self-heals that: when the registry appears, cordis runs this
473
- // callback and `agents` is set. It CANNOT hurt `asker`, because a listed service that never appears
474
- // simply leaves this fiber's callback unrun — the coupling finding 4 was about is not reintroduced.
475
- ctx.inject(['agents'], (actx) => {
476
- agents = actx.agents
477
- log.info('agents registry became available — turn-end cards can verify their caller')
478
424
  })
479
425
 
480
426
  // settings card (restart-applies) — same shape dsh-pocket/dsh-browser use
@@ -515,93 +461,6 @@ export function apply (ctx, config) {
515
461
  if (k) return String(k)
516
462
  return '<' + ((s.constructor && s.constructor.name) || typeof s) + '>'
517
463
  }
518
- // ---- THE AGENT EVERY QUESTION MUST NAME (2026-09-15) -----------------------------------------
519
- // MEASURED FAILURE, not a theory. The turn-end card was dispatched and the host answered
520
- // 22:57:16.836 turn-end card: NO ANSWER (NO_PROVIDER) — nothing steered, no choice was made
521
- // and no card was ever rendered — while the raiser itself worked fine (two seconds later it logged
522
- // "Desktop already in front for pending question (1 question(s): computer_use_turn_end)").
523
- //
524
- // CAUSE (`@deepseek-ai/dsh-api-remotes/lib/index.js:115-124`): the remote forwarder that carries a
525
- // question to the browser's card is a WATERFALL listener that begins
526
- // const carrierAgent = carrierKeyOf(this); if (carrierAgent === void 0) return next();
527
- // so an agent-LESS request is never forwarded to the client's answerer
528
- // (`dsh-client-ui-user-questions/lib/client.js:880`). Nobody accepts, and the service rejects with
529
- // NO_PROVIDER. `dsh-user-questions/README.zh.md:41` says the same thing in words: "Web 回答者只接收带
530
- // Agent scope 的请求;不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人
531
- // 接受则以 NO_PROVIDER 失败." The host's own `ask_user_question` always passes `agent: exec.agent`.
532
- //
533
- // WHY NOT SIMPLY PASS `bus.agent`: `ask()` validates it (`lib/index.js:56-60`) — it must be the
534
- // registry's EXACT live instance (`agents.get(a.id) === a`, else CALLER_NOT_LIVE) AND a live ROOT
535
- // (`agents.roots().includes(a)`, else DELEGATED_CALLER). `bus.agent` is the last tool caller, which
536
- // is right in practice but unverified, and a wrong agent fails in exactly the same silent way this
537
- // whole bug did. So the caller is verified FIRST and only replaced when the registry disapproves —
538
- // and every replacement is logged, so a fallback can never masquerade as the normal path.
539
- //
540
- // "LIVE ROOT" IS NOT ENOUGH — IT MUST BE THE ROOT OF THIS SESSION (found by RUNNING this function,
541
- // 2026-09-15, not by reading it). Liveness and rootness say nothing about WHICH chat the card is
542
- // for: another session's live root passed both checks and was returned as `verified`, so a turn that
543
- // ended in THIS chat could raise its card in another one. `session` is therefore part of the test,
544
- // and a candidate that is a live root of some OTHER session is treated as rejected — never adopted
545
- // merely because the registry does not object.
546
- const rootAgentFor = (session, candidate) => {
547
- try {
548
- if (!agents || typeof agents.roots !== 'function') return { agent: candidate, ask: false, reason: 'no-registry' }
549
- const roots = agents.roots()
550
- const live = (a) => !!a && !!agents.get && agents.get(a.id) === a
551
- const ofSession = (a) => !!a && session !== null && session !== undefined &&
552
- sameSession(a.session || a.sessionId, session)
553
- const isRoot = (a) => !!a && roots.includes(a)
554
- // ---- EXACTLY ONE ROOT OWNS THIS SESSION; THAT ROOT IS THE CORRECT CALLER ------------------
555
- // `roots()` is store-derived, so a fresh lookup cannot be stale; re-running `live` keeps the
556
- // "exact live instance" guarantee explicit rather than inferred from the filter.
557
- const same = roots.filter(ofSession)
558
- if (live(candidate) && isRoot(candidate) && ofSession(candidate)) {
559
- return { agent: candidate, ask: true, reason: 'verified' }
560
- }
561
- if (same.length === 1 && live(same[0])) {
562
- return { agent: same[0], ask: true, reason: live(candidate) && isRoot(candidate) ? 'other-session-root' : 'registry-root-by-session' }
563
- }
564
- // ---- NOTHING VERIFIED: SAY SO, AND DO NOT LET THE CALLER ASK WITH THIS AGENT ---------------
565
- // `ask:false` IS THE FIX, NOT A LABEL (review finding 2, 2026-09-15). The previous version
566
- // returned the raw candidate here with `verified` withheld — but `ask()` accepts a live root of
567
- // ANY session, so the card was raised in the WRONG CHAT. And a same-name note could not carry
568
- // the distinction: 'other-session-root' is also the CORRECT substitution just above. So the
569
- // decision travels as its own boolean, and `ask` remains a value only for the message.
570
- let reason
571
- if (live(candidate) && isRoot(candidate)) reason = 'other-session-root'
572
- else if (!candidate) reason = 'no-candidate'
573
- else if (!live(candidate)) reason = 'not-live-instance'
574
- else if (!isRoot(candidate)) reason = 'not-a-root'
575
- else reason = 'session-mismatch'
576
- return { agent: candidate, ask: false, reason: `${reason}/roots=${roots.length}/match=${same.length}` }
577
- } catch (e) {
578
- return { agent: candidate, ask: false, reason: 'resolve-threw:' + ((e && e.message) || 'unknown') }
579
- }
580
- }
581
-
582
- // ---- THE "CARD LOOP" HAD A DIFFERENT CAUSE, AND A SUPPRESSION FLAG WAS THE WRONG FIX -----------
583
- // Measured at 23:22: selecting 继续 produced the card again 2 s later, and again 5 s after that, until
584
- // the human cancelled it (`NO ANSWER (ASK_CANCELLED)`). A `suppressCardForTurn` flag was added for
585
- // that — and the re-audit showed it was BACKWARDS, from the turn-end diagnostic file:
586
- //
587
- // 15:22:12.724Z CARD … ask=yes via=verified ← the card the human answered
588
- // 15:22:23.039 TypeError … reading 'kind' ← the steer's bare object crashed the round
589
- // 15:22:23.048Z FIRED kind=error / CARD … ← 10 ms later — an ERRORED turn, not a new one
590
- // 15:22:30.588Z FIRED kind=error / CARD …
591
- //
592
- // There was never an echo: 继续 → crash → the turn ended as `kind=error` → a card for the errored turn.
593
- // The flag could not have suppressed that anyway — `steer` wakes the driver, the loop appends
594
- // `turn/start` immediately, and the `turn/start` handler cleared the flag before the card ever read it.
595
- // So it suppressed the case it was not meant to touch (a normal card-steered turn, where the human DOES
596
- // want to be asked) and could never fire for the case that actually looped. It is REMOVED rather than
597
- // repaired: the driver of that loop was the message-shape crash, and that is fixed at its source.
598
- //
599
- // WHAT DOES REMAIN TRUE: an errored turn raises the same decision card. That is deliberate — the human
600
- // should still decide what happens to the machine — but the card now SAYS the turn errored, so it is
601
- // not mistaken for an ordinary completion.
602
- // THERE IS NO machine-wide suppression flag here. If a future measurement shows a genuine echo, it must
603
- // be bound to (sessionKey, turn) and justified by evidence of that shape — not by this one.
604
-
605
464
  let inFlight = 0
606
465
  // Ctrl+Alt+Q cancels the turn ON PURPOSE. The abort that follows must never be mistaken for
607
466
  // the human hitting the stop button — that rule below re-armed the brake and left the red
@@ -683,6 +542,7 @@ export function apply (ctx, config) {
683
542
  return gate.claim({ session: s, agent: exec && exec.agent })
684
543
  }
685
544
 
545
+ const callOwners = new WeakMap()
686
546
  const ensureCycle = async (exec) => {
687
547
  const cl = claimOf(exec)
688
548
  // `gate.open` RESERVES ownership synchronously and THROWS for a foreign claim, so reaching the
@@ -709,6 +569,7 @@ export function apply (ctx, config) {
709
569
  log.warn('a computer-use exit record is still present after resume — a newer exit stands; the call that follows will be refused')
710
570
  return { opened: false, reason: 'a newer exit record stands' }
711
571
  })
572
+ if (exec && typeof exec === 'object') callOwners.set(exec, { claim: cl, cycleId: gate.snapshot().id })
712
573
  // THE MACHINE'S MAILBOX BELONGS TO THE OWNER (integration bug, 2026-09-13). Binding it in
713
574
  // `tools/pre-execute` meant EVERY caller rebound it — including one the gate was about to refuse
714
575
  // as foreign — so a Ctrl+Alt+Q could cancel the wrong session while the real owner kept working
@@ -721,125 +582,40 @@ export function apply (ctx, config) {
721
582
  return pending
722
583
  }
723
584
 
724
- // Keep only in-flight accounting for owner lifecycle events. No idle timer: approval waits,
725
- // thinking, and pauses keep the cycle until an explicit lifecycle event ends it.
726
- // ---- WHAT THE CARD RECOMMENDS, AND WHAT THE TIMER THEREFORE DOES (2026-09-15) ---------------------
727
- // ONE place decides both, so the card's text and the timer's behaviour cannot disagree — which is the
728
- // defect the human caught: the card promised "I will not start an unattended turn" while the design
729
- // words they remembered promised a branch.
730
- //
731
- // ---- AND THE BRANCH IS DECIDED BY THE TASK SITUATION, NOT BY A COUNTER ---------------------------
732
- // The first implementation capped consecutive automatic continuations with a config number (default 2).
733
- // The human rejected that design in one sentence: "由且仅由问题任务的情形决定,而不是几次。你现在刚我说
734
- // 是两次,不应该这样硬编码设计." They are right, and the counter also solved the wrong problem: it rationed
735
- // the TIMER, when what decides the branch is whether the work is still going.
736
- //
737
- // SO THE SITUATION IS READ FROM THE TASK, ONCE PER TURN:
738
- // * `turnSituation.action = true` — an actuation really reached the worker during this turn, so the task
739
- // was mid-motion when the turn ended. There IS somewhere to continue to.
740
- // * nothing was dispatched — the turn was observation, an error, or a wrap-up. There is nothing to
741
- // continue TO, so the timer makes no decision. That is the human's own rule, not a cap.
742
- // It is written by the `onDispatch` hook (see `hooks`), which fires at the last statement before the tool
743
- // body in `tools.js` — after every refusal has thrown — so "dispatched" is a fact, not an intention.
744
- //
745
- // ---- AND IT BELONGS TO ONE SESSION, NOT TO THE MACHINE (2026-09-16) ------------------------------
746
- // `turn/start` is a HOST-WIDE event, so a turn starting in another chat would clear this value — and the
747
- // mirror case is worse: chat A drives the machine, chat B starts a turn, chat A's turn ends, and A's card
748
- // carries a recommendation read from a machine-wide flag. `owner` names the session the situation
749
- // describes, and the timer only trusts it for that session.
750
- //
751
- // DECLARED BEFORE `hooks` ON PURPOSE: `hooks.onDispatch` closes over this object, and a body that reads a
752
- // `const` from its temporal dead zone is a run-time error waiting for the first driving call.
753
- const RECOMMEND_CONTINUE = '继续(按你自己的理解接着干)'
754
- const RECOMMEND_STOP = null // "no recommendation" is the honest value when the task says nothing
755
- const turnSituation = { action: false, reason: '(no actuation reached the worker this turn)', owner: null, seq: [] }
756
- /** Does the recorded situation describe THIS session's turn? */
757
- const situationIsMine = (session) => !!turnSituation.owner && turnSituation.owner === sessionKey(session)
758
-
759
- // ---- THE STAGNATION SIGNATURE: A COMPARISON, NOT A COUNT (2026-09-16) -----------------------------
760
- // The human rejected a counter ("由且仅由问题任务的情形决定,而不是几次"). The remaining risk is a task that
761
- // drives on EVERY turn — a poll or retry loop — which no situation gate can stop, because each turn does
762
- // something. So the guard is a COMPARISON between two situations:
763
- // * `lastDrive` is the tool sequence that justified the last automatic continuation;
764
- // * if the next turn's sequence is IDENTICAL and the human has not answered in between, continuing is
765
- // not progress — the same thing was done again — so the timer stops and says so.
766
- // It accumulates nothing, hardcodes no number, and any real progress changes the sequence. Its honest
767
- // weakness: a task that legitimately repeats one identical step would stop one iteration early — which
768
- // is the conservative direction, because the card is still raised and the human decides.
769
-
770
- let lastDrive = null // { seq: string[] } — the sequence behind the last timer-driven continuation
771
- let humanAnsweredSince = false
772
- // ---- AND THE OBSERVATION-ONLY CASE (point 2, 2026-09-16) -----------------------------------------
773
- // A turn that only looked (a screenshot, a UIA read) has no dispatch, so the situation gate above said
774
- // "no recommendation" even when the work was plainly mid-task. `lastTurnDrove` carries the previous turn's
775
- // state forward for exactly one turn: an observation turn that FOLLOWS a driving turn is still mid-task,
776
- // so it may recommend once — and if the agent then keeps observing without driving, the same similarity
777
- // check stops it (the sequence would repeat).
778
- let lastTurnDrove = false
779
- const stalledNow = (session) => !!lastDrive && !humanAnsweredSince && situationIsMine(session) &&
780
- lastDrive.seq.length > 0 && lastDrive.seq.join('>') === turnSituation.seq.join('>')
781
- /** Record the situation that justified a continuation, so the NEXT identical one can be recognised. */
782
- const noteDrive = () => { lastDrive = { seq: [...turnSituation.seq] } }
783
-
784
- const recommendContinueNow = (kind, session) => {
785
- if (config.turnEndTimeoutAction !== 'recommended') return RECOMMEND_STOP
786
- if ((kind || 'completed') !== 'completed') return RECOMMEND_STOP // an errored turn is never continued
787
- if (cu.stopped || brakeLatch()) return RECOMMEND_STOP // a brake outranks a timer
788
- // A SITUATION THAT DOES NOT BELONG TO THIS TURN IS NO SITUATION: without this, an actuation in another
789
- // chat could recommend continuing in this one.
790
- if (!situationIsMine(session)) return RECOMMEND_STOP
791
- if (turnSituation.action) return continueOrStop(session)
792
- // Observation-only turn: still mid-task if the PREVIOUS turn drove, and only when that situation is not
793
- // itself a repetition of what the last automatic continuation already acted on.
794
- if (lastTurnDrove && turnSituation.seq.length > 0) return continueOrStop(session)
795
- return RECOMMEND_STOP
796
- }
797
- /**
798
- * The ONE mapping from "there is work to continue" to the recommendation. The polarity lives here and
799
- * nowhere else: an inverted ternary at a call site would continue exactly when the task did nothing, and
800
- * the reviewer showed that regression can keep every other assertion green.
801
- */
802
- const continueOrStop = (session) => (stalledNow(session) ? RECOMMEND_STOP : RECOMMEND_CONTINUE)
803
- /** Why the recommendation came out the way it did — for the log, which must not have to guess. */
804
- const recommendationReason = (session) => {
805
- if (stalledNow(session)) return '(the same sequence of tools was already continued for — repeating it is not progress)'
806
- if (turnSituation.action) return turnSituation.reason
807
- if (lastTurnDrove && turnSituation.seq.length > 0) return `(the previous turn drove the machine and this one only observed: ${turnSituation.seq.join('>')})`
808
- return turnSituation.reason
809
- }
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
+ })
810
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
+ })
811
598
  const hooks = {
812
- // ---- THE TASK SITUATION IS WRITTEN AT THE DISPATCH DOOR, NOT AT THE POLICY DOOR (2026-09-16) -----
813
- // `tools/pre-execute` runs BEFORE every refusal — the ownership verdict, the voided opening, the
814
- // cancellation, and the policy/brake gates inside `reg`. Marking the situation there meant a call that
815
- // was denied or refused could claim the task was in motion (and, worse in the other direction, a
816
- // HIGH-RISK actuation the human had just APPROVED looks like `kind: 'ask'` at the policy door, so the
817
- // timer would silently never recommend exactly when a dangerous action had just been authorised).
818
- // `tools.js` calls this immediately before `inner(args, exec)` — the last statement before the tool
819
- // body, after every refusal has already thrown. So this is the one place where "it was dispatched" is
820
- // a fact rather than an intention, which is what the recommendation is supposed to rest on.
821
- onDispatch: (exec) => {
822
- try {
823
- if (!exec || typeof exec.name !== 'string' || !exec.name.startsWith('computer_')) return
824
- // ---- EVERY computer_* DISPATCH IS RECORDED, AND THIS LINE WAS MISSING (defect, 2026-09-16) -----
825
- // The comment that used to sit here claimed "observation calls are recorded too". The code did the
826
- // opposite: it returned early for a non-driving call, so `seq` held DRIVING calls only. Two
827
- // consequences, both silent:
828
- // * the observation-only rule in `recommendContinueNow` (`lastTurnDrove && seq.length > 0`) could
829
- // never fire — `seq` was empty by construction on exactly the turn it was written for, so the
830
- // feature was dead code that read as working;
831
- // * `seq` could not serve as "this turn touched the machine at all", which is the fact the
832
- // turn-end gate now needs (a billed, immediately-failing turn must not raise a card).
833
- // `seq` is the turn's SIGNATURE (which tools ran, in order); `action` stays the narrower claim
834
- // (something was ACTUATED) because the recommendation's honesty rests on that distinction.
835
- turnSituation.seq.push(`${exec.name}`)
836
- if (!isDrivingCall(exec.name, exec.arguments || {})) return
837
- turnSituation.action = true
838
- turnSituation.reason = `(${exec.name} actuated the machine during this turn)`
839
- } 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}`))
840
618
  },
841
- onStart: () => { inFlight++ },
842
- onEnd: () => { inFlight = Math.max(0, inFlight - 1) },
843
619
  // ---- THE WRAP-UP DIRECTIVE (2026-09-15) --------------------------------------------------------
844
620
  // WHAT WENT WRONG. A human brake (ESC, a mouse shake, the wheel, typing outside the host) engaged
845
621
  // the brake and steered a "WRAP UP NOW" notice into the turn — but `agent.steer` only delivers at a
@@ -1073,6 +849,7 @@ export function apply (ctx, config) {
1073
849
  if (r && r.exited) { gate.mark('ended', { why: 'the ask window ended in an exit' }); return }
1074
850
  if (!r || r.stopped) { gate.mark('paused', { why: 'the ask window ended with a stop standing' }); return }
1075
851
  gate.mark('open', { why: 'the ask window closed with no brake (no human input)' })
852
+ terminalCleanup.resumePending()
1076
853
  } catch { /* never break the host */ }
1077
854
  })
1078
855
 
@@ -1091,6 +868,7 @@ export function apply (ctx, config) {
1091
868
  return
1092
869
  }
1093
870
  brakeWhy = '' // the brake is off, so there is no reason left to hand to a refused call
871
+ if (terminalCleanup.resumePending()) return
1094
872
  const version = gate.exitVersion()
1095
873
  notice(
1096
874
  '继续 — the human re-armed computer use with Ctrl+Alt+R: the brake is cleared and the red border ' +
@@ -1111,7 +889,7 @@ export function apply (ctx, config) {
1111
889
  // late callback, not a queued call in this same turn — may move this id back to a live state; a
1112
890
  // new cycle needs a new turn's first real call and gets a new id (lib/cycle.js).
1113
891
  gate.mark('ended', { why: (e && e.why) || 'exit event' })
1114
- cu.calm().catch(() => {})
892
+ terminalCleanup.forget() // native exit already performed cleanup; never send a late calm
1115
893
  selfAbortUntil = Date.now() + 8000
1116
894
  // CAPTURE THE RECIPIENT BEFORE THE FIRST `await` (integration bug, 2026-09-13). The notice below
1117
895
  // awaits message construction and delivery, and anything that rebinds `bus.agent` during that
@@ -1190,8 +968,7 @@ export function apply (ctx, config) {
1190
968
  log.info(`agent/status idle while ${gate.state()} — the cycle stays alive (Ctrl+Alt+R resumes it)`)
1191
969
  return
1192
970
  }
1193
- gate.mark('ended', { why: 'owner agent idle', session: gate.ownerSessionKey(), agent: payload && payload.agent })
1194
- cu.calm().catch(() => {})
971
+ lifecycle.idle(payload.agent)?.catch(error => log.warn(`computer-use idle release failed: ${error.message}`))
1195
972
  } catch { /* the indicator must never break the host */ }
1196
973
  })
1197
974
 
@@ -1203,7 +980,7 @@ export function apply (ctx, config) {
1203
980
  const whoKey = agentKeyOf(payload && payload.agent)
1204
981
  if (whoKey && gate.ownsAgent(whoKey)) {
1205
982
  log.info(`owner agent disposed (${whoKey}) — releasing the machine for the next real call`)
1206
- gate.release('owner agent disposed')
983
+ terminalCleanup.request('owner agent disposed')
1207
984
  }
1208
985
  } catch { /* never break the host */ }
1209
986
  })
@@ -1232,17 +1009,6 @@ export function apply (ctx, config) {
1232
1009
  // session's generation is all a turn boundary is allowed to do — and it is what gives a
1233
1010
  // brand-new session its qualification, since it never has to match a remembered driver.
1234
1011
  if (session) gate.noteTurnStart(session)
1235
- // A NEW TURN CLEARS THE TASK SITUATION. Per turn, never accumulated: the recommendation the timer
1236
- // may execute is about THIS turn's work, so the flag, the sequence and the owner all start empty.
1237
- // `lastTurnDrove` CARRIES THE PREVIOUS TURN FORWARD ON PURPOSE (the observation-only case), and the
1238
- // stagnation signature (`lastDrive`) is history, not per-turn state — resetting either here would
1239
- // silently disable both guards.
1240
- lastTurnDrove = turnSituation.action
1241
- turnSituation.action = false
1242
- turnSituation.reason = '(no actuation reached the worker this turn)'
1243
- turnSituation.owner = sessionKey(session)
1244
- turnSituation.seq = []
1245
- declaredRecommendation = undefined
1246
1012
  const owner = gate.ownerSessionKey()
1247
1013
  if (!owner || !session || sameSession(session, owner)) nudgedTurn = false
1248
1014
  // NOTHING IS OPENED HERE (A/B). The opener is the first real call of the turn — the only
@@ -1268,357 +1034,24 @@ export function apply (ctx, config) {
1268
1034
  if (selfAbortUntil && Date.now() < selfAbortUntil) {
1269
1035
  selfAbortUntil = 0
1270
1036
  log.warn(`turn ended ${kind} — caused by our own Ctrl+Alt+Q exit; NOT re-engaging the brake`)
1271
- cu.calm().catch(() => {})
1272
1037
  return
1273
1038
  }
1274
1039
  // Host cancellation is per request/turn; only the physical Ctrl+Esc path
1275
1040
  // raises an input-triggered persistent brake.
1276
1041
  log.warn(`turn ended ${kind} in a computer-use session — cancelling only its input`)
1277
1042
  recoveryEpoch++; temporaryPause = null
1278
- cu.cancelInputs?.()
1279
1043
  // An explicit Ctrl+Esc pause remains latched. Ordinary Host cancellation ends
1280
1044
  // this turn and its queued input without inventing a persistent human brake.
1281
- if (!cu.stopped) {
1282
- gate.mark('ended', { why: `owner turn ${kind}` })
1283
- cu.calm().catch(() => {})
1284
- }
1045
+ terminalCleanup.request(`owner turn ${kind}`)
1285
1046
  return
1286
1047
  }
1287
- // ---- THE TURN ENDS; THE DECISION ABOUT THE CYCLE BELONGS TO THE CARD (2026-09-15) -----------
1288
- // This branch used to CLOSE the cycle first (`gate.mark('ended')`) and only then raise the card.
1289
- // `'ended'` is irreversible, so the FIRST turn end that reached here ended the cycle — and from
1290
- // the second turn onward `gate.ownsSession(...)` was false, the whole block was skipped, and the
1291
- // card could never fire again. Measured: one card at 22:30, then nothing for five turns while
1292
- // the log filled with `agent/status idle ignored (another agent)`. The human's report was exactly
1293
- // that: "我还是没亲眼看到触发".
1294
- //
1295
- // The human chose 甲: a completed turn NO LONGER closes the cycle on its own. The card decides,
1296
- // and "关闭周期" is one of its branches — which is also what they asked for in words ("你决定之后
1297
- // 再说,关闭整个周期,还是继续"). The old worry that motivated auto-closing ("不能让正常完成一律保留成
1298
- // 永远活着") is now handled by the card: an explicit branch, or the timeout default, ends it.
1299
- // A pause/ask still keeps the cycle alive for Ctrl+Alt+R, unchanged.
1300
- // ---- A TURN THAT NEVER TOUCHED THE MACHINE IS NOT A COMPUTER-USE SUSPENSION (defect, 2026-09-16) --
1301
- // THE DEATH LOOP THIS CLOSES, in the human's own words: "你卡进死循环了 不断弹卡片". Measured in
1302
- // `turn-end-diag.log`: eleven cards in two and a half minutes (02:38:03 → 02:40:40), every one of them
1303
- // `kind=error`, and the session transcript says why —
1304
- // turn 146…156 reason={"kind":"error","error":{"message":"Insufficient Balance","code":"QUOTA"}}
1305
- // The account was out of credit, so EVERY turn died at step 1. Each death ended a turn, the turn-end
1306
- // gate asked for a decision about a MACHINE THAT HAD NOT BEEN TOUCHED, the human answered, and their
1307
- // answer started another turn that died the same way. The card was not wrong about the machine; it was
1308
- // answering a question nobody had asked, about a machine that was idle, forever.
1309
- // The honest predicate is already on hand: `turnSituation.seq` is the turn's own dispatch signature
1310
- // (every `computer_*` call the turn actually dispatched — see `hooks.onDispatch`). Empty means this
1311
- // turn never reached the machine, so there is no cycle decision to make and the card must not fire. A
1312
- // billing error, a provider outage or a chat-only turn is reported by the host's own "本轮运行失败";
1313
- // dressing it as a machine decision is what produced the loop.
1314
- // It also keeps the human's standing rule intact: "一旦挂起,任何挂起,都必须进入卡片" — a suspension
1315
- // OF THE MACHINE still always gets its card, and now a turn that suspended nothing no longer fakes one.
1316
- //
1317
- // THE RESIDUAL RISK, STATED RATHER THAN HIDDEN: `turnSituation` is ONE slot and `turn/start` is a
1318
- // host-wide event, so if another session (a background subagent, another chat) starts a turn between
1319
- // this turn's last dispatch and its end, that start resets `seq` and this gate then suppresses a card
1320
- // the owner had earned. The failure direction is the safe one — no card, the cycle stays open, and the
1321
- // machine was never touched by anyone in the meantime — and `recommendContinueNow` has depended on the
1322
- // same single slot since it was written. Making it exact means keying the situation BY SESSION, which
1323
- // is a change to the recommendation's own input and is not something to smuggle in behind a bug fix.
1324
- const touched = situationIsMine(session) && turnSituation.seq.length > 0
1325
- if (gate.ownsSession(sessionKey(session)) &&
1326
- gate.state() !== 'paused' && gate.state() !== 'asking' &&
1327
- !cu.stopped && !brakeLatch() && touched) {
1328
- diagTurnEnd(`FIRED kind=${kind || 'completed'} asker=${!!asker} raiseHuman=${!!raiseHuman} ` +
1329
- `seq=${turnSituation.seq.length} session=${String(sessionKey(session)).slice(0, 12)}`)
1330
- // ---- A COMPUTER-USE TURN MUST NOT END SILENTLY (2026-09-15, the human's standing rule) ----
1331
- // "一旦挂起,任何挂起,都必须进入卡片… 如果60s没回复,超时之后自动进行分支判断". Until now a
1332
- // turn end produced a taskbar toast and nothing else: the design's RAISE + CARD + 60 s branch
1333
- // was never implemented on this path. The 60 s clock that DID exist lived in the worker's
1334
- // `computer_ask` (`worker.cs:5430`) and was removed, correctly, by the ask merge — and the
1335
- // plugin-side branch was never added back.
1336
- //
1337
- // WHY A DETOUR RATHER THAN A CANCELLABLE CARD: the harness has no way to withdraw a pending
1338
- // card (checked during the merge — it ends by being answered, dismissed, or by the turn being
1339
- // torndown). So the card is NOT cancelled. A local timer simply stops waiting on it, which is
1340
- // the same thing the human asked for and needs no host API. The card may linger in the chat;
1341
- // answering it later changes nothing, because the race below has already settled.
1342
- //
1343
- // AUTO-DECISION: on timeout the agent DOES NOT continue driving on its own. A turn that ended
1344
- // by itself leaves the machine free and the human absent — starting a new turn unattended is
1345
- // the one outcome that cannot be undone. The human's own words allow either branch ("是否必须
1346
- // 终结还是按照你自己的理解继续"); stopping is the reversible one.
1347
- void askAtTurnEnd(session, kind)
1348
- } else {
1349
- // THE ANSWER THAT ACTUALLY NEEDS A REASON. Without this line "the card did not fire" has no
1350
- // explanation at all, which is exactly where this feature was stuck.
1351
- diagTurnEnd(`SKIPPED kind=${kind || 'completed'} state=${gate.state()} ` +
1352
- `owns=${gate.ownsSession(sessionKey(session))} stopped=${!!cu.stopped} latch=${!!brakeLatch()} ` +
1353
- `touched=${touched} seq=${turnSituation.seq.length} ` +
1354
- `session=${String(sessionKey(session)).slice(0, 12)}`)
1355
- }
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}`))
1356
1052
  } catch { /* never break the host */ }
1357
1053
  })
1358
1054
 
1359
- /**
1360
- * Raise the Desktop, show the decision card, and stop waiting after the timeout.
1361
- *
1362
- * Returns a promise that settles with `{answered, selected, custom, timedOut}` — never rejects.
1363
- * The card is deliberately left in place on timeout; see the call site for why that is the design
1364
- * and not an oversight.
1365
- */
1366
-
1367
- async function askAtTurnEnd (session, kind) {
1368
- try {
1369
- if (!asker || typeof asker.ask !== 'function') return { answered: false, reason: 'no-asker' }
1370
- // ---- RESOLVE, RECORD, GUARD, AND ONLY THEN RAISE (review re-audit B, 2026-09-15) -------------
1371
- // The raise used to happen first, so a REFUSED card still restored and focused Desktop for a
1372
- // question that would never exist — the exact opposite of this file's own rule ("only raise when
1373
- // a human input is actually pending; never yank the human", see `enableHumanAttention`). Nothing
1374
- // is lost by reordering: `rootAgentFor` is fully synchronous, so the card loses zero latency, and
1375
- // the 60 s clock still starts only after the voucher exists (the old version armed a timer whose
1376
- // promise nobody would ever await).
1377
- const rp = rootAgentFor(session, bus.agent)
1378
- // The recommendation is fixed HERE, once, before the card is built — the timer later executes what
1379
- // the card said, never a fresh judgement made while nobody is watching.
1380
- const recommend = recommendContinueNow(kind, session)
1381
- // THE REASON IS CAPTURED HERE, WITH THE DECISION (review E4). `turnSituation` is MUTABLE and a
1382
- // `turn/start` can land during the 60 s wait, so logging `turnSituation.reason` at fire time could
1383
- // print "recommended because (no actuation reached the worker this turn)" — a sentence that
1384
- // contradicts itself. The reason that justified THIS card is the one recorded with it.
1385
- const recommendReason = recommendationReason(session)
1386
- // AND THE STALL VERDICT IS ALSO FIXED HERE, WITH THE DECISION. It was computed inside
1387
- // `recommendContinueNow`, and re-reading it 60 s later would compare against a `turnSituation` that a
1388
- // `turn/start` may have already replaced — the same mutability trap as the reason above.
1389
- const stalledAtDecision = stalledNow(session)
1390
- diagTurnEnd(`CARD kind=${kind || 'completed'} rec=${recommend ? 'yes' : (config.turnEndTimeoutAction === 'recommended' ? 'no(situation/brake)' : 'setting=stop')} ` +
1391
- `ask=${rp.ask ? 'yes' : 'NO'} via=${rp.reason} ` +
1392
- `agentId=${rp.agent && rp.agent.id ? String(rp.agent.id).slice(0, 8) : '-'}`)
1393
- // ---- NEVER ASK WITH AN AGENT THE REGISTRY DID NOT VOUCH FOR --------------------------------
1394
- // `ask:false` means the resolver could NOT prove this agent is the live root of the session whose
1395
- // turn just ended. Two ways that ends badly, both worse than no card:
1396
- // * a live root of ANOTHER session is accepted by `ask()` — the card would appear in a
1397
- // different chat, asking the human about a turn they are not looking at (review finding 2);
1398
- // * an agent-less request is dropped by the forwarder (`api-remotes/lib/index.js:117`), so the
1399
- // ask can only ever come back NO_PROVIDER — a guaranteed-useless round trip that already
1400
- // cost one debugging round (review finding 3).
1401
- // The refusal is recorded in the turn-end diagnostic file BEFORE this return, so "the card did
1402
- // not fire" still has a written reason. `computer_ask` is unaffected: it asks through the
1403
- // worker's own path.
1404
- //
1405
- // THE INDICATOR IS NOT TOUCHED HERE, DELIBERATELY (review re-audit E2). A refusal marks nothing,
1406
- // so the cycle survives to the owner-idle edge, which is what cuts the border. If that edge has
1407
- // already passed, the indicator can linger until the next call or exit — the same outcome the
1408
- // pre-fix NO_PROVIDER produced, so this is not a regression, and guessing a closer here would
1409
- // mean a card-less turn silently ending a cycle the human may still want.
1410
- if (!rp.ask || !rp.agent) {
1411
- log.warn(`turn-end card: NOT raised — the registry did not vouch for a caller of this session (${rp.reason}); ` +
1412
- 'no card is better than a card in the wrong chat (the cycle is left to the owner-idle edge)')
1413
- return { answered: false, reason: rp.reason || 'unverified-agent', timedOut: false }
1414
- }
1415
- 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`)
1416
- // RAISE, THEN ASK — and do not await the raise: `raiseFor` only has to start the reveal, while
1417
- // the card must appear whatever the window operation does. The OUTCOME is recorded, though: "the
1418
- // human never saw the card" was the one fact nothing wrote down, and it took a human report
1419
- // ("没有把DSH激活放到我面前") to find it (defect, 2026-09-16).
1420
- Promise.resolve(raiseHuman?.(`turn-end:${Date.now()}`, `computer-use turn ended (${kind || 'completed'})`))
1421
- .then((shown) => diagTurnEnd(`RAISE shown=${shown !== false} kind=${kind || 'completed'}`))
1422
- .catch(() => {})
1423
- // ---- WHO RECEIVES THE HUMAN'S ANSWER (review finding 5, 2026-09-15) ------------------------
1424
- // The card is scoped to `rp.agent`, so the answer must go back to the SAME agent. Steering
1425
- // `bus.agent` — a different object whenever the resolver substituted one — would deliver the
1426
- // human's decision to a replaced or already-dead agent, inside a `try` that swallows the throw,
1427
- // i.e. the answer would vanish with no trace. Resolved once, used by every branch below.
1428
- const recipient = rp.agent || bus.agent
1429
- const ANSWER_TIMEOUT_MS = 60_000
1430
- let timer = null
1431
- const timedOut = new Promise((resolve) => {
1432
- timer = setTimeout(() => resolve({ answered: false, timedOut: true, reason: 'timeout' }), ANSWER_TIMEOUT_MS)
1433
- })
1434
- const asked = asker.ask({
1435
- // THE AGENT IS NOT OPTIONAL. See `rootAgentFor` for the measured NO_PROVIDER this fixes.
1436
- agent: rp.agent,
1437
- questions: [{
1438
- id: 'computer_use_turn_end',
1439
- header: 'computer-use 回合结束',
1440
- // THE CARD SAYS IT STAYS. The human's rule: "只要我没有任何反应,卡片就应该一直挂着,除非有
1441
- // 这个60秒超时之后你取消它,你决定之后再说" — and they must be TOLD they can still change
1442
- // their mind later, or the late-correction path is invisible to the only person who can use it.
1443
- // THE TURN'S OUTCOME IS ON THE CARD (review finding E3, 2026-09-15). An errored turn raises the
1444
- // same card as a completed one, which is right — the human must still decide what happens to the
1445
- // machine — but at 23:22 the crash produced "本轮运行失败" immediately followed by "这一回合结束了",
1446
- // with nothing saying the two were the same event. `kind` is already known here; saying it costs
1447
- // one line and removes the confusion at its source.
1448
- // ---- THE CARD SAYS WHAT THE TIMER WILL DO, AND THE TWO CANNOT DISAGREE (2026-09-15) --------
1449
- // The human's rule, in their words: "这个问题超时的情况下,如果你有推荐选项就走推荐,如果没有
1450
- // 推荐选项就停。而不是所有情况都一定有推荐选项,因为有些问题你确实是没有办法替我执行选择."
1451
- // So there are two honest texts and NO third one. The previous text claimed the timer always
1452
- // "按停处理", which was both wrong about the timer (it made no decision) and absent from the
1453
- // design; and it promised "不会自己开新回合" even in the mode where it will.
1454
- question: (kind === 'error'
1455
- ? '**这一回合是以错误结束的**(不是正常做完),要我怎么走?\n\n'
1456
- : '这一回合结束了。要我怎么走?\n\n') +
1457
- '· **卡片会一直挂着**,你多久后回来点都算数,而且**以你为准**(我会比对偏差并改成你的选择)。\n' +
1458
- (recommend
1459
- ? `· **60 秒内你不回复**,我走标着「推荐」的那一项:**${recommend}**。`
1460
- : '· **60 秒内你不回复**,我**不做决定、也不动机器**——这一题我没有替你选的立场,等你给策略。'),
1461
- options: [
1462
- { label: '继续(按你自己的理解接着干)', description: '等同于你按 Ctrl+Alt+R:我会开一个新回合继续。' },
1463
- { label: '停(等我给策略)', description: '我不动,周期留着,等你在聊天里写下下一步。' },
1464
- { label: '关闭周期(这一轮到此为止)', description: '收掉周期和指示器;下次要用 computer-use 需要新回合重新开。' },
1465
- ].map((o) => (o.label === recommend ? { ...o, description: `【推荐】${o.description}` } : o)),
1466
- }],
1467
- }).then((reply) => {
1468
- const a = reply && Array.isArray(reply.answers) ? reply.answers[0] : null
1469
- const selected = a && Array.isArray(a.selected) ? a.selected : []
1470
- const custom = a && typeof a.custom === 'string' && a.custom.trim() !== '' ? a.custom.trim() : undefined
1471
- // ---- WHAT THIS LINE DOES AND DOES NOT PROVE (corrected by review finding 11, 2026-09-15) ---
1472
- // It proves the ANSWERER ACCEPTED AND SETTLED the request — i.e. a browser answerer took it and
1473
- // the round trip completed — which is exactly what a NO_PROVIDER never reaches, so its
1474
- // presence/absence still separates "reached a card" from "reached nobody".
1475
- // It does NOT prove a card was RENDERED BEFORE the human touched anything: the client resolves
1476
- // this waterfall only on the answer (`dsh-client-ui-user-questions/lib/client.js:113-119`,
1477
- // `await pending.result`). An earlier version of this comment claimed otherwise; the claim was
1478
- // wrong, and a wrong witness is worse than none.
1479
- log.info(`turn-end decision card answered: selected=${JSON.stringify(selected)}` +
1480
- `${custom ? ` custom=${JSON.stringify(custom.slice(0, 40))}` : ''}`)
1481
- return { answered: true, selected, custom, timedOut: false }
1482
- }).catch((e) => ({ answered: false, reason: (e && e.code) || 'error', timedOut: false }))
1483
- const out = await Promise.race([asked, timedOut])
1484
- if (timer) clearTimeout(timer)
1485
- // ---- THE LATE HANDLER MUST BE ABOUT A LATE ANSWER (measured defect, 2026-09-15 23:40) ---------
1486
- // `asked.then(...)` used to be attached INSIDE this branch but unconditionally, so `asked` settling
1487
- // by the ordinary path ALSO ran it. Host log, the human's own click:
1488
- // 23:40:12.584 answered: selected=["继续…"] ← the real answer, on time
1489
- // 23:40:12.585 turn-end card answered LATE … ← same millisecond, the duplicate
1490
- // 23:40:14.846 the human SELECTED 继续 … ← and the ordinary branch, 2 s later
1491
- // One click was handled TWICE. With the old bare-object steer both copies crashed; with the fixed
1492
- // message shape the duplicate would silently steer the human's text a second time. The flag makes
1493
- // the handler mean what its name and its log line say.
1494
- const settledLate = !!out.timedOut
1495
- if (out.timedOut) {
1496
- // ---- THE TIMER BRANCHES (2026-09-15, restored from the human's own design words) -------------
1497
- // "如果60s没回复,超时之后自动进行分支判断,是否必须终结还是按照你自己的理解继续", refined by them
1498
- // later the same night: "如果你有推荐选项就走推荐,如果没有推荐选项就停。而不是所有情况都一定有推荐
1499
- // 选项,因为有些问题你确实是没有办法替我执行选择."
1500
- // So: a declared recommendation is EXECUTED; without one the timer makes NO decision and touches
1501
- // nothing. Only the agent's next turn is ever at stake — never the cycle, never the brake.
1502
- if (recommend) {
1503
- // ---- THE GATE IS RE-CHECKED AT FIRE TIME, NOT ONLY WHEN THE CARD WAS BUILT (review A(ii)) --
1504
- // `recommendContinueNow` ran 60 s ago. In between, a human ESC / presence panic sets `cu.stopped`
1505
- // AND the STOP latch, and Ctrl+Alt+Q writes the EXITED record. Without this re-check the timer
1506
- // would still steer 继续 and wake a turn whose every `computer_*` call the plugin's own gates
1507
- // refuse — a billed turn that can only produce refusals, while the card had promised "我走标着
1508
- // 「推荐」的那一项". The machine was never at risk (the timer touches no cycle and no brake), but
1509
- // the promise would be false, and this file's rule is that a human act outranks a timer.
1510
- if (cu.stopped || brakeLatch()) {
1511
- log.warn('turn-end decision card timed out after 60 s, but the human braked during the wait — ' +
1512
- 'the recommendation is WITHDRAWN (a timer never outranks a brake); nothing steered')
1513
- } else if (stalledAtDecision) {
1514
- log.warn('turn-end decision card timed out after 60 s — the situation has not changed since the last ' +
1515
- 'automatic continuation (the same sequence of tools), so continuing is not progress; the timer ' +
1516
- 'makes no decision and the human decides')
1517
- } else {
1518
- // THE SIGNATURE IS RECORDED WHEN THE CONTINUATION ACTUALLY HAPPENS, not when the card is built:
1519
- // a card that is never answered from must not spend the stall check on a turn that never ran.
1520
- noteDrive()
1521
- log.info(`turn-end decision card timed out after 60 s — executing the RECOMMENDED option ` +
1522
- `(${JSON.stringify(recommend)}); recommended because ${recommendReason}`)
1523
- if (recipient) {
1524
- void buildUserMessage(RECOMMEND_CONTINUE, 'turn-end-timeout', 'the 60 s timer executed the card\'s recommended option')
1525
- .then((m) => recipient.steer(m))
1526
- .catch((e) => log.warn(`the recommended option could not be delivered: ${(e && e.message) || 'unknown'}`))
1527
- }
1528
- }
1529
- } else {
1530
- log.info(`turn-end decision card timed out after 60 s — NO recommendation ${recommendReason}, ` +
1531
- 'so the timer makes no decision and touches nothing: the machine stays with the human')
1532
- }
1533
- // ---- THE HUMAN MAY STILL ANSWER, AND THEIR ANSWER OUTRANKS THE TIMER (2026-09-15) ---------
1534
- // Their standing rule: "卡片挂着也行,我要是后面回来有点击或回复,你直接立即以我的输入为最高
1535
- // 优先,比对我不在期间的选择和最新输入是否有偏差". So the settled-by-timeout branch is NOT
1536
- // final: the SAME promise is kept and awaited a second time, out of band. When it resolves we
1537
- // compare what the timer chose against what they actually said, and report the deviation.
1538
- //
1539
- // WHY THIS CANNOT BE DONE AT THE CARD: the harness offers no way to withdraw a pending card,
1540
- // so "the timer decided" and "the card is still answerable" are both true at once. Rather than
1541
- // fight that, the late answer is treated as a CORRECTION — the truthful model of what it is.
1542
- void asked.then((late) => {
1543
- // Belt and braces: only a card that was still pending when the timer fired is "late".
1544
- if (!settledLate) return
1545
- if (!late || !late.answered) return
1546
- const lateText = late.custom || (Array.isArray(late.selected) && late.selected[0]) || ''
1547
- if (!lateText) return
1548
- // THE TIMER'S OUTCOME IS "NO DECISION", NOT "CHOSE 停". Saying the timer "chose 停" would be
1549
- // the same lie this branch was written to stop making: the timer decides only that the agent
1550
- // will NOT continue unattended. The human's late word therefore never CONTRADICTS the timer —
1551
- // it is the first decision anyone made, and it wins.
1552
- const deviation = true
1553
- log.warn(`turn-end card answered LATE (after the 60 s timer). The timer made NO decision ` +
1554
- `(the agent simply did not continue unattended); the human now says: ${JSON.stringify(lateText)}. ` +
1555
- (deviation
1556
- ? 'ACTING ON THE HUMAN\'S INPUT — it takes precedence, and it is the only decision on record.'
1557
- : 'No deviation.'))
1558
- if (deviation && recipient) {
1559
- // A REAL MESSAGE, NEVER A BARE OBJECT — see `buildUserMessage`. A raw `{text}` here crashes
1560
- // the harness's own inbox listener (`message.source.kind`) and the human sees the round fail.
1561
- void buildUserMessage(lateText, 'turn-end-late', 'the human answered the turn-end card late')
1562
- .then((m) => recipient.steer(m))
1563
- .catch((e) => log.warn(`late answer could not be delivered: ${(e && e.message) || 'unknown'}`))
1564
- }
1565
- }).catch(() => {})
1566
- return out
1567
- }
1568
- // A STRATEGY IN THE HUMAN'S OWN WORDS OUTRANKS THE MENU, exactly as the design intends ("其中一个
1569
- // 给出一个我的自由输入指令"): if they typed anything, that text is the instruction.
1570
- //
1571
- // DO NOT LOG A CHOICE NOBODY MADE (2026-09-15, caught by the human: "我啥都没按 笑死了 没有任何人工
1572
- // 输入"). This branch used to fall through to `the human chose 停` for EVERY input-less ending —
1573
- // a dismissed card, an aborted ask, an empty reply — so the log asserted a human decision that had
1574
- // never happened, and the human read it back as evidence. That is the SAME defect this session
1575
- // spent hours removing from the brake monitors ("a signal that cannot separate those two cases
1576
- // must not drive a brake"), committed here in prose form. Each ending now names itself, and only
1577
- // a REAL selection may be reported as a choice.
1578
- if (!out.answered) {
1579
- log.info(`turn-end card: NO ANSWER (${out.reason || 'unknown'}) — nothing steered, no choice was made`)
1580
- return out
1581
- }
1582
- // A REAL ANSWER CLEARS THE STALL HISTORY: the similarity rule exists to stop the TIMER talking to
1583
- // itself, never to overrule a human who is present and choosing.
1584
- humanAnsweredSince = true
1585
- const text = out.custom || (Array.isArray(out.selected) && out.selected[0]) || ''
1586
- if (!text) {
1587
- log.info('turn-end card: answered with an EMPTY selection (skipped or dismissed) — nothing steered, this is NOT a choice of 停')
1588
- } else if (out.custom) {
1589
- if (recipient) {
1590
- void buildUserMessage(out.custom, 'turn-end-strategy', 'the human typed a strategy at the turn-end card')
1591
- .then((m) => recipient.steer(m))
1592
- .catch((e) => log.warn(`the human's strategy could not be delivered: ${(e && e.message) || 'unknown'}`))
1593
- }
1594
- log.info(`turn-end card: the human TYPED a strategy (${JSON.stringify(out.custom.slice(0, 60))}) — steered into a new turn`)
1595
- } else if (/关闭周期/.test(text)) {
1596
- // THE THIRD BRANCH the human asked for ("关闭整个周期,还是继续"). This is the ending that used
1597
- // to happen automatically on every completed turn; now it is an explicit choice.
1598
- if (gate.mark('ended', { why: 'the human chose to close the cycle at the turn-end card' })) {
1599
- cu.calm().catch(() => {})
1600
- log.info(`turn-end card: the human SELECTED 关闭周期 (${JSON.stringify(text.slice(0, 40))}) — cycle closed, indicator cut`)
1601
- } else {
1602
- log.info('turn-end card: 关闭周期 was selected but the cycle was already ended — nothing to do')
1603
- }
1604
- } else if (/继续/.test(text)) {
1605
- // A REAL MESSAGE, NEVER A BARE OBJECT — this is the exact call the human's 继续 crashed on
1606
- // (measured 23:22:23, `Cannot read properties of undefined (reading 'kind')`).
1607
- if (recipient) {
1608
- void buildUserMessage('继续', 'turn-end-card', 'the human chose 继续 at the turn-end card')
1609
- .then((m) => recipient.steer(m))
1610
- .catch((e) => log.warn(`继续 could not be delivered: ${(e && e.message) || 'unknown'}`))
1611
- }
1612
- log.info(`turn-end card: the human SELECTED 继续 (${JSON.stringify(text.slice(0, 40))}) — steered, a new turn will start`)
1613
- } else if (/停/.test(text)) {
1614
- log.info(`turn-end card: the human SELECTED 停 (${JSON.stringify(text.slice(0, 40))}) — nothing steered, the cycle is left open for you`)
1615
- } else {
1616
- log.info(`turn-end card: an unrecognised selection ${JSON.stringify(text.slice(0, 40))} — nothing steered`)
1617
- }
1618
- return out
1619
- } catch { return { answered: false, reason: 'error' } }
1620
- }
1621
-
1622
1055
  // approval gate — every computer_* tool call passes through the policy
1623
1056
  // pure function; its {kind: allow|deny|ask} rides the tools/pre-execute
1624
1057
  // waterfall into the harness approval UI (fail-closed, one-shot grants).