@gotcos/glasses-server 6.48.0 → 6.48.2

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,153 @@
1
+ // The Stop-driven drain kick (6.48.1).
2
+ //
3
+ // Before this, a queued follow-up waited for the 20 s sweep AND the holder's 30 s idle
4
+ // window: measured 35 s or more from the turn's end to delivery. The hooks now say the
5
+ // exact moment a turn ends (Stop), so the queue is drained THEN.
6
+ //
7
+ // ONE DRAIN IN FLIGHT, EXACTLY ONE FOLLOW-UP. The 20 s timer and the Stop kick share
8
+ // this guard, so a Stop landing during a sweep cannot start a second sweep that reads
9
+ // the same queue file and delivers the same sentence twice; it schedules one more pass
10
+ // after the current one, and a burst of Stops collapses into that one pass.
11
+ //
12
+ // A kick is spent only when a queue file exists for that session: a Stop on a session
13
+ // nobody queued for costs a directory listing and nothing else. The match is the full
14
+ // session id: a Claude queue file is always keyed by the full UUID, because the attach
15
+ // gate refuses anything else as `invalid_thread_id` before a turn can be queued, and
16
+ // every hook envelope carries the full id (QA, 2026-09-15: the 8-character branch this
17
+ // used to carry was unreachable).
18
+ //
19
+ // The sweep itself runs on the NEXT macrotask, never inside the caller: a kick arrives
20
+ // from the signal store inside the spool sweep, and the drain's synchronous head (a
21
+ // registry scan, a `ps` per owner, a tail read) belongs after that sweep and after the
22
+ // stream listeners for the same Stop have written their line.
23
+
24
+ export interface DrainKickDeps {
25
+ /** The sweep. Resolves the number delivered; rejects are swallowed here. */
26
+ drain: () => Promise<number>
27
+ /** Thread ids with a queue file right now, any provider. */
28
+ queuedThreadIds: () => string[]
29
+ }
30
+
31
+ export interface DrainKick {
32
+ /** Run the sweep, or fold into the one in flight. Resolves when THIS request's pass has run. */
33
+ sweep(): Promise<void>
34
+ /** A session's turn ended: sweep (next macrotask) if a queue names it. Returns true when a sweep was requested. */
35
+ kick(sessionId: string): boolean
36
+ /**
37
+ * Kick once `ready()` answers true, polling every `everyMs` for up to `maxMs`; a kick
38
+ * at Stop time is too early when the engine closes the turn only after its Stop hooks
39
+ * (12-38 s on the release Mac), and nothing fires a hook when they return. Returns
40
+ * false at once when no queue names the session, so an unqueued Stop costs nothing.
41
+ */
42
+ kickWhen(sessionId: string, ready: () => boolean, opts?: { everyMs?: number; maxMs?: number }): boolean
43
+ /** For health: passes run, kicks spent, kicks folded into a running pass, waits open. */
44
+ stats(): { passes: number; kicks: number; folded: number; inFlight: boolean; waiting: number }
45
+ }
46
+
47
+ export const KICK_POLL_MS = 1_000
48
+ export const KICK_WAIT_MAX_MS = 60_000
49
+
50
+ /** The facts the kick plan reads off a session signal after one hook event. */
51
+ export interface KickSignalFacts {
52
+ turnOpen: boolean
53
+ subagentsOpen: number
54
+ ended: unknown
55
+ stopAt: number | null
56
+ }
57
+
58
+ /** What one hook event asks of the kick: nothing, a kick now, or a kick once the registry says idle after `stopAt`. */
59
+ export type KickPlan = { kind: 'kick' } | { kind: 'kick_when_registry_idle'; stopAt: number } | null
60
+
61
+ /**
62
+ * The composition root's rule, pure so it is pinned (QA N1, 2026-09-15):
63
+ *
64
+ * - a COS child's own SessionEnd frees the thread for the NEXT queued turn at once
65
+ * (without it a chained follow-up waited for the 20 s timer); nothing else a child
66
+ * does kicks, since its Stop is the turn this server is delivering;
67
+ * - the tab's Stop kicks once the registry flips idle after it (the Stop hooks have
68
+ * returned); a Stop with the turn still open, a sub-agent still open, or the session
69
+ * ended asks nothing;
70
+ * - a SubagentStop never kicks: a background sub-agent finishing after the Stop is
71
+ * answered by the model in a turn of its own, whose Stop kicks, and neither the hook
72
+ * short-circuit nor the B6 clause vouches for a session whose newest event is a
73
+ * SubagentStop, so a kick there could only fall through to the 30 s backstop.
74
+ */
75
+ export function kickPlanFor(signal: KickSignalFacts, event: string, eventTs: number, child: boolean): KickPlan {
76
+ if (child) return event === 'SessionEnd' ? { kind: 'kick' } : null
77
+ if (event !== 'Stop') return null
78
+ if (signal.turnOpen || signal.subagentsOpen > 0 || signal.ended) return null
79
+ return { kind: 'kick_when_registry_idle', stopAt: typeof signal.stopAt === 'number' ? signal.stopAt : eventTs }
80
+ }
81
+
82
+ export function createDrainKick(deps: DrainKickDeps): DrainKick {
83
+ let inFlight: Promise<void> | null = null
84
+ let followUp: Promise<void> | null = null
85
+ const stats = { passes: 0, kicks: 0, folded: 0 }
86
+
87
+ const runOnce = async (): Promise<void> => {
88
+ stats.passes++
89
+ try { await deps.drain() } catch { /* the next pass retries; the sweep logs its own errors */ }
90
+ }
91
+
92
+ const sweep = (): Promise<void> => {
93
+ if (!inFlight) {
94
+ inFlight = runOnce().finally(() => { inFlight = null })
95
+ return inFlight
96
+ }
97
+ // Exactly one follow-up pass, however many requests arrive while the first runs.
98
+ if (!followUp) {
99
+ stats.folded++
100
+ const current = inFlight
101
+ followUp = current.then(() => { followUp = null; return sweep() })
102
+ } else {
103
+ stats.folded++
104
+ }
105
+ return followUp
106
+ }
107
+
108
+ const queueNames = (sessionId: string): boolean => {
109
+ const id = sessionId.toLowerCase()
110
+ return deps.queuedThreadIds().some(t => t.toLowerCase() === id)
111
+ }
112
+
113
+ const waits = new Map<string, ReturnType<typeof setInterval>>()
114
+
115
+ const kick = (sessionId: string): boolean => {
116
+ if (!queueNames(sessionId)) return false
117
+ stats.kicks++
118
+ setImmediate(() => { void sweep() })
119
+ return true
120
+ }
121
+
122
+ return {
123
+ sweep,
124
+ kick,
125
+ kickWhen(sessionId: string, ready: () => boolean, opts = {}): boolean {
126
+ if (!queueNames(sessionId)) return false
127
+ const key = sessionId.toLowerCase()
128
+ const existing = waits.get(key)
129
+ if (existing) { clearInterval(existing); waits.delete(key) }
130
+ let readyNow = false
131
+ try { readyNow = ready() } catch { readyNow = false }
132
+ if (readyNow) return kick(sessionId)
133
+ const everyMs = opts.everyMs ?? KICK_POLL_MS
134
+ const maxMs = opts.maxMs ?? KICK_WAIT_MAX_MS
135
+ const startedAt = Date.now()
136
+ const timer = setInterval(() => {
137
+ let ok = false
138
+ try { ok = ready() } catch { ok = false }
139
+ if (ok || Date.now() - startedAt >= maxMs) {
140
+ clearInterval(timer)
141
+ waits.delete(key)
142
+ // Past the wait the sweep still runs: the gate decides, and the 20 s timer would
143
+ // have anyway. Nothing here can deliver; only the gate can.
144
+ kick(sessionId)
145
+ }
146
+ }, everyMs)
147
+ timer.unref?.()
148
+ waits.set(key, timer)
149
+ return true
150
+ },
151
+ stats: () => ({ ...stats, inFlight: inFlight !== null, waiting: waits.size }),
152
+ }
153
+ }
@@ -150,6 +150,23 @@ export interface OccupancyProbes {
150
150
  * safety property here.
151
151
  */
152
152
  transcriptMtimeMs?: (provider: OccupancyProvider, threadId: string) => number | null
153
+ /**
154
+ * THE B6 CLAUSE (6.48.1). Epoch ms of the holder's newest hook `Stop`, ONLY when the
155
+ * engine itself has closed the turn: that Stop is the session's newest hook event, no
156
+ * sub-agent is open, the session has not ended, AND the registry record says `idle`
157
+ * with `statusUpdatedAt` at or after the Stop (Claude flips it only when every Stop
158
+ * hook has returned, 12-38 s after the Stop on the release Mac, which is when a prompt
159
+ * queued at the desk would dequeue). Null otherwise, and always null for Codex.
160
+ * OPTIONAL, and its absence is the default: attached by `withHookTurnClock` at the
161
+ * composition root only when `COS_SESSION_HOOKS` is on, so the pure gate never reads
162
+ * the environment.
163
+ *
164
+ * What it buys: a foreign Desktop holder whose turn the engine has closed reads `idle`
165
+ * at once instead of after the 30 s transcript window. The transcript's mtime is not
166
+ * consulted: the bookkeeping rows Claude writes as the hooks finish are exactly what
167
+ * makes the file look busy at the moment it is safest to attach.
168
+ */
169
+ holderTurnEndedAtMs?: (provider: OccupancyProvider, threadId: string) => number | null
153
170
  /**
154
171
  * Cursor Agent CLI session at `~/.cursor/chats/<hash>/<id>/`, or null when
155
172
  * that id is not exactly one continuable chats dir. Occupancy MUST NOT import
@@ -237,6 +254,7 @@ export function isActiveRecently(mtimeMs: number | null | undefined, nowMs: numb
237
254
  */
238
255
  export type HolderActivity = 'working' | 'idle' | 'unknown'
239
256
 
257
+
240
258
  export function holderActivity(mtimeMs: number | null | undefined, nowMs: number): HolderActivity {
241
259
  if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return 'unknown'
242
260
  if (!Number.isFinite(nowMs)) return 'unknown'
@@ -261,11 +279,28 @@ function readHolderActivity(
261
279
  nowMs: number,
262
280
  ): HolderActivity {
263
281
  if (typeof probes.transcriptMtimeMs !== 'function') return 'unknown'
282
+ let mtime: number | null
264
283
  try {
265
- return holderActivity(probes.transcriptMtimeMs(provider, threadId), nowMs)
284
+ mtime = probes.transcriptMtimeMs(provider, threadId)
266
285
  } catch {
267
286
  return 'unknown'
268
287
  }
288
+ const activity = holderActivity(mtime, nowMs)
289
+ if (activity !== 'working') return activity
290
+ // THE B6 CLAUSE. Only a `working` verdict is revisited, only for Claude, only with a
291
+ // real clock behind it, and only when the hook probe VOUCHES that the engine closed
292
+ // the turn (null is strict). `unknown` stays unknown: the clause narrows a refusal,
293
+ // never a doubt.
294
+ if (provider !== 'claude' || typeof probes.holderTurnEndedAtMs !== 'function' || typeof mtime !== 'number') return activity
295
+ let stopAt: number | null
296
+ try {
297
+ stopAt = probes.holderTurnEndedAtMs(provider, threadId)
298
+ } catch {
299
+ return activity
300
+ }
301
+ if (typeof stopAt !== 'number' || !Number.isFinite(stopAt)) return activity
302
+ if (stopAt > nowMs + ACTIVE_RECENTLY_WINDOW_MS) return activity
303
+ return 'idle'
269
304
  }
270
305
 
271
306
  /** A Claude registry filename is exactly `<pid>.json`. Not `*.json`. */
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { request } from 'node:http'
28
28
  import type { QueuedThreadTurn } from './thread-turn-queue.js'
29
+ import { targetKey } from './agent-session-binding-store.js'
29
30
 
30
31
  /** Per-request ceiling. Attach and turn both answer immediately; the turn route
31
32
  * admits with 202 and does the long work in the background. */
@@ -97,16 +98,26 @@ export async function deliverQueuedTurnOverLoopback(
97
98
  }
98
99
  const bindingId = typeof attach.body.bindingId === 'string' ? attach.body.bindingId : ''
99
100
  if (!bindingId) return { ok: false, reason: 'attach_no_binding' }
100
-
101
+ // 6.48.1: the turns route has required `epoch` and `targetKey` since the binding
102
+ // hardening, and refused this body as `invalid_request` (retryable, so the turn was
103
+ // refunded and held forever: every drained follow-up since then never landed; found by
104
+ // the 6.48.1 live proof, 2026-09-15). The epoch is the one the attach just minted and
105
+ // the target key is the server's own function of (provider, native id), exactly what
106
+ // the phone sends.
107
+ const epoch = typeof attach.body.epoch === 'number' && Number.isInteger(attach.body.epoch) && attach.body.epoch >= 1 ? attach.body.epoch : null
108
+ if (epoch === null) return { ok: false, reason: 'attach_no_epoch' }
109
+ const boundTo = typeof attach.body.boundTo === 'string' ? attach.body.boundTo : undefined
101
110
  const sent = await post(
102
111
  port, token,
103
112
  `/api/agent-sessions/bindings/${encodeURIComponent(bindingId)}/turns`,
104
- // `clientTurnId` is carried through unchanged so the turn route's own
105
- // idempotency ledger recognises a re-delivery of the SAME turn. Without it a
106
- // retry after an ambiguous response would put the sentence in twice.
107
- { clientTurnId: turn.clientTurnId, prompt: turn.prompt },
113
+ {
114
+ clientTurnId: turn.clientTurnId,
115
+ prompt: turn.prompt,
116
+ epoch,
117
+ targetKey: targetKey(turn.provider, turn.threadId),
118
+ ...(boundTo ? { boundTo } : {}),
119
+ },
108
120
  )
109
- // 202 is the success shape: admitted, delivered in the background, poll the ledger.
110
121
  if (sent.status === 202 || sent.status === 200) return { ok: true }
111
122
  // Same defect on the turn leg: `refuseTurn` emits `reason`/`reasonCopy`/`retryable`
112
123
  // and no `error`. `retryable` is the server's OWN judgement about this refusal --
@@ -13,11 +13,17 @@ import { closeSync, constants, existsSync, fstatSync, mkdirSync, openSync, readd
13
13
  import { join } from 'node:path'
14
14
  import { atomicWriteFileSync } from './atomic-fs.js'
15
15
  import { dataPath } from './data-dir.js'
16
- import { draftsFromLine, type SessionStreamProvider } from './session-stream-events.js'
16
+ import { turnFromTail, type SessionStreamProvider, type TurnFromTail } from './session-stream-events.js'
17
17
  import { pruneQueue, type QueuedThreadTurn } from './thread-turn-queue.js'
18
18
 
19
- /** Bytes of transcript tail read to decide whether the last turn ended. */
20
- export const TURN_END_TAIL_BYTES = 64 * 1024
19
+ /**
20
+ * Bytes of transcript tail read to decide whether the last turn ended. 512 KiB, not 64:
21
+ * on the release Mac 3 of the 10 newest Desktop transcripts end in a ~76 KB bookkeeping
22
+ * row (a `prompt_snapshot` attachment) AFTER the terminal record, and a 64 KiB tail read
23
+ * only that row, saw no conversation record, and held the queue for the 30 s backstop
24
+ * (QA, 2026-09-15). The read is one `pread` per decision; nothing here streams.
25
+ */
26
+ export const TURN_END_TAIL_BYTES = 512 * 1024
21
27
 
22
28
  function queueDir(): string {
23
29
  const dir = dataPath('thread-turn-queue')
@@ -62,6 +68,46 @@ export function writeQueue(provider: string, threadId: string, queue: readonly Q
62
68
  atomicWriteFileSync(queuePath(provider, threadId), `${JSON.stringify(queue, null, 2)}\n`)
63
69
  }
64
70
 
71
+ /**
72
+ * How many WAITING follow-ups a list/detail row should show (6.48.1).
73
+ *
74
+ * Queue files are named with the native thread id (often a full UUID). Claude
75
+ * list rows stay on the registry's 8-character form until a transcript match
76
+ * expands them. An exact match always wins; an 8-character id is allowed to
77
+ * count a unique prefix among waiting queues of that provider, and an
78
+ * ambiguous prefix counts as zero rather than guessing.
79
+ *
80
+ * PURE: the store builds the input from disk; this decides the number.
81
+ */
82
+ export function queuedWaitingForSession(
83
+ queues: ReadonlyArray<{ provider: string; threadId: string; waiting: number }>,
84
+ provider: string, sessionId: string,
85
+ ): number {
86
+ const id = sessionId.toLowerCase()
87
+ const rows = queues.filter(q => q.provider === provider && q.waiting > 0)
88
+ const exact = rows.find(q => q.threadId.toLowerCase() === id)
89
+ if (exact) return exact.waiting
90
+ if (id.length !== 8) return 0
91
+ const prefixed = rows.filter(q => q.threadId.toLowerCase().startsWith(id))
92
+ const owners = new Set(prefixed.map(q => q.threadId.toLowerCase()))
93
+ if (owners.size !== 1) return 0
94
+ return prefixed[0]!.waiting
95
+ }
96
+
97
+ /** One lookup for a whole list/search request: one directory read, then O(1) per row. */
98
+ export function queuedWaitingLookup(now: number): (provider: string, sessionId: string) => number {
99
+ const queues = queuedThreadKeys().map(({ provider, threadId }) => ({
100
+ provider,
101
+ threadId,
102
+ waiting: readQueue(provider, threadId, now).filter(t => t.status === 'waiting').length,
103
+ }))
104
+ return (provider, sessionId) => queuedWaitingForSession(queues, provider, sessionId)
105
+ }
106
+
107
+ export function queuedTurnsFields(count: number): { queued_turns?: number } {
108
+ return count > 0 ? { queued_turns: count } : {}
109
+ }
110
+
65
111
  /** Every thread with a queue file, for the drain sweep. */
66
112
  export function queuedThreadKeys(): Array<{ provider: string; threadId: string }> {
67
113
  try {
@@ -83,24 +129,32 @@ export function queuedThreadKeys(): Array<{ provider: string; threadId: string }
83
129
  /**
84
130
  * Did the holder's last turn END?
85
131
  *
86
- * REUSES `draftsFromLine`, the same parser the live stream uses, so "the turn ended"
87
- * means exactly here what it means there. Hand-rolling a second `type === 'result'`
88
- * check is how two definitions of done drift apart.
132
+ * 6.48.1: decided by `turnFromTail`, the transcript's own rule (a terminal
133
+ * `stop_reason` with no tool_use awaiting its result, a `result` row, or a user
134
+ * interrupt). It replaced the status-draft loop here because a Desktop transcript
135
+ * writes no `result` row, so the old rule could only ever say "ended" for a
136
+ * `claude -p` run and every Desktop Continue waited the 30 s idle backstop (measured
137
+ * 2026-09-15: 0 of 30 newest transcripts carry one). The live feed still narrates
138
+ * from `draftsFromLine`; the two are pinned to agree on recorded tails.
89
139
  *
90
140
  * Reads a bounded tail, newest record wins. Returns false on any doubt -- an
91
141
  * unreadable transcript is not evidence a turn finished, and false only means the
92
142
  * queue HOLDS, which is always the safe answer.
93
143
  */
144
+ /** The full tail verdict, for callers that need to know OPEN as well as ended. Null on any doubt. */
145
+ export function transcriptTurnVerdict(provider: SessionStreamProvider, path: string | null): TurnFromTail | null {
146
+ const lines = readTail(path)
147
+ return lines === null ? null : turnFromTail(provider, lines)
148
+ }
149
+
94
150
  export function transcriptTurnEnded(provider: SessionStreamProvider, path: string | null): boolean {
95
- if (!path || !existsSync(path)) return false
151
+ return transcriptTurnVerdict(provider, path)?.ended === true
152
+ }
153
+
154
+ /** The bounded tail as lines, with the same two open flags; null when it cannot be read. */
155
+ function readTail(path: string | null): string[] | null {
156
+ if (!path || !existsSync(path)) return null
96
157
  try {
97
- // BOTH flags, and both are load-bearing -- hazard-invariants.test.ts enforces
98
- // them and each is right on its own terms. O_NOFOLLOW: a symlinked `<id>.jsonl`
99
- // could point at any file on disk and would be parsed here as a transcript.
100
- // O_NONBLOCK: `openSync` on a FIFO with no writer NEVER RETURNS, and it is a
101
- // synchronous syscall on Node's single thread, so one planted path would stop
102
- // health, meeting save and transcribe-stream along with this drain. That one is
103
- // recorded in the repo as three reproductions of the same bug, >34s to SIGKILL.
104
158
  const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
105
159
  try {
106
160
  const size = fstatSync(fd).size
@@ -110,18 +164,11 @@ export function transcriptTurnEnded(provider: SessionStreamProvider, path: strin
110
164
  const lines = buf.toString('utf-8').split('\n')
111
165
  // The first line of a tail read is almost always a fragment.
112
166
  if (start > 0) lines.shift()
113
- let ended = false
114
- for (const line of lines) {
115
- if (!line.trim()) continue
116
- for (const draft of draftsFromLine(provider, line)) {
117
- if (draft.kind === 'status') ended = draft.state === 'done'
118
- }
119
- }
120
- return ended
167
+ return lines
121
168
  } finally {
122
169
  closeSync(fd)
123
170
  }
124
171
  } catch {
125
- return false
172
+ return null
126
173
  }
127
174
  }
@@ -53,11 +53,15 @@
53
53
  // ---------------------------------------------------------------------------
54
54
  // Thirty seconds of transcript silence is the wrong trigger on its own: a long tool
55
55
  // call goes quiet mid-turn, and draining there would inject a message into the middle
56
- // of someone's reasoning. The precise signal is the turn ENDING, which is observable in
57
- // the transcript itself -- Claude writes a `result` record, Codex a `task_complete` /
56
+ // of someone's reasoning. The precise signal is the turn ENDING. For Claude that is the
57
+ // engine's own Stop hook followed by the registry flipping `idle` (6.48.1; a Desktop
58
+ // transcript carries no `result` record, and the Stop alone is 12-38 s early while the
59
+ // Stop hooks run), else the transcript's newest assistant record with a terminal
60
+ // `stop_reason` and no pending tool (`turnFromTail`); Codex writes `task_complete` /
58
61
  // `turn_complete`. The idle clock stays as a BACKSTOP for a holder that dies or a
59
62
  // provider that writes no terminal record, so a queue cannot wedge forever on a missing
60
- // event. Same reasoning as the session-trail handoff, one layer down.
63
+ // event, and positive evidence of an OPEN turn outranks it. Same reasoning as the
64
+ // session-trail handoff, one layer down.
61
65
 
62
66
  /** Terminal-ish states a queued turn can reach. `waiting` is the only live one. */
63
67
  export type QueuedTurnStatus =
@@ -186,6 +190,14 @@ export interface DrainObservation {
186
190
  turnEnded: boolean
187
191
  /** The 30s transcript clock. `idle` is the backstop when no terminal record lands. */
188
192
  activity: 'working' | 'idle' | 'unknown'
193
+ /**
194
+ * 6.48.1: POSITIVE evidence the turn is still open (a tool_use awaiting its result, a
195
+ * prompt newer than the last reply, or the hooks saying the turn has not stopped),
196
+ * bounded by the caller to a recent window. It outranks the idle backstop: a 40 s tool
197
+ * leaves the transcript untouched for 40 s, and "idle for 30 s" used to deliver a
198
+ * follow-up straight into that live turn (measured 2026-09-15 on the 6.48.1 live proof).
199
+ */
200
+ turnOpen?: boolean
189
201
  /** The gate's reason when `attachable` is false. Carried ONLY so a fence -- the one
190
202
  * hold a clock cannot end -- can be given a longer life than a busy thread. */
191
203
  reason?: string | null
@@ -218,6 +230,10 @@ export function drainDecision(
218
230
  // Turn-ended is the precise signal; idle is the backstop for a holder that wrote no
219
231
  // terminal record. `working` holds even when attachable, because attachable only says
220
232
  // no one else owns it -- it does not say a turn is not mid-flight.
233
+ // Positive evidence the turn is OPEN outranks a terminal record older than it: a prompt
234
+ // queued at the desk during the Stop hooks dequeues after them, and the tail's newest
235
+ // terminal record cannot see it (QA, 2026-09-15).
236
+ if (seen.turnOpen === true) return 'hold'
221
237
  if (seen.turnEnded) return 'deliver'
222
238
  return seen.activity === 'idle' ? 'deliver' : 'hold'
223
239
  }