@gotcos/glasses-server 6.61.7 → 6.62.1
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/.env.example +6 -0
- package/CHANGELOG.md +34 -0
- package/README.md +5 -1
- package/bin/cli.cjs +50 -3
- package/bin/hooks/cos-cursor-observer.cjs +11 -4
- package/bin/hooks/cos-session-hook +46 -11
- package/docs/provider-parity.md +43 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +4 -2
- package/server/index.ts +135 -16
- package/server/lib/agent-session-binding-store.ts +9 -3
- package/server/lib/agent-session-store.ts +7 -4
- package/server/lib/attached-provider-adapter.ts +158 -45
- package/server/lib/banned-permission-args.ts +42 -1
- package/server/lib/claude-bridge.ts +17 -8
- package/server/lib/claude-hooks-installer.ts +22 -2
- package/server/lib/codex-bridge.ts +17 -2
- package/server/lib/codex-hooks-installer.ts +538 -0
- package/server/lib/codex-live-queue.ts +1 -1
- package/server/lib/codex-model-catalog.ts +49 -2
- package/server/lib/codex-rollout-state.ts +90 -0
- package/server/lib/codex-session-posture.ts +528 -0
- package/server/lib/continue-plan.ts +180 -0
- package/server/lib/continue-read-health.ts +26 -0
- package/server/lib/cursor-bridge.ts +26 -6
- package/server/lib/cursor-fork-context.ts +140 -0
- package/server/lib/cursor-model-catalog.ts +62 -5
- package/server/lib/cursor-observer-installer.ts +48 -7
- package/server/lib/cursor-session-model.ts +339 -0
- package/server/lib/cursor-spawn-env.ts +210 -0
- package/server/lib/fork-thread.ts +143 -25
- package/server/lib/health-static-probes.ts +21 -11
- package/server/lib/lens-gist-engines.ts +15 -4
- package/server/lib/lens-gist.ts +1 -1
- package/server/lib/live-cues-cursor.ts +16 -6
- package/server/lib/occupancy-probes.ts +45 -1
- package/server/lib/permission-broker.ts +24 -9
- package/server/lib/provider-actions.ts +149 -0
- package/server/lib/provider-failure-text.ts +27 -0
- package/server/lib/provider-observe.ts +212 -0
- package/server/lib/provider-proof.ts +23 -14
- package/server/lib/query-job-runtime.ts +16 -1
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/session-act-fields.ts +61 -0
- package/server/lib/session-cancel.ts +95 -4
- package/server/lib/session-continue-facts-real.ts +74 -0
- package/server/lib/session-continue-facts.ts +267 -0
- package/server/lib/session-halt.ts +67 -6
- package/server/lib/session-hook-events.ts +57 -0
- package/server/lib/session-hook-ledger.ts +21 -3
- package/server/lib/session-hook-spool.ts +68 -6
- package/server/lib/session-hooks-runtime.ts +32 -2
- package/server/lib/session-peer-inbox-deps.ts +7 -6
- package/server/lib/session-recommendation.ts +3 -2
- package/server/lib/session-signal-store.ts +74 -5
- package/server/lib/session-stream-events.ts +32 -1
- package/server/lib/thread-attach-capability.ts +15 -1
- package/server/lib/thread-drain-kick.ts +4 -2
- package/server/lib/thread-occupancy.ts +44 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue.ts +6 -0
- package/server/lib/turn-request.ts +274 -0
- package/server/routes/agent-session-bindings.ts +224 -22
- package/server/routes/agent-session-stream.ts +26 -7
- package/server/routes/agent-sessions.ts +155 -10
- package/server/routes/health.ts +27 -2
- package/server/routes/session-hooks.ts +56 -5
- package/server/routes/thread-turn-queue.ts +37 -9
- package/server/scripts/hooks-cli.ts +33 -7
|
@@ -44,7 +44,10 @@ import { searchAgentSessions, type AgentSessionSearchHit } from '../lib/agent-se
|
|
|
44
44
|
import { claudeSessionNamesVisible, claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
|
|
45
45
|
import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
|
|
46
46
|
import { deriveForRow, signalFor, workObservationFor } from '../lib/session-hooks-runtime.js'
|
|
47
|
-
import { derivedRowFields, type DerivedSessionState } from '../lib/session-state-derive.js'
|
|
47
|
+
import { derivedRowFields, type DerivedSessionState, type RegistryFacts } from '../lib/session-state-derive.js'
|
|
48
|
+
import type { WorkObservation } from '../lib/session-work-observation.js'
|
|
49
|
+
import type { SessionSignal } from '../lib/session-signal-store.js'
|
|
50
|
+
import { HOOK_ACTIVE_CAP_MS, codexRolloutFacts, type CodexRolloutFacts } from '../lib/codex-rollout-state.js'
|
|
48
51
|
import { queuedTurnsFields, allQueuedWaitingLookup } from '../lib/thread-turn-queue-store.js'
|
|
49
52
|
import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
|
|
50
53
|
import { workspaceFromCwd } from '../lib/claude-session-registry.js'
|
|
@@ -67,6 +70,8 @@ import {
|
|
|
67
70
|
import type { OccupancyDirs } from '../lib/thread-occupancy.js'
|
|
68
71
|
import { parseTurnsParam, readRecentSessionTurns, type RecentTurnsRead } from '../lib/agent-session-turns.js'
|
|
69
72
|
import { cosSpawnedPids } from '../lib/agent-session-ownership-store.js'
|
|
73
|
+
import { sessionActFactsFor, sessionActFields } from '../lib/session-act-fields.js'
|
|
74
|
+
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
70
75
|
|
|
71
76
|
export const agentSessionsRouter = Router()
|
|
72
77
|
|
|
@@ -202,24 +207,129 @@ async function transcriptMtimes(
|
|
|
202
207
|
* rename here is silent on this side and renders every session as merely open on
|
|
203
208
|
* the lens. Pure: it takes the scan, it does no I/O.
|
|
204
209
|
*/
|
|
205
|
-
export function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan) {
|
|
210
|
+
export function withRunning<T extends { session_id: string }>(entry: T, scan: OccupiedScan, hint: RunningHint | null = null) {
|
|
206
211
|
const occ = scan.occupied.get(entry.session_id)
|
|
212
|
+
// 6.62.0: a Cursor row with hook evidence is decided by it (there is no process occupancy
|
|
213
|
+
// for Cursor, and its file time says nothing once the hooks have spoken).
|
|
214
|
+
const decides = hint?.decides === true
|
|
207
215
|
return {
|
|
208
216
|
...entry,
|
|
209
217
|
// A process HOLDS this thread open, whoever started it. Not "is generating":
|
|
210
218
|
// a Claude record for an open window outlives the work by however long the
|
|
211
219
|
// window stays up, which is why `running_active` exists below.
|
|
212
|
-
running: occ !== undefined,
|
|
220
|
+
running: decides ? hint!.running : occ !== undefined || hint?.running === true,
|
|
213
221
|
// Held by something that is not COS, so a Continue would be refused. The
|
|
214
222
|
// badge reads `running`; the Continue affordance reads this.
|
|
215
|
-
running_foreign: (occ?.foreignOwners ?? 0) > 0,
|
|
223
|
+
running_foreign: (occ?.foreignOwners ?? 0) > 0 || hint?.foreign === true,
|
|
216
224
|
// The transcript was WRITTEN in the last few seconds: an agent is generating
|
|
217
225
|
// here, not just holding the file. This is the only one of the three that
|
|
218
226
|
// clears on its own when the work stops. Still a display hint, never a gate.
|
|
219
|
-
|
|
227
|
+
// 6.62.0: or the engine's own hooks (or a Codex rollout) say a turn is open and the
|
|
228
|
+
// last event is inside HOOK_ACTIVE_CAP_MS (plan 1.1-1.3).
|
|
229
|
+
running_active: decides ? hint!.active : occ?.activeRecently === true || hint?.active === true,
|
|
220
230
|
}
|
|
221
231
|
}
|
|
222
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 6.62.0 (plan 1.1-1.3): what the engine's own evidence says about working, for `withRunning`.
|
|
235
|
+
*
|
|
236
|
+
* A DISPLAY HINT, NEVER A GATE: the write gate (`thread-occupancy.ts`) is untouched. An open
|
|
237
|
+
* turn reads active only while its last event is inside HOOK_ACTIVE_CAP_MS: an Esc with no
|
|
238
|
+
* Stop, a crash, a Cursor abort leave a turn open, and the half-hour open-turn ceiling would
|
|
239
|
+
* otherwise show a ghost "working" row for thirty minutes (W5).
|
|
240
|
+
*
|
|
241
|
+
* hook running, last event inside the cap running, active (every provider)
|
|
242
|
+
* hook running, quiet past the cap Cursor: running, NOT active (OPEN, quiet: a
|
|
243
|
+
* tool can run longer than the cap with no event,
|
|
244
|
+
* and an Esc with no Stop looks the same, QA W8);
|
|
245
|
+
* Claude and Codex: no hint (occupancy answers)
|
|
246
|
+
* hook waiting running, not active
|
|
247
|
+
* Codex rollout turn open inside the cap running, active (no Codex hooks yet)
|
|
248
|
+
* Cursor, any other hook state not running, not active (`decides`)
|
|
249
|
+
*
|
|
250
|
+
* A hook-derived `running` only exists inside the deriver's 30-minute open-turn ceiling, so an
|
|
251
|
+
* open-quiet Cursor row ends there, or at its Stop or SessionEnd.
|
|
252
|
+
*
|
|
253
|
+
* For Claude and Codex the hint only ADDS to occupancy (a held, freshly written thread still
|
|
254
|
+
* reads as it did). For Cursor, whose list has no occupancy at all, a hook signal decides, and
|
|
255
|
+
* a running Cursor turn is the user's own (an IDE composer or their terminal; COS's own child
|
|
256
|
+
* is kept out of the phase), so it is foreign.
|
|
257
|
+
*/
|
|
258
|
+
export interface RunningHint {
|
|
259
|
+
running: boolean
|
|
260
|
+
active: boolean
|
|
261
|
+
foreign: boolean
|
|
262
|
+
/** Cursor with hook evidence: this hint IS the answer, occupancy and file time are not read. */
|
|
263
|
+
decides: boolean
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function runningHintFor(provider: AgentProvider, derived: DerivedSessionState | undefined, lastEventAt: number | null, now: number): RunningHint | null {
|
|
267
|
+
const cursor = provider === 'cursor'
|
|
268
|
+
const quiet: RunningHint | null = cursor ? { running: false, active: false, foreign: false, decides: true } : null
|
|
269
|
+
if (!derived) return null
|
|
270
|
+
const inCap = lastEventAt !== null && now - lastEventAt <= HOOK_ACTIVE_CAP_MS && lastEventAt <= now + 5_000
|
|
271
|
+
if (derived.state_source === 'hook') {
|
|
272
|
+
if (derived.agent_state === 'waiting') return { running: true, active: false, foreign: cursor, decides: cursor }
|
|
273
|
+
if (derived.agent_state === 'running' && inCap) return { running: true, active: true, foreign: cursor, decides: cursor }
|
|
274
|
+
if (derived.agent_state === 'running' && cursor) return { running: true, active: false, foreign: true, decides: true }
|
|
275
|
+
return quiet
|
|
276
|
+
}
|
|
277
|
+
if (provider === 'codex' && derived.state_source === 'transcript' && derived.agent_state === 'running' && inCap) {
|
|
278
|
+
return { running: true, active: true, foreign: false, decides: false }
|
|
279
|
+
}
|
|
280
|
+
return null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The newest thing a signal heard: an event, or (Cursor) a thought. */
|
|
284
|
+
function signalLastAt(signal: SessionSignal | undefined): number | null {
|
|
285
|
+
if (!signal) return null
|
|
286
|
+
return Math.max(signal.lastEventAt, signal.lastThought?.at ?? 0)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A Codex thread with a writer-lock holder: alive, with no status of its own (W6). */
|
|
290
|
+
const CODEX_HOLDER_FACTS: RegistryFacts = { alive: true, status: null, waitingFor: null, statusUpdatedAt: null, lastActiveAt: null }
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 6.62.0 (plan 1.3): one derivation for a Codex row. The hooks when they have spoken; else the
|
|
294
|
+
* rollout's own turn markers, read for a thread written inside the cap. Registry facts only
|
|
295
|
+
* when a writer-lock holder exists: passing "dead" facts for every unloaded thread would end
|
|
296
|
+
* each of them after two scans (session-state-derive.ts).
|
|
297
|
+
*/
|
|
298
|
+
function deriveCodexRow(sessionId: string, held: boolean, facts: CodexRolloutFacts | null, now: number, remember = true): DerivedSessionState | undefined {
|
|
299
|
+
return deriveForRow({
|
|
300
|
+
sessionId,
|
|
301
|
+
registry: held ? CODEX_HOLDER_FACTS : undefined,
|
|
302
|
+
transcript: facts ? { inFlight: facts.open, lastActivityAt: facts.mtimeMs } : undefined,
|
|
303
|
+
now,
|
|
304
|
+
remember,
|
|
305
|
+
attachedTurn: isAttachedTurnActive(sessionStreamKey('codex', sessionId)),
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The evidence clock a row's hint is judged by: the hooks, else (Codex) the rollout write. */
|
|
310
|
+
function hintClock(signal: SessionSignal | undefined, derived: DerivedSessionState | undefined, facts: CodexRolloutFacts | null): number | null {
|
|
311
|
+
if (derived?.state_source === 'hook') return signalLastAt(signal)
|
|
312
|
+
return facts?.mtimeMs ?? null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 6.62.0 (plan 1.9): Codex compaction. Codex's PreCompact hook opens it; the rollout's
|
|
317
|
+
* `ContextCompaction` item, written when it is over, closes it (as does the PostCompact hook).
|
|
318
|
+
*/
|
|
319
|
+
function codexCompactionFields(observation: WorkObservation | undefined, facts: CodexRolloutFacts | null, now: number): ReturnType<typeof workObservationFields> {
|
|
320
|
+
if (!observation || observation.compactingAt === null) return {}
|
|
321
|
+
if (facts?.compactedAt != null && facts.compactedAt >= observation.compactingAt) return {}
|
|
322
|
+
const { compaction } = workObservationFields({ ...observation, agents: [], partial: false }, now)
|
|
323
|
+
return compaction ? { compaction } : {}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 6.62.0 (plan 1.1): a Cursor row's last activity is its last hook event; the transcript has no clock. */
|
|
327
|
+
function cursorActivityFields(provider: AgentProvider, signal: SessionSignal | undefined): { last_activity_at?: string } {
|
|
328
|
+
if (provider !== 'cursor') return {}
|
|
329
|
+
const at = signalLastAt(signal)
|
|
330
|
+
return at ? { last_activity_at: new Date(at).toISOString() } : {}
|
|
331
|
+
}
|
|
332
|
+
|
|
223
333
|
/**
|
|
224
334
|
* Occupancy plus freshness for ONE thread, for the detail route.
|
|
225
335
|
*
|
|
@@ -399,11 +509,30 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
399
509
|
if (row.provider !== 'cursor' || !signalFor(row.session_id)) continue
|
|
400
510
|
derivedById.set(row.session_id, deriveForRow({ sessionId: row.session_id, now, remember: false }))
|
|
401
511
|
}
|
|
512
|
+
// 6.62.0 (plan 1.3): Codex rows get the one derivation too, from the hooks or the rollout.
|
|
513
|
+
const rolloutById = new Map<string, CodexRolloutFacts | null>()
|
|
514
|
+
for (const row of sessions) {
|
|
515
|
+
if (row.provider !== 'codex') continue
|
|
516
|
+
const facts = codexRolloutFacts(row.file, now)
|
|
517
|
+
rolloutById.set(row.session_id, facts)
|
|
518
|
+
derivedById.set(row.session_id, deriveCodexRow(row.session_id, running.occupied.has(row.session_id), facts, now))
|
|
519
|
+
}
|
|
402
520
|
const queuedOf = await allQueuedWaitingLookup(now)
|
|
403
521
|
const subagents = await readCodexSubagents(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), roots.codexSessions, now)
|
|
404
522
|
res.json({
|
|
405
|
-
sessions: sessions.map((row, index) =>
|
|
406
|
-
|
|
523
|
+
sessions: sessions.map((row, index) => {
|
|
524
|
+
const derived = derivedById.get(row.session_id)
|
|
525
|
+
const signal = signalFor(row.session_id)
|
|
526
|
+
const facts = rolloutById.get(row.session_id) ?? null
|
|
527
|
+
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now)
|
|
528
|
+
return {
|
|
529
|
+
...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
|
|
530
|
+
...cursorActivityFields(row.provider, signal),
|
|
531
|
+
...(row.provider === 'codex'
|
|
532
|
+
? { ...(subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}), ...codexCompactionFields(workObservationFor(row.session_id), facts, now) }
|
|
533
|
+
: workObservationFields(workObservationFor(row.session_id), now)),
|
|
534
|
+
}
|
|
535
|
+
}),
|
|
407
536
|
total: sessions.length,
|
|
408
537
|
windowHours: AGENT_SESSION_WINDOW_HOURS,
|
|
409
538
|
sort,
|
|
@@ -442,7 +571,9 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
442
571
|
if (!peersByPrefix.has(prefix)) peersByPrefix.set(prefix, peer)
|
|
443
572
|
}
|
|
444
573
|
const derivedFor = (hit: AgentSessionSearchHit): DerivedSessionState | undefined => {
|
|
445
|
-
|
|
574
|
+
// 6.62.0 (plan 1.3): a Codex or Cursor hit the hooks have spoken for gets the same state
|
|
575
|
+
// as its row, from the signal alone (a hit has no transcript walk).
|
|
576
|
+
if (hit.provider !== 'claude') return signalFor(hit.session_id) ? deriveForRow({ sessionId: hit.session_id, remember: false, attachedTurn: isAttachedTurnActive(sessionStreamKey(hit.provider, hit.session_id)) }) : undefined
|
|
446
577
|
const peer = peersByPrefix.get(hit.session_id.slice(0, 8).toLowerCase())
|
|
447
578
|
return deriveForRow({
|
|
448
579
|
sessionId: hit.session_id,
|
|
@@ -515,7 +646,9 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
515
646
|
const named = names.get(parsed.session_id) || names.get(sessionId)
|
|
516
647
|
if (named) parsed.display_label = named
|
|
517
648
|
}
|
|
518
|
-
const
|
|
649
|
+
const now = Date.now()
|
|
650
|
+
const rollout = provider === 'codex' ? codexRolloutFacts(found, now) : null
|
|
651
|
+
const observed = provider === 'codex' ? codexCompactionFields(workObservationFor(parsed.session_id), rollout, now) : workObservationFields(workObservationFor(parsed.session_id))
|
|
519
652
|
const subagents = provider === 'codex' ? (await readCodexSubagents([sessionId], agentSessionRoots().codexSessions)).get(sessionId.toLowerCase()) : observed.subagent_activity
|
|
520
653
|
const modified = st.mtime.toISOString()
|
|
521
654
|
const activity = await readSessionActivity(provider, found)
|
|
@@ -534,9 +667,14 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
534
667
|
} else if (provider === 'cursor' && signalFor(parsed.session_id)) {
|
|
535
668
|
// 6.51.0: the detail agrees with its list row (hook-grounded Cursor state).
|
|
536
669
|
derived = deriveForRow({ sessionId: parsed.session_id, remember: false })
|
|
670
|
+
} else if (provider === 'codex') {
|
|
671
|
+
// 6.62.0 (plan 1.3): as its list row: the hooks, else the rollout's turn markers.
|
|
672
|
+
derived = deriveCodexRow(parsed.session_id, running.occupied.has(parsed.session_id), rollout, now, false)
|
|
537
673
|
}
|
|
674
|
+
const signal = signalFor(parsed.session_id)
|
|
675
|
+
const hint = runningHintFor(provider, derived, hintClock(signal, derived, rollout), now)
|
|
538
676
|
res.json({
|
|
539
|
-
...withRunning({ session_id: parsed.session_id }, running),
|
|
677
|
+
...withRunning({ session_id: parsed.session_id }, running, hint),
|
|
540
678
|
...derivedRowFields(derived),
|
|
541
679
|
...queuedTurnsFields((await allQueuedWaitingLookup(Date.now()))(provider, parsed.session_id)),
|
|
542
680
|
// The client must be able to tell "this server stamped nothing" from "this
|
|
@@ -587,8 +725,15 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
|
|
|
587
725
|
file_size_bytes: parsed.file_size_bytes,
|
|
588
726
|
omitted_tools: parsed.omitted_tools,
|
|
589
727
|
...(activity.lastActivityAt ? { last_activity_at: activity.lastActivityAt } : {}),
|
|
728
|
+
...cursorActivityFields(provider, signal),
|
|
590
729
|
...(activity.lastTool ? { last_tool: activity.lastTool } : {}),
|
|
591
730
|
...(recentTurns ? { recent_turns: recentTurns.turns, recent_turns_more: recentTurns.more } : {}),
|
|
731
|
+
// 6.62.0 (plans 1.8, 3.12): the lens reads both from the DETAIL (display-pages.ts).
|
|
732
|
+
// 6.62.0 /qa (W11): detail and the attach verdict only; list rows never wait on model reads.
|
|
733
|
+
// Q3: the note says Ask unless this client declared it shows the note.
|
|
734
|
+
...sessionActFields((await sessionActFactsFor([{ provider, session_id: parsed.session_id, file: found }], {
|
|
735
|
+
noteAck: continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]),
|
|
736
|
+
}))[0]),
|
|
592
737
|
})
|
|
593
738
|
} catch (error) {
|
|
594
739
|
console.error(`[agent-sessions] detail failed: ${error instanceof Error ? error.message : error}`)
|
package/server/routes/health.ts
CHANGED
|
@@ -87,7 +87,10 @@ import {
|
|
|
87
87
|
} from '../lib/thread-attach-capability.js'
|
|
88
88
|
import { cachedHookStatus, sessionHooksEnabled, sessionHooksHealthFields } from '../lib/session-hooks-runtime.js'
|
|
89
89
|
import { sessionCancelFeature } from '../lib/session-cancel.js'
|
|
90
|
-
import {
|
|
90
|
+
import { codexDeskHaltReady, providerObserveFields } from '../lib/provider-observe.js'
|
|
91
|
+
import { liveProviderActInput, mergeProviderSections, providerActSections } from '../lib/provider-actions.js'
|
|
92
|
+
import { cursorDeskHaltReady, hookHaltReady } from '../lib/claude-hooks-installer.js'
|
|
93
|
+
import { continueReadHealthFields } from '../lib/continue-read-health.js'
|
|
91
94
|
import { permissionBrokerHealthFields, sessionQuestionsCapability } from '../lib/permission-broker.js'
|
|
92
95
|
import { continueLiveEnabled, liveDeliveryStats } from '../lib/session-peer-inbox-deps.js'
|
|
93
96
|
import { codexLiveStats } from '../lib/codex-live-queue-deps.js'
|
|
@@ -305,7 +308,9 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
305
308
|
// 6.53.0: cancel a session run. `cosTurn` always; `deskClaude` only once the hooks are
|
|
306
309
|
// applied and the 6.53 hook is installed (Install hooks in COS Control after updating).
|
|
307
310
|
// The status read is the same cached one `sessionHooks` below reports.
|
|
308
|
-
|
|
311
|
+
// 6.62.0: `deskCodex` (the Codex hooks installed AND trusted by Codex) and `deskCursor` (it
|
|
312
|
+
// runs the Claude hooks). `deskClaude` reads exactly as before.
|
|
313
|
+
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus()), codexDeskHaltReady(), cursorDeskHaltReady(cachedHookStatus())),
|
|
309
314
|
}
|
|
310
315
|
const voice = {
|
|
311
316
|
available: keyStatus.hasKey || tts_local.ready,
|
|
@@ -415,6 +420,18 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
415
420
|
// how many are held (that is on the authenticated questions route).
|
|
416
421
|
...permissionBrokerHealthFields(),
|
|
417
422
|
...continueLiveHealthFields(),
|
|
423
|
+
// 6.62.0 /qa (W6): spawn isolation, posture and Cursor model read counters.
|
|
424
|
+
...continueReadHealthFields(),
|
|
425
|
+
// 6.62.0: ONE providers object per engine. `act` (plan 3.11): what each provider can do,
|
|
426
|
+
// with a reason for every "no". `observe` (plan 1.10): what COS can see of its sessions.
|
|
427
|
+
providers: mergeProviderSections(
|
|
428
|
+
providerActSections(liveProviderActInput({
|
|
429
|
+
attachEnabled: threadAttach.enabled,
|
|
430
|
+
forkProviders: threadAttach.forkProviders ?? [],
|
|
431
|
+
sessionCancel: features.sessionCancel,
|
|
432
|
+
})),
|
|
433
|
+
providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) }),
|
|
434
|
+
),
|
|
418
435
|
server_instance_id: getServerInstanceId(),
|
|
419
436
|
boot_id: serverMetrics.bootId,
|
|
420
437
|
generation_id: getServerGenerationId(),
|
|
@@ -548,6 +565,14 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
548
565
|
res.json({
|
|
549
566
|
...catalog,
|
|
550
567
|
...threadAttachHealthFields(threadAttach),
|
|
568
|
+
// 6.62.0 (plans 3.5, 3.11): the phone reads THIS surface, so the live-Continue counters
|
|
569
|
+
// and the per-provider action contract are published here too, same helpers as health.
|
|
570
|
+
...continueLiveHealthFields(),
|
|
571
|
+
providers: mergeProviderSections(providerActSections(liveProviderActInput({
|
|
572
|
+
attachEnabled: threadAttach.enabled,
|
|
573
|
+
forkProviders: threadAttach.forkProviders ?? [],
|
|
574
|
+
sessionCancel: sessionCancelFeature(sessionHooksEnabled(), hookHaltReady(cachedHookStatus()), codexDeskHaltReady(), cursorDeskHaltReady(cachedHookStatus())),
|
|
575
|
+
})), providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) })),
|
|
551
576
|
options: [
|
|
552
577
|
...(catalog.options ?? []),
|
|
553
578
|
...cursorOptions,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { installCursorObserver } from '../lib/cursor-observer-installer.js'
|
|
2
|
+
import { codexPresent, installCodexHooks, uninstallCodexHooks, type CodexInstallResult } from '../lib/codex-hooks-installer.js'
|
|
3
|
+
import { refreshAfterInstall } from '../lib/provider-observe.js'
|
|
2
4
|
// Session hooks: status, install, uninstall, and today's runs.
|
|
3
5
|
//
|
|
4
6
|
// `GET /api/session-hooks/status` is what Control's banner keys off. Five outcomes on
|
|
@@ -17,8 +19,50 @@ export function isInteractiveEntrypoint(entrypoint: string | null): boolean {
|
|
|
17
19
|
return entrypoint === 'claude-desktop' || entrypoint === 'cli'
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
/**
|
|
23
|
+
* 6.62.0: the Codex half of install and uninstall, injectable so a test never touches the
|
|
24
|
+
* real `~/.codex`. Defaults are the real installer, gated on Codex being present at all.
|
|
25
|
+
*/
|
|
26
|
+
export interface SessionHooksCodexDeps {
|
|
27
|
+
present: () => boolean
|
|
28
|
+
install: (options: { dryRun: boolean }) => CodexInstallResult
|
|
29
|
+
uninstall: (options: { dryRun: boolean }) => CodexInstallResult
|
|
30
|
+
/** Re-read the provider hook files (and Codex trust, in the background) after a write. */
|
|
31
|
+
refresh: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const realCodexDeps: SessionHooksCodexDeps = {
|
|
35
|
+
present: () => codexPresent(),
|
|
36
|
+
install: options => installCodexHooks(options),
|
|
37
|
+
uninstall: options => uninstallCodexHooks(options),
|
|
38
|
+
refresh: () => refreshAfterInstall(),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The Codex half, run AFTER Claude's and the observer's and never able to change their answer
|
|
43
|
+
* (B2): a missing Codex is `skipped`, a refusal or a throw is reported in `codex` and the route
|
|
44
|
+
* still answers as Claude's result says.
|
|
45
|
+
*/
|
|
46
|
+
export function codexHookStep(deps: SessionHooksCodexDeps, action: 'install' | 'uninstall', dryRun: boolean): Record<string, unknown> {
|
|
47
|
+
try {
|
|
48
|
+
if (!deps.present()) return { ok: true, skipped: 'codex_not_present' }
|
|
49
|
+
const result = action === 'install' ? deps.install({ dryRun }) : deps.uninstall({ dryRun })
|
|
50
|
+
return {
|
|
51
|
+
ok: result.ok,
|
|
52
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
53
|
+
changed: result.changed,
|
|
54
|
+
backupPath: result.backupPath,
|
|
55
|
+
status: { state: result.status.state, installed: result.status.installed, scriptOk: result.status.scriptOk },
|
|
56
|
+
...(dryRun && result.merged ? { merged: result.merged } : {}),
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { ok: false, reason: 'codex_step_threw', detail: error instanceof Error ? error.name : 'error' }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSessionHooksRouter(options: { port: number; codex?: SessionHooksCodexDeps }): Router {
|
|
21
64
|
const router = Router()
|
|
65
|
+
const codex = options.codex ?? realCodexDeps
|
|
22
66
|
|
|
23
67
|
router.get('/session-hooks/status', (_req, res) => {
|
|
24
68
|
res.set('Cache-Control', 'private, no-store')
|
|
@@ -34,8 +78,10 @@ export function createSessionHooksRouter(options: { port: number }): Router {
|
|
|
34
78
|
return
|
|
35
79
|
}
|
|
36
80
|
const cursorObserver = installCursorObserver({ dryRun })
|
|
37
|
-
|
|
38
|
-
|
|
81
|
+
const codexStep = codexHookStep(codex, 'install', dryRun)
|
|
82
|
+
if (!dryRun) { try { codex.refresh() } catch { /* health catches up on its own tick */ } }
|
|
83
|
+
if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_install_failed', cursorObserver, codex: codexStep, status: result.status }); return }
|
|
84
|
+
res.json({ ok: true, cursorObserver, codex: codexStep, changed: result.changed || cursorObserver.changed, scriptCopied: result.scriptCopied, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
|
|
39
85
|
})
|
|
40
86
|
|
|
41
87
|
router.post('/session-hooks/uninstall', (req, res) => {
|
|
@@ -47,8 +93,10 @@ export function createSessionHooksRouter(options: { port: number }): Router {
|
|
|
47
93
|
return
|
|
48
94
|
}
|
|
49
95
|
const cursorObserver = installCursorObserver({ dryRun, uninstall: true })
|
|
50
|
-
|
|
51
|
-
|
|
96
|
+
const codexStep = codexHookStep(codex, 'uninstall', dryRun)
|
|
97
|
+
if (!dryRun) { try { codex.refresh() } catch { /* health catches up on its own tick */ } }
|
|
98
|
+
if (!cursorObserver.ok) { res.status(409).json({ ok: false, reason: 'cursor_observer_uninstall_failed', cursorObserver, codex: codexStep, status: result.status }); return }
|
|
99
|
+
res.json({ ok: true, cursorObserver, codex: codexStep, changed: result.changed || cursorObserver.changed, backupPath: result.backupPath, status: result.status, ...(dryRun ? { merged: result.merged } : {}) })
|
|
52
100
|
})
|
|
53
101
|
|
|
54
102
|
// Sessions the hooks saw start and end, for Control's scheduled-job ledger. A run
|
|
@@ -63,6 +111,9 @@ export function createSessionHooksRouter(options: { port: number }): Router {
|
|
|
63
111
|
const runs: Array<Record<string, unknown>> = []
|
|
64
112
|
for (const signal of sessionSignalStore.snapshot()) {
|
|
65
113
|
if (signal.observationOnly) continue
|
|
114
|
+
// 6.62.0: Control's scheduled-job ledger lists `claude -p` jobs. A Codex thread (no
|
|
115
|
+
// registry entrypoint at all) or a Cursor composer is never one.
|
|
116
|
+
if (signal.provider !== undefined && signal.provider !== 'claude') continue
|
|
66
117
|
if (signal.firstSeenAt < sinceMs && !(signal.ended && signal.ended.at >= sinceMs)) continue
|
|
67
118
|
if (!all && isInteractiveEntrypoint(signal.entrypoint)) continue
|
|
68
119
|
runs.push({
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// EVERY DEPENDENCY IS INJECTED so the whole path is testable without a live server.
|
|
17
17
|
|
|
18
18
|
import { Router, type Request, type Response } from 'express'
|
|
19
|
+
import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-plan.js'
|
|
19
20
|
import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
|
|
20
21
|
import { COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
|
|
21
22
|
import {
|
|
@@ -49,6 +50,13 @@ export interface ThreadTurnQueueDeps {
|
|
|
49
50
|
serverRetryable?: boolean
|
|
50
51
|
}>
|
|
51
52
|
nativeQueue?: NativeQueueControl
|
|
53
|
+
/**
|
|
54
|
+
* 6.62.0 (W14), optional: is this Cursor id an Agent CLI chat (a `~/.cursor/chats` dir)?
|
|
55
|
+
* Only those are drained from here, and only when the gate, which now includes the
|
|
56
|
+
* chat's 30 s busy window, says free. An IDE composer still leaves only through its own
|
|
57
|
+
* Stop hook. Absent: no Cursor turn is ever drained here, exactly as in 6.61.
|
|
58
|
+
*/
|
|
59
|
+
cursorCliChat?: (threadId: string) => boolean
|
|
52
60
|
now: () => number
|
|
53
61
|
}
|
|
54
62
|
|
|
@@ -92,6 +100,11 @@ function safeTurnOpen(deps: ThreadTurnQueueDeps, provider: string, threadId: str
|
|
|
92
100
|
try { return deps.turnOpen!(provider, threadId) } catch { return undefined }
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
/** A throwing or absent CLI-chat probe means "not a CLI chat": the 6.61 Cursor hold. */
|
|
104
|
+
function safeCursorCliChat(deps: ThreadTurnQueueDeps, threadId: string): boolean {
|
|
105
|
+
try { return deps.cursorCliChat ? deps.cursorCliChat(threadId) === true : false } catch { return false }
|
|
106
|
+
}
|
|
107
|
+
|
|
95
108
|
/** A throwing cancel read is no hold: the gate still decides every delivery. */
|
|
96
109
|
function safeCancelHold(deps: ThreadTurnQueueDeps, provider: string, threadId: string): number | null {
|
|
97
110
|
try { return deps.cancelHoldUntil ? deps.cancelHoldUntil(provider, threadId) : null } catch { return null }
|
|
@@ -142,18 +155,31 @@ export async function drainThread(
|
|
|
142
155
|
// composer's own Stop hook (`routes/cursor-stop-followup.ts`), which claims it the
|
|
143
156
|
// moment the turn ends; a second deliverer could hand the same sentence over twice.
|
|
144
157
|
// The pass still runs for it so the TTL retires an unclaimed turn honestly.
|
|
145
|
-
|
|
158
|
+
//
|
|
159
|
+
// 6.62.0 (W14): an Agent CLI chat is the exception. A `-p` run fires no Stop hook, so
|
|
160
|
+
// nothing else would ever send its parked turn; it drains here once the gate (with the
|
|
161
|
+
// chat's 30 s busy window) reads free, and that quiet window is its turn-ended signal.
|
|
162
|
+
const cliChat = provider === 'cursor' && safeCursorCliChat(deps, threadId)
|
|
163
|
+
const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = provider === 'cursor' && !cliChat
|
|
146
164
|
? { attachable: false, reason: 'cursor_stop_hook_delivery' }
|
|
147
165
|
: refusedGate ?? deps.occupancy(provider, threadId)
|
|
148
166
|
if (!gate.attachable) refusedGate = gate
|
|
149
|
-
const seen: DrainObservation =
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
const seen: DrainObservation = cliChat
|
|
168
|
+
? {
|
|
169
|
+
attachable: gate.attachable,
|
|
170
|
+
turnEnded: gate.attachable,
|
|
171
|
+
activity: gate.attachable ? 'idle' : 'working',
|
|
172
|
+
reason: gate.reason,
|
|
173
|
+
cancelHoldUntil: safeCancelHold(deps, provider, threadId),
|
|
174
|
+
}
|
|
175
|
+
: {
|
|
176
|
+
attachable: gate.attachable,
|
|
177
|
+
turnEnded: deps.turnEnded(provider, threadId),
|
|
178
|
+
turnOpen: deps.turnOpen ? safeTurnOpen(deps, provider, threadId) : undefined,
|
|
179
|
+
activity: deps.activity(provider, threadId),
|
|
180
|
+
reason: gate.reason,
|
|
181
|
+
cancelHoldUntil: safeCancelHold(deps, provider, threadId),
|
|
182
|
+
}
|
|
157
183
|
const decision = drainDecision(turn, seen, deps.now())
|
|
158
184
|
|
|
159
185
|
if (decision === 'hold') { held += 1; continue }
|
|
@@ -282,6 +308,8 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
|
|
|
282
308
|
const admitted = admitToQueue(queue, {
|
|
283
309
|
clientTurnId, cosSessionId, provider, threadId, prompt,
|
|
284
310
|
queuedAt: now, status: 'waiting', attempts: 0,
|
|
311
|
+
// 6.62.0 /qa (Q3): the acknowledgement travels with the row to its drain.
|
|
312
|
+
...(continueNoteAcknowledged(req.headers[CONTINUE_NOTE_HEADER]) ? { continueNoteAck: true as const } : {}),
|
|
285
313
|
})
|
|
286
314
|
if (!admitted.ok) return res.status(409).json({ error: admitted.reason })
|
|
287
315
|
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { installCursorObserver } from '../lib/cursor-observer-installer.js'
|
|
3
|
-
// Install, inspect or remove the COS session hook in ~/.claude/settings.json.
|
|
3
|
+
// Install, inspect or remove the COS session hook in ~/.claude/settings.json, and (6.62.0) in
|
|
4
|
+
// ~/.codex/hooks.json when Codex is on this Mac.
|
|
4
5
|
//
|
|
5
|
-
// npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141]
|
|
6
|
+
// npx --yes @gotcos/glasses-server@latest --hooks install [--dry-run] [--port 3141] [--codex]
|
|
6
7
|
// npx --yes @gotcos/glasses-server@latest --hooks status
|
|
7
|
-
// npx --yes @gotcos/glasses-server@latest --hooks uninstall [--dry-run]
|
|
8
|
+
// npx --yes @gotcos/glasses-server@latest --hooks uninstall [--dry-run] [--codex]
|
|
9
|
+
//
|
|
10
|
+
// `--codex` acts on Codex ONLY (the rollback runbook's `--hooks uninstall --codex` leaves the
|
|
11
|
+
// Claude hooks in place). Without it, Codex rides along when present and never changes the
|
|
12
|
+
// exit code: a Codex refusal is reported under `codex` and Claude's answer stands (B2).
|
|
8
13
|
//
|
|
9
14
|
// Prints one JSON document. Exit 0 on success, 2 on a refusal (unparseable or symlinked
|
|
10
15
|
// settings, a missing packaged script), 64 on a bad argument. Never prints the token.
|
|
11
16
|
|
|
12
17
|
import { hookStatus, hookStatusAdvice, installClaudeHooks, uninstallClaudeHooks } from '../lib/claude-hooks-installer.js'
|
|
18
|
+
import { codexHookAdvice, codexHookStatus, codexPresent, installCodexHooks, refreshCodexHookTrust, uninstallCodexHooks } from '../lib/codex-hooks-installer.js'
|
|
13
19
|
import { sessionHooksEnabled } from '../lib/session-hooks-runtime.js'
|
|
14
20
|
|
|
15
21
|
const args = process.argv.slice(2)
|
|
@@ -20,36 +26,56 @@ const port = portIndex >= 0 ? Number(args[portIndex + 1]) : Number(process.env.P
|
|
|
20
26
|
const deskIndex = args.indexOf('--desk-idle-s')
|
|
21
27
|
const deskIdleSeconds = deskIndex >= 0 ? Number(args[deskIndex + 1]) : Number(process.env.COS_PERMISSION_BROKER_DESK_IDLE_S ?? 90)
|
|
22
28
|
|
|
29
|
+
const codexOnly = args.includes('--codex')
|
|
30
|
+
|
|
23
31
|
function print(value: unknown): void {
|
|
24
32
|
console.log(JSON.stringify(value, null, 2))
|
|
25
33
|
}
|
|
26
34
|
|
|
35
|
+
/** The Codex half: skipped when Codex is not here; with trust read from Codex itself (K3). */
|
|
36
|
+
async function codexPart(action: 'status' | 'install' | 'uninstall'): Promise<Record<string, unknown> & { ok: boolean }> {
|
|
37
|
+
if (!codexPresent()) return { ok: true, skipped: 'codex_not_present' }
|
|
38
|
+
const result = action === 'status' ? { ok: true, status: codexHookStatus() }
|
|
39
|
+
: action === 'install' ? installCodexHooks({ dryRun }) : uninstallCodexHooks({ dryRun })
|
|
40
|
+
const trust = result.status.installed && !dryRun ? await refreshCodexHookTrust({ fresh: true }) : null
|
|
41
|
+
const advice = action === 'uninstall' ? null : codexHookAdvice(result.status, trust?.trust ?? 'unknown')
|
|
42
|
+
// QA W4: why the trust word is what it is (a failed read's code, `not_listed`), or null.
|
|
43
|
+
return { ...result, ...(trust ? { trust: trust.trust, trustReason: trust.reason } : {}), ...(advice ? { advice } : {}) }
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// `serverApplies` says whether the running server would READ the spool into rows: the
|
|
28
47
|
// hooks can be installed while the feature is off (COS_CLAUDE_SESSIONS_ENABLED unset),
|
|
29
48
|
// and a status that said only "installed" would hide that.
|
|
30
49
|
const serverApplies = sessionHooksEnabled()
|
|
31
50
|
const flagsNote = serverApplies ? undefined : 'Rows change only when the server runs with COS_CLAUDE_SESSIONS_ENABLED=1 (or COS_SESSION_HOOKS=1).'
|
|
32
51
|
|
|
52
|
+
if ((action === 'install' || action === 'uninstall') && codexOnly) {
|
|
53
|
+
const codex = await codexPart(action)
|
|
54
|
+
print({ action, dryRun, codexOnly: true, codex })
|
|
55
|
+
process.exit(codex.ok ? 0 : 2)
|
|
56
|
+
}
|
|
33
57
|
if (action === 'status') {
|
|
34
58
|
const status = hookStatus()
|
|
35
59
|
// 6.53.3: say in words what the state means and what fixes it (`script_outdated` after an
|
|
36
60
|
// update that changed the script, until Install hooks).
|
|
37
61
|
const advice = hookStatusAdvice(status)
|
|
38
|
-
print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...(advice ? { advice } : {}), ...status })
|
|
62
|
+
print({ action, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...(advice ? { advice } : {}), ...status, codex: await codexPart('status') })
|
|
39
63
|
process.exit(0)
|
|
40
64
|
}
|
|
41
65
|
if (action === 'install') {
|
|
42
66
|
if (!Number.isFinite(port) || port <= 0) { console.error('Bad --port'); process.exit(64) }
|
|
43
67
|
const result = installClaudeHooks({ port, deskIdleSeconds: Number.isFinite(deskIdleSeconds) ? deskIdleSeconds : 90, dryRun })
|
|
44
68
|
const cursorObserver = result.ok ? installCursorObserver({ dryRun }) : undefined
|
|
45
|
-
|
|
69
|
+
const codex = result.ok ? await codexPart('install') : undefined
|
|
70
|
+
print({ action, dryRun, serverApplies, ...(flagsNote ? { note: flagsNote } : {}), ...result, cursorObserver, codex })
|
|
46
71
|
process.exit(result.ok && cursorObserver?.ok ? 0 : 2)
|
|
47
72
|
}
|
|
48
73
|
if (action === 'uninstall') {
|
|
49
74
|
const result = uninstallClaudeHooks({ dryRun })
|
|
50
75
|
const cursorObserver = result.ok ? installCursorObserver({ dryRun, uninstall: true }) : undefined
|
|
51
|
-
|
|
76
|
+
const codex = result.ok ? await codexPart('uninstall') : undefined
|
|
77
|
+
print({ action, dryRun, ...result, cursorObserver, codex })
|
|
52
78
|
process.exit(result.ok && cursorObserver?.ok ? 0 : 2)
|
|
53
79
|
}
|
|
54
|
-
console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N]')
|
|
80
|
+
console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N] [--desk-idle-s N] [--codex]')
|
|
55
81
|
process.exit(64)
|