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/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
- // ---- WHAT THE 60 s TIMER DOES WHEN NOBODY ANSWERS (2026-09-15, the human's own design words) -------
416
- // "如果60s没回复,超时之后自动进行分支判断,是否必须终结还是按照你自己的理解继续" — the timer is meant
417
- // to BRANCH, not just stop. The shipped code implemented only the stop half and said so in a comment;
418
- // the human asked why. It is a setting because the judgement is theirs to set up ONCE, rather than the
419
- // agent improvising it at every timeout.
420
- // 'stop' — the timer makes NO decision: the machine stays with the human, the cycle stays open.
421
- // 'recommended' — the timer executes the choice the CARD DECLARED as its recommendation. Note this is
422
- // a judgement about the AGENT's next turn, never about the machine: it does not close
423
- // the cycle and it does not release a human's brake.
424
- // WHETHER THERE IS A RECOMMENDATION IS NOT CONFIGURED — IT IS READ FROM THE TASK (2026-09-15).
425
- // A `turnEndAutoContinueMax` counter existed here for one revision; the human rejected it ("由且仅由问题
426
- // 任务的情形决定,而不是几次 … 不应该这样硬编码设计"). The branch is now decided per turn by whether a real
427
- // actuation reached the worker in that turn (see `turnSituation`), so there is no budget to configure.
399
+ // Compatibility with saved settings; automatic completion has no question or timer.
428
400
  turnEndTimeoutAction: Schema.union([
429
- Schema.const('stop').description('no decision — wait for the human (default)'),
430
- Schema.const('recommended').description('execute the recommendation the card declared'),
431
- ]).default('stop').description('what the 60 s turn-end timer does when nobody answers'),
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
- raiseHuman = enableHumanAttention(ctx, log)
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
- // Keep only in-flight accounting for owner lifecycle events. No idle timer: approval waits,
723
- // thinking, and pauses keep the cycle until an explicit lifecycle event ends it.
724
- // ---- WHAT THE CARD RECOMMENDS, AND WHAT THE TIMER THEREFORE DOES (2026-09-15) ---------------------
725
- // ONE place decides both, so the card's text and the timer's behaviour cannot disagree — which is the
726
- // defect the human caught: the card promised "I will not start an unattended turn" while the design
727
- // words they remembered promised a branch.
728
- //
729
- // ---- AND THE BRANCH IS DECIDED BY THE TASK SITUATION, NOT BY A COUNTER ---------------------------
730
- // The first implementation capped consecutive automatic continuations with a config number (default 2).
731
- // The human rejected that design in one sentence: "由且仅由问题任务的情形决定,而不是几次。你现在刚我说
732
- // 是两次,不应该这样硬编码设计." They are right, and the counter also solved the wrong problem: it rationed
733
- // the TIMER, when what decides the branch is whether the work is still going.
734
- //
735
- // SO THE SITUATION IS READ FROM THE TASK, ONCE PER TURN:
736
- // * `turnSituation.action = true` — an actuation really reached the worker during this turn, so the task
737
- // was mid-motion when the turn ended. There IS somewhere to continue to.
738
- // * nothing was dispatched — the turn was observation, an error, or a wrap-up. There is nothing to
739
- // continue TO, so the timer makes no decision. That is the human's own rule, not a cap.
740
- // It is written by the `onDispatch` hook (see `hooks`), which fires at the last statement before the tool
741
- // body in `tools.js` — after every refusal has thrown — so "dispatched" is a fact, not an intention.
742
- //
743
- // ---- AND IT BELONGS TO ONE SESSION, NOT TO THE MACHINE (2026-09-16) ------------------------------
744
- // `turn/start` is a HOST-WIDE event, so a turn starting in another chat would clear this value — and the
745
- // mirror case is worse: chat A drives the machine, chat B starts a turn, chat A's turn ends, and A's card
746
- // carries a recommendation read from a machine-wide flag. `owner` names the session the situation
747
- // describes, and the timer only trusts it for that session.
748
- //
749
- // DECLARED BEFORE `hooks` ON PURPOSE: `hooks.onDispatch` closes over this object, and a body that reads a
750
- // `const` from its temporal dead zone is a run-time error waiting for the first driving call.
751
- const RECOMMEND_CONTINUE = '继续(按你自己的理解接着干)'
752
- const RECOMMEND_STOP = null // "no recommendation" is the honest value when the task says nothing
753
- const turnSituation = { action: false, reason: '(no actuation reached the worker this turn)', owner: null, seq: [] }
754
- /** Does the recorded situation describe THIS session's turn? */
755
- const situationIsMine = (session) => !!turnSituation.owner && turnSituation.owner === sessionKey(session)
756
-
757
- // ---- THE STAGNATION SIGNATURE: A COMPARISON, NOT A COUNT (2026-09-16) -----------------------------
758
- // The human rejected a counter ("由且仅由问题任务的情形决定,而不是几次"). The remaining risk is a task that
759
- // drives on EVERY turn — a poll or retry loop — which no situation gate can stop, because each turn does
760
- // something. So the guard is a COMPARISON between two situations:
761
- // * `lastDrive` is the tool sequence that justified the last automatic continuation;
762
- // * if the next turn's sequence is IDENTICAL and the human has not answered in between, continuing is
763
- // not progress — the same thing was done again — so the timer stops and says so.
764
- // It accumulates nothing, hardcodes no number, and any real progress changes the sequence. Its honest
765
- // weakness: a task that legitimately repeats one identical step would stop one iteration early — which
766
- // is the conservative direction, because the card is still raised and the human decides.
767
-
768
- let lastDrive = null // { seq: string[] } — the sequence behind the last timer-driven continuation
769
- let humanAnsweredSince = false
770
- // ---- AND THE OBSERVATION-ONLY CASE (point 2, 2026-09-16) -----------------------------------------
771
- // A turn that only looked (a screenshot, a UIA read) has no dispatch, so the situation gate above said
772
- // "no recommendation" even when the work was plainly mid-task. `lastTurnDrove` carries the previous turn's
773
- // state forward for exactly one turn: an observation turn that FOLLOWS a driving turn is still mid-task,
774
- // so it may recommend once — and if the agent then keeps observing without driving, the same similarity
775
- // check stops it (the sequence would repeat).
776
- let lastTurnDrove = false
777
- const stalledNow = (session) => !!lastDrive && !humanAnsweredSince && situationIsMine(session) &&
778
- lastDrive.seq.length > 0 && lastDrive.seq.join('>') === turnSituation.seq.join('>')
779
- /** Record the situation that justified a continuation, so the NEXT identical one can be recognised. */
780
- const noteDrive = () => { lastDrive = { seq: [...turnSituation.seq] } }
781
-
782
- const recommendContinueNow = (kind, session) => {
783
- if (config.turnEndTimeoutAction !== 'recommended') return RECOMMEND_STOP
784
- if ((kind || 'completed') !== 'completed') return RECOMMEND_STOP // an errored turn is never continued
785
- if (cu.stopped || brakeLatch()) return RECOMMEND_STOP // a brake outranks a timer
786
- // A SITUATION THAT DOES NOT BELONG TO THIS TURN IS NO SITUATION: without this, an actuation in another
787
- // chat could recommend continuing in this one.
788
- if (!situationIsMine(session)) return RECOMMEND_STOP
789
- if (turnSituation.action) return continueOrStop(session)
790
- // Observation-only turn: still mid-task if the PREVIOUS turn drove, and only when that situation is not
791
- // itself a repetition of what the last automatic continuation already acted on.
792
- if (lastTurnDrove && turnSituation.seq.length > 0) return continueOrStop(session)
793
- return RECOMMEND_STOP
794
- }
795
- /**
796
- * The ONE mapping from "there is work to continue" to the recommendation. The polarity lives here and
797
- * nowhere else: an inverted ternary at a call site would continue exactly when the task did nothing, and
798
- * the reviewer showed that regression can keep every other assertion green.
799
- */
800
- const continueOrStop = (session) => (stalledNow(session) ? RECOMMEND_STOP : RECOMMEND_CONTINUE)
801
- /** Why the recommendation came out the way it did — for the log, which must not have to guess. */
802
- const recommendationReason = (session) => {
803
- if (stalledNow(session)) return '(the same sequence of tools was already continued for — repeating it is not progress)'
804
- if (turnSituation.action) return turnSituation.reason
805
- if (lastTurnDrove && turnSituation.seq.length > 0) return `(the previous turn drove the machine and this one only observed: ${turnSituation.seq.join('>')})`
806
- return turnSituation.reason
807
- }
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
- // ---- THE TASK SITUATION IS WRITTEN AT THE DISPATCH DOOR, NOT AT THE POLICY DOOR (2026-09-16) -----
811
- // `tools/pre-execute` runs BEFORE every refusal — the ownership verdict, the voided opening, the
812
- // cancellation, and the policy/brake gates inside `reg`. Marking the situation there meant a call that
813
- // was denied or refused could claim the task was in motion (and, worse in the other direction, a
814
- // HIGH-RISK actuation the human had just APPROVED looks like `kind: 'ask'` at the policy door, so the
815
- // timer would silently never recommend exactly when a dangerous action had just been authorised).
816
- // `tools.js` calls this immediately before `inner(args, exec)` — the last statement before the tool
817
- // body, after every refusal has already thrown. So this is the one place where "it was dispatched" is
818
- // a fact rather than an intention, which is what the recommendation is supposed to rest on.
819
- onDispatch: (exec) => {
820
- try {
821
- if (!exec || typeof exec.name !== 'string' || !exec.name.startsWith('computer_')) return
822
- // ---- EVERY computer_* DISPATCH IS RECORDED, AND THIS LINE WAS MISSING (defect, 2026-09-16) -----
823
- // The comment that used to sit here claimed "observation calls are recorded too". The code did the
824
- // opposite: it returned early for a non-driving call, so `seq` held DRIVING calls only. Two
825
- // consequences, both silent:
826
- // * the observation-only rule in `recommendContinueNow` (`lastTurnDrove && seq.length > 0`) could
827
- // never fire — `seq` was empty by construction on exactly the turn it was written for, so the
828
- // feature was dead code that read as working;
829
- // * `seq` could not serve as "this turn touched the machine at all", which is the fact the
830
- // turn-end gate now needs (a billed, immediately-failing turn must not raise a card).
831
- // `seq` is the turn's SIGNATURE (which tools ran, in order); `action` stays the narrower claim
832
- // (something was ACTUATED) because the recommendation's honesty rests on that distinction.
833
- turnSituation.seq.push(`${exec.name}`)
834
- if (!isDrivingCall(exec.name, exec.arguments || {})) return
835
- turnSituation.action = true
836
- turnSituation.reason = `(${exec.name} actuated the machine during this turn)`
837
- } catch { /* a recommendation must never break a dispatch */ }
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
- cu.calm().catch(() => {})
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
- gate.mark('ended', { why: 'owner agent idle', session: gate.ownerSessionKey(), agent: payload && payload.agent })
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
- gate.release('owner agent disposed')
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
- if (!cu.stopped) {
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
- // ---- THE TURN ENDS; THE DECISION ABOUT THE CYCLE BELONGS TO THE CARD (2026-09-15) -----------
1286
- // This branch used to CLOSE the cycle first (`gate.mark('ended')`) and only then raise the card.
1287
- // `'ended'` is irreversible, so the FIRST turn end that reached here ended the cycle — and from
1288
- // the second turn onward `gate.ownsSession(...)` was false, the whole block was skipped, and the
1289
- // card could never fire again. Measured: one card at 22:30, then nothing for five turns while
1290
- // the log filled with `agent/status idle ignored (another agent)`. The human's report was exactly
1291
- // that: "我还是没亲眼看到触发".
1292
- //
1293
- // The human chose 甲: a completed turn NO LONGER closes the cycle on its own. The card decides,
1294
- // and "关闭周期" is one of its branches — which is also what they asked for in words ("你决定之后
1295
- // 再说,关闭整个周期,还是继续"). The old worry that motivated auto-closing ("不能让正常完成一律保留成
1296
- // 永远活着") is now handled by the card: an explicit branch, or the timeout default, ends it.
1297
- // A pause/ask still keeps the cycle alive for Ctrl+Alt+R, unchanged.
1298
- // ---- A TURN THAT NEVER TOUCHED THE MACHINE IS NOT A COMPUTER-USE SUSPENSION (defect, 2026-09-16) --
1299
- // THE DEATH LOOP THIS CLOSES, in the human's own words: "你卡进死循环了 不断弹卡片". Measured in
1300
- // `turn-end-diag.log`: eleven cards in two and a half minutes (02:38:03 → 02:40:40), every one of them
1301
- // `kind=error`, and the session transcript says why —
1302
- // turn 146…156 reason={"kind":"error","error":{"message":"Insufficient Balance","code":"QUOTA"}}
1303
- // The account was out of credit, so EVERY turn died at step 1. Each death ended a turn, the turn-end
1304
- // gate asked for a decision about a MACHINE THAT HAD NOT BEEN TOUCHED, the human answered, and their
1305
- // answer started another turn that died the same way. The card was not wrong about the machine; it was
1306
- // answering a question nobody had asked, about a machine that was idle, forever.
1307
- // The honest predicate is already on hand: `turnSituation.seq` is the turn's own dispatch signature
1308
- // (every `computer_*` call the turn actually dispatched — see `hooks.onDispatch`). Empty means this
1309
- // turn never reached the machine, so there is no cycle decision to make and the card must not fire. A
1310
- // billing error, a provider outage or a chat-only turn is reported by the host's own "本轮运行失败";
1311
- // dressing it as a machine decision is what produced the loop.
1312
- // It also keeps the human's standing rule intact: "一旦挂起,任何挂起,都必须进入卡片" — a suspension
1313
- // OF THE MACHINE still always gets its card, and now a turn that suspended nothing no longer fakes one.
1314
- //
1315
- // THE RESIDUAL RISK, STATED RATHER THAN HIDDEN: `turnSituation` is ONE slot and `turn/start` is a
1316
- // host-wide event, so if another session (a background subagent, another chat) starts a turn between
1317
- // this turn's last dispatch and its end, that start resets `seq` and this gate then suppresses a card
1318
- // the owner had earned. The failure direction is the safe one — no card, the cycle stays open, and the
1319
- // machine was never touched by anyone in the meantime — and `recommendContinueNow` has depended on the
1320
- // same single slot since it was written. Making it exact means keying the situation BY SESSION, which
1321
- // is a change to the recommendation's own input and is not something to smuggle in behind a bug fix.
1322
- const touched = situationIsMine(session) && turnSituation.seq.length > 0
1323
- if (gate.ownsSession(sessionKey(session)) &&
1324
- gate.state() !== 'paused' && gate.state() !== 'asking' &&
1325
- !cu.stopped && !brakeLatch() && touched) {
1326
- diagTurnEnd(`FIRED kind=${kind || 'completed'} asker=${!!asker} raiseHuman=${!!raiseHuman} ` +
1327
- `seq=${turnSituation.seq.length} session=${String(sessionKey(session)).slice(0, 12)}`)
1328
- // ---- A COMPUTER-USE TURN MUST NOT END SILENTLY (2026-09-15, the human's standing rule) ----
1329
- // "一旦挂起,任何挂起,都必须进入卡片… 如果60s没回复,超时之后自动进行分支判断". Until now a
1330
- // turn end produced a taskbar toast and nothing else: the design's RAISE + CARD + 60 s branch
1331
- // was never implemented on this path. The 60 s clock that DID exist lived in the worker's
1332
- // `computer_ask` (`worker.cs:5430`) and was removed, correctly, by the ask merge — and the
1333
- // plugin-side branch was never added back.
1334
- //
1335
- // WHY A DETOUR RATHER THAN A CANCELLABLE CARD: the harness has no way to withdraw a pending
1336
- // card (checked during the merge — it ends by being answered, dismissed, or by the turn being
1337
- // torndown). So the card is NOT cancelled. A local timer simply stops waiting on it, which is
1338
- // the same thing the human asked for and needs no host API. The card may linger in the chat;
1339
- // answering it later changes nothing, because the race below has already settled.
1340
- //
1341
- // AUTO-DECISION: on timeout the agent DOES NOT continue driving on its own. A turn that ended
1342
- // by itself leaves the machine free and the human absent — starting a new turn unattended is
1343
- // the one outcome that cannot be undone. The human's own words allow either branch ("是否必须
1344
- // 终结还是按照你自己的理解继续"); stopping is the reversible one.
1345
- void askAtTurnEnd(session, kind)
1346
- } else {
1347
- // THE ANSWER THAT ACTUALLY NEEDS A REASON. Without this line "the card did not fire" has no
1348
- // explanation at all, which is exactly where this feature was stuck.
1349
- diagTurnEnd(`SKIPPED kind=${kind || 'completed'} state=${gate.state()} ` +
1350
- `owns=${gate.ownsSession(sessionKey(session))} stopped=${!!cu.stopped} latch=${!!brakeLatch()} ` +
1351
- `touched=${touched} seq=${turnSituation.seq.length} ` +
1352
- `session=${String(sessionKey(session)).slice(0, 12)}`)
1353
- }
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).