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.
@@ -0,0 +1,66 @@
1
+ // Finishing desktop work releases the machine without cancelling the conversation.
2
+ export function createCycleLifecycle ({ gate, cu, claimOf, owner, paused, inFlight, ownershipOf = () => null }) {
3
+ const refusal = reason => ({ released: false, reason, conversationContinues: true })
4
+ let pendingCompletion = null
5
+
6
+ async function release (exec, why = 'desktop work finished', automatic = false, expected) {
7
+ if (!gate.isLive()) return { released: false, alreadyReleased: true, conversationContinues: true }
8
+ const ownership = expected || ownershipOf(exec) || { claim: claimOf(exec), cycleId: gate.snapshot().id }
9
+ const { claim, cycleId } = ownership
10
+ gate.assertOwnership(claim, cycleId)
11
+ if (paused() || cu.exited || cu.cooperation?.active) return refusal('a manual pause, question, exit, or active human handoff still owns this cycle')
12
+ if (inFlight() > (automatic ? 0 : 1)) return refusal('computer-use calls are still in flight')
13
+ const token = gate.beginCompletion(claim, cycleId)
14
+ try {
15
+ const result = await cu.calm({ beforeDispatch: () => {
16
+ const current = gate.snapshot()
17
+ if (current.id !== token.cycleId || current.state !== 'releasing' || current.ownerIdent !== token.ident ||
18
+ current.exitVersion !== token.exitVersion) {
19
+ throw Object.assign(new Error('COMPUTER_OWNER_CHANGED: this cleanup no longer owns the releasing cycle; no cleanup was dispatched.'),
20
+ { code: 'COMPUTER_OWNER_CHANGED', outcome: 'not-dispatched' })
21
+ }
22
+ } })
23
+ if (result?.released === false || paused() || cu.exited) {
24
+ gate.cancelCompletion(token)
25
+ return refusal(result?.reason || 'a newer pause or exit superseded completion')
26
+ }
27
+ const released = gate.finishCompletion(token)
28
+ return { released, ...(released ? {} : { reason: 'a newer cycle state superseded completion' }),
29
+ conversationContinues: true, cycleEnded: released,
30
+ message: released ? 'Computer-use control and monitoring released. Continue the conversation normally; a later required desktop call may acquire a fresh cycle.' : 'The current cycle state remains authoritative.' }
31
+ } catch (error) {
32
+ gate.cancelCompletion(token)
33
+ throw error
34
+ }
35
+ }
36
+
37
+ function settle () {
38
+ if (!pendingCompletion || inFlight() !== 0) return null
39
+ const pending = pendingCompletion
40
+ try { gate.assertOwnership(pending.ownership.claim, pending.ownership.cycleId) }
41
+ catch { pendingCompletion = null; return null } // Old events cannot close a newer turn or cycle.
42
+ if (paused() || cu.exited) { pendingCompletion = null; return null }
43
+ if (cu.cooperation?.active) return null // Handback will drain this same captured completion.
44
+ pendingCompletion = null
45
+ return release(pending.exec, pending.why, true, pending.ownership)
46
+ }
47
+
48
+ function requestCompletion (exec, why) {
49
+ if (!exec?.agent || paused() || !gate.isLive() || gate.state() !== 'open') return null
50
+ pendingCompletion = { exec, why, ownership: { claim: claimOf(exec), cycleId: gate.snapshot().id } }
51
+ return settle()
52
+ }
53
+
54
+ function completeTurn (session, kind = 'completed') {
55
+ if (kind === 'aborted' || kind === 'interrupted' || !gate.ownsSession(claimOf({ session }).sessionKey)) return null
56
+ const exec = owner()
57
+ return requestCompletion(exec, `owner turn ${kind}`)
58
+ }
59
+
60
+ function idle (agent) {
61
+ if (!agent || !gate.ownsAgent(claimOf({ agent }).agentKey)) return null
62
+ return requestCompletion(owner(), 'owner finished desktop work')
63
+ }
64
+
65
+ return { release, completeTurn, idle, settle }
66
+ }
package/lib/cycle.js CHANGED
@@ -16,9 +16,10 @@
16
16
  // cannot open a cycle at all. The attempt itself is ONE shared settled-once promise per
17
17
  // qualification, so concurrent calls in a turn cannot slip an op into the worker's stdin ahead
18
18
  // of the `resume` that opens the cycle (build/test-cycle-gate.mjs measures that ordering).
19
- // * `ended` is terminal for that id. A new cycle needs a NEW qualification (a later turn's first
20
- // real call) and gets a NEW id. Nothing about Ctrl+Alt+R, a mouse event or a late callback can
21
- // revive an ended cycle.
19
+ // * `ended` is terminal for that id. A new cycle needs a NEW qualification and gets a NEW id.
20
+ // A later turn qualifies naturally; explicit successful owner completion can also requalify
21
+ // that owner in the same turn. Ctrl+Alt+Q stays terminal for its turn. Neither a mouse event
22
+ // nor a late callback can revive an ended cycle.
22
23
  //
23
24
  // NO MEMORY BOUND LIVES IN THIS FILE, AND THAT IS DELIBERATE (#5, 2026-09-13). A cap that evicts a
24
25
  // consumed qualification is not a memory policy, it is a RE-AUTHORIZATION channel: after 64
@@ -55,7 +56,7 @@ export function createCycleGate ({
55
56
  } = {}) {
56
57
  let nextId = 0
57
58
  let exitVersion = 0 // bumped by every `ended`: a pending open compares it before/after its await
58
- /** @type {{id:number, ownerIdent:string|null, ownerLabel:string, ownerSessionKey:string|null, ownerAgentKey:string|null, state:'open'|'paused'|'asking'|'ended', openedAt:number, lastAt:number, endedWhy:string}|null} */
59
+ /** @type {{id:number, ownerIdent:string|null, ownerLabel:string, ownerSessionKey:string|null, ownerAgentKey:string|null, state:'open'|'paused'|'asking'|'releasing'|'ended', openedAt:number, lastAt:number, endedWhy:string}|null} */
59
60
  let cycle = null
60
61
  /** qualification PER IDENTITY: ident -> {turn, promise, dead}. One entry per identity, never evicted. */
61
62
  const idents = new Map()
@@ -64,18 +65,29 @@ export function createCycleGate ({
64
65
  /** ident -> the turn generation in which an exit was seen: that turn may not open a cycle */
65
66
  const endedTurns = new Map()
66
67
  const events = [] // bounded transition log for the guards and diagnostics
68
+ const objectIds = new WeakMap()
69
+ let nextObjectId = 0
70
+ const identityKey = (value, preferred, kind) => {
71
+ const key = preferred(value)
72
+ if (key) return String(key)
73
+ if (value && (typeof value === 'object' || typeof value === 'function')) {
74
+ if (!objectIds.has(value)) objectIds.set(value, `${kind}-object-${++nextObjectId}`)
75
+ return objectIds.get(value)
76
+ }
77
+ return null
78
+ }
67
79
 
68
80
  const note = (what, detail) => {
69
81
  events.push({ at: now(), what, ...(detail || {}) })
70
82
  if (events.length > 96) events.shift()
71
83
  }
72
84
 
73
- const identOf = (sessionKeyVal, agentKeyVal) => `${sessionKeyVal || '-'}|${agentKeyVal || '-'}`
85
+ const identOf = (sessionKeyVal, agentKeyVal) => JSON.stringify([sessionKeyVal, agentKeyVal])
74
86
 
75
87
  /** A claim is what the CALL brings: whose call it is, and which of that session's turns it is in. */
76
88
  function claim ({ session, agent } = {}) {
77
- const sk = sessionKey(session)
78
- const ak = agentKey(agent)
89
+ const sk = identityKey(session, sessionKey, 'session')
90
+ const ak = identityKey(agent, agentKey, 'agent')
79
91
  const turn = (sk && turns.get(sk)) || 0
80
92
  const ident = identOf(sk, ak)
81
93
  return {
@@ -91,7 +103,7 @@ export function createCycleGate ({
91
103
 
92
104
  /** Renewed by THAT session's turn/start only. Another session's event must not touch it (B). */
93
105
  function noteTurnStart (session) {
94
- const sk = sessionKey(session)
106
+ const sk = identityKey(session, sessionKey, 'session')
95
107
  if (!sk) return 0
96
108
  const n = (turns.get(sk) || 0) + 1
97
109
  turns.set(sk, n)
@@ -131,6 +143,13 @@ export function createCycleGate ({
131
143
  * not a warning — lib/tools.js rethrows it so the call fails loudly instead of driving anyway.
132
144
  */
133
145
  function open (cl, openFn) {
146
+ if (!cl.sessionKey && !cl.agentKey) {
147
+ const err = new Error('COMPUTER_OWNER_REQUIRED: computer-use requires a stable session or agent identity; no operation was dispatched.')
148
+ err.dshCycleRefusal = true
149
+ err.code = 'COMPUTER_OWNER_REQUIRED'
150
+ err.outcome = 'not-dispatched'
151
+ throw err
152
+ }
134
153
  // A live cycle with NO owner is a pause/ask that arrived before any call claimed the machine.
135
154
  // Ownership is free: the next real call adopts it. Only a cycle owned by a DIFFERENT session is
136
155
  // a refusal.
@@ -145,11 +164,16 @@ export function createCycleGate ({
145
164
  const err = new Error(
146
165
  `this machine is owned by another computer-use cycle (owner ${cycle.ownerLabel}, cycle ` +
147
166
  `#${cycle.id}, state ${cycle.state}) — a session that is not the owner must not drive it. ` +
148
- 'Ask the human to end that session (Ctrl+Alt+Q) or wait for its turn to finish, then retry.')
167
+ 'Keep this task pending until the owner finishes or explicitly releases computer use, then retry.')
149
168
  err.dshCycleRefusal = true
150
169
  note('refused-foreign', { owner: cycle.ownerLabel, id: cycle.id, by: cl.label })
151
170
  throw err
152
171
  }
172
+ if (isLive(cycle) && (cycle.state === 'releasing' || cycle.terminating)) {
173
+ const err = new Error('COMPUTER_CYCLE_RELEASING: the owning agent is releasing computer use. Wait for release to finish before starting a fresh cycle.')
174
+ err.dshCycleRefusal = true; err.code = 'COMPUTER_CYCLE_RELEASING'; err.outcome = 'not-dispatched'
175
+ throw err
176
+ }
153
177
 
154
178
  // ONE qualification per (session, agent, turn), kept per identity and NEVER evicted.
155
179
  let e = idents.get(cl.ident)
@@ -278,6 +302,72 @@ export function createCycleGate ({
278
302
  claim,
279
303
  noteTurnStart,
280
304
  open,
305
+ // Recheck the original call's claim after every async wait. Re-deriving it
306
+ // would silently give an old tool the identity of a newer turn.
307
+ assertOwnership (cl, expectedCycleId) {
308
+ const currentTurn = (cl.sessionKey && turns.get(cl.sessionKey)) || 0
309
+ if (!isLive(cycle) || cycle.state === 'releasing' || cycle.terminating || !ownedBy(cycle, cl) || cycle.id !== expectedCycleId ||
310
+ cl.turn !== currentTurn || idents.get(cl.ident)?.dead) {
311
+ const err = new Error('COMPUTER_OWNER_CHANGED: this call no longer owns the active computer-use cycle. No further input was dispatched; only the current owner may continue.')
312
+ err.dshCycleRefusal = true
313
+ err.code = 'COMPUTER_OWNER_CHANGED'
314
+ err.outcome = 'not-dispatched'
315
+ throw err
316
+ }
317
+ return true
318
+ },
319
+ // Complete the machine cycle without ending the chat. The intermediate state
320
+ // closes the async gap while the native worker releases its lease.
321
+ beginCompletion (cl, expectedCycleId) {
322
+ this.assertOwnership(cl, expectedCycleId)
323
+ if (cycle.state !== 'open') {
324
+ const err = new Error('COMPUTER_CYCLE_PAUSED: a human pause or active question keeps computer-use ownership; resume it before voluntarily releasing the cycle.')
325
+ err.dshCycleRefusal = true; err.code = 'COMPUTER_CYCLE_PAUSED'; err.outcome = 'not-dispatched'
326
+ throw err
327
+ }
328
+ const token = { cycleId: cycle.id, ident: cl.ident, turn: cl.turn, exitVersion }
329
+ cycle.completion = token
330
+ cycle.state = 'releasing'
331
+ note('completion-started', { id: cycle.id, owner: cl.label })
332
+ return token
333
+ },
334
+ // Host cancellation/disposal ends this turn's right to input immediately,
335
+ // but holds ownership until native cleanup finishes. A manual pause keeps its
336
+ // latch; after R the deferred terminal cleanup runs without waking that task.
337
+ beginTermination (cl, expectedCycleId, why, hold = false) {
338
+ if (!isLive(cycle) || !ownedBy(cycle, cl) || cycle.id !== expectedCycleId) return null
339
+ cycle.terminating = true
340
+ rememberEndedTurn(cl)
341
+ if (hold || cycle.state === 'paused' || cycle.state === 'asking') return { deferred: true }
342
+ if (cycle.state === 'releasing' && cycle.completion) {
343
+ cycle.completion.terminal = true; cycle.completion.why = why
344
+ return { joined: true, token: cycle.completion }
345
+ }
346
+ const token = { cycleId: cycle.id, ident: cl.ident, turn: cl.turn, exitVersion, terminal: true, why }
347
+ cycle.completion = token; cycle.state = 'releasing'
348
+ note('termination-started', { id: cycle.id, owner: cl.label, why })
349
+ return { token }
350
+ },
351
+ finishCompletion (token) {
352
+ if (!cycle || cycle.completion !== token || cycle.state !== 'releasing' ||
353
+ cycle.id !== token.cycleId || exitVersion !== token.exitVersion) return false
354
+ cycle.state = 'ended'
355
+ cycle.endedWhy = token.terminal ? token.why : 'owner completed computer use; chat remains active'
356
+ cycle.lastAt = now()
357
+ cycle.completion = null
358
+ exitVersion++
359
+ const e = idents.get(token.ident)
360
+ if (!token.terminal && e && !e.dead && e.turn === token.turn) e.promise = null
361
+ note('completed', { id: cycle.id, owner: cycle.ownerLabel })
362
+ return true
363
+ },
364
+ cancelCompletion (token) {
365
+ if (!cycle || cycle.completion !== token || cycle.state !== 'releasing' ||
366
+ cycle.id !== token.cycleId || exitVersion !== token.exitVersion) return false
367
+ cycle.state = 'open'; cycle.completion = null
368
+ note('completion-failed', { id: cycle.id })
369
+ return true
370
+ },
281
371
  mark,
282
372
  forgetIdentity,
283
373
  snapshot: () => ({