@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.
- package/CHANGELOG.md +22 -0
- package/README.md +15 -4
- package/package.json +1 -1
- package/server/index.ts +70 -7
- package/server/lib/attached-workspace.ts +53 -18
- package/server/lib/claude-session-registry.ts +19 -0
- package/server/lib/occupancy-probes.ts +43 -0
- package/server/lib/session-hook-events.ts +5 -0
- package/server/lib/session-hook-ledger.ts +7 -3
- package/server/lib/session-hook-spool.ts +11 -3
- package/server/lib/session-hooks-runtime.ts +114 -5
- package/server/lib/session-signal-store.ts +33 -5
- package/server/lib/session-state-derive.ts +38 -0
- package/server/lib/session-stream-bus.ts +93 -9
- package/server/lib/session-stream-events.ts +144 -0
- package/server/lib/thread-drain-kick.ts +153 -0
- package/server/lib/thread-occupancy.ts +36 -1
- package/server/lib/thread-turn-queue-deliver.ts +17 -6
- package/server/lib/thread-turn-queue-store.ts +70 -23
- package/server/lib/thread-turn-queue.ts +19 -3
- package/server/routes/agent-session-stream.ts +169 -11
- package/server/routes/agent-sessions.ts +34 -5
- package/server/routes/claude-sessions.ts +11 -17
- package/server/routes/session-hooks.ts +10 -0
- package/server/routes/thread-turn-queue.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 6.48.2
|
|
2
|
+
|
|
3
|
+
Continue and Fork work again on a session whose transcript opens with a big row. Reported from the lens on 6.9.482 (Miles, 2026-09-15): Continue on a Claude session answered "Can't continue. Fork thread instead" while the row said the session was attachable.
|
|
4
|
+
|
|
5
|
+
- The attach route reads the session's working directory out of the transcript's first rows. It read a fixed 512 KiB, and a session whose first prompt carried pasted screenshots opens with one row of about 1 MB, so the window held four bookkeeping rows and a torn fifth, no `cwd`, and every Continue and Fork on that session was refused `target_unresolvable`. The read now widens (512 KiB, 2 MiB, 8 MiB, 16 MiB) while the window keeps ending inside a row with no `cwd` seen, and stops as soon as the whole file has been read or a row boundary shows the transcript records none.
|
|
6
|
+
- The first `cwd` in the transcript is the workspace. Two different values used to refuse the attach as a disagreement; on the release Mac every such transcript was one session whose shell had `cd`-ed into a subfolder inside a turn, and Claude stamps each row with the process cwd of the moment. The directory the session started in keys its project folder and is the only place `--resume` finds it, so it is the answer. On the release Mac 738 of 758 transcripts resolve (before: 703); the 20 that do not are scratch sessions whose temporary directory has since been deleted, which is a refusal by design.
|
|
7
|
+
- Tests: the first-cwd rule for Claude and Codex, the torn-window signal, the widening sequence against the production shape (four bookkeeping rows, a 950 KB image row, then the cwd), a short file read once, a first row past the cap refused at the cap. Mutation gate: the widening and the first-cwd rule.
|
|
8
|
+
|
|
9
|
+
## 6.48.1
|
|
10
|
+
|
|
11
|
+
One derivation everywhere, a Desktop turn ends when the engine says it ended, a queued follow-up drains then instead of on the 30 s idle window, and the live feed carries the same state every row does. No client change is required: the next Control and EHPK builds read the new keys, and today's consumers ignore them.
|
|
12
|
+
|
|
13
|
+
- A queued follow-up is delivered when the turn ends, and "ends" means what the engine means. Claude Code does not close a turn at the `Stop` hook: it closes it when every Stop hook has RETURNED, and only then dequeues anything typed meanwhile and flips its registry record to `idle`. On the release Mac the COS repo's own Stop hooks run 12-38 s, so a delivery at Stop + 2 s would have raced a prompt typed at the desk. The drain now waits for the registry's `idle` stamped at or after the Stop (polled every second, 60 s at most), then sweeps through one in-flight guard shared with the 20 s timer, so a burst of Stops folds into exactly one follow-up pass and a sentence is never delivered twice. A kick is spent only when a queue names that session; a COS child's own end kicks at once so chained follow-ups do not wait for the timer. Measured on the release Mac against a live holder whose own Stop hook ran 25 s: the registry flipped idle 25.0 s after the Stop hook, the follow-up was delivered 185 ms after that flip (its user row landed 1.5 s later, once, answered once, in the holder's own transcript, with the holder still open). Before this release: 35 s or more from the turn's end, and a delivery during the Stop hooks was possible.
|
|
14
|
+
- The attach gate reads the same end of turn. With `COS_SESSION_HOOKS` on, a foreign Desktop holder whose newest hook event is a `Stop`, with the turn closed, no sub-agent open, and the registry idle at or after that Stop, reads idle at once instead of after the 30 s window. Null, unknown, a record that says busy, Codex and a far-future clock stay strict; the clause narrows a `working` verdict and never lifts a doubt. A COS Continue child registers under the tab's session id: when two records name the session the tab's is read, the child's only when it is alone.
|
|
15
|
+
- The transcript's own rule for "the turn ended", used where the hooks did not see the session: the newest assistant record's `stop_reason` is terminal (end_turn, stop_sequence, max_tokens, refusal) with no `tool_use` still awaiting its result, a `result` row, or a user interrupt; a new prompt reopens it, a `<task-notification>` and a cross-session message included; a prompt queued during the Stop hooks (Claude's `queue-operation` row) keeps it open; other meta and compact rows do not. The tail read is 512 KiB: 3 of the 10 newest Desktop transcripts on the release Mac end in a ~76 KB attachment row after the terminal record, which a 64 KiB read could not see past. Positive evidence that a turn is OPEN (the hooks, or the tail) now holds ahead of the tail's ended verdict and ahead of the idle backstop, each bounded to the last 30 minutes so a crashed session still drains.
|
|
16
|
+
- Every `status` draft on the session stream carries `agent_state`, `state_source`, `state_since` and, when known, `waiting_kind`, `waiting_detail`, `failure`, and on an idle state `last_reply`, as extra fields on the existing kind; `state` keeps its vocabulary (running and waiting read `working`, idle and failed `idle`, ended `done`). Every change of a session's hook state is a live status draft, so a client's state line moves on the engine's events. The stream derives from the same inputs the rows do (the hook signal, the registry record, and whether a COS turn is attached), so an Esc-interrupted turn reads idle on the lens the moment the list says so, and a Continue's trail reads `working` for as long as the child writes, never `done` from a stale Stop or from the child's own end. A shipped lens renders the frames exactly as before (its parser keeps `seq` and strips unknown fields). `COS_SESSION_HOOK_SSE=0` omits the extra fields and the live state drafts.
|
|
17
|
+
- Reconnect without a reseed: each streamed session keeps a ring of its last 500 events (the active turn kept whole up to 2,000, the ring outliving the last subscriber by five minutes), each event carries a `cursor` and the server's `epoch`, and the SSE `id:` line is `<epoch>.<cursor>`. `?after=` (or `Last-Event-ID`) replays what the ring holds after that cursor, from the same server life only; a cursor from another life, one past the ring, and one with nothing after it all seed as a fresh open, so a reconnect is never an empty screen. `?seed=turn` opens a session at its current prompt and every step since, oldest first, bounded by the same 512 KiB tail read. The default seed is unchanged.
|
|
18
|
+
- `queued_turns` on every `/api/agent-sessions` row (list, search, detail) and every `/api/claude-sessions` peer: the waiting follow-ups on that thread, omitted at 0. Search hits carry the same derived state fields the list does.
|
|
19
|
+
- Two 6.48.0 defects: a tab adopted mid-turn read idle from a `PostToolUse` with no prompt seen (Claude 2.1.272 reloads hooks when the settings change, so open tabs report without a restart); a main-thread tool event now opens the turn, and a sub-agent's (`agent_id`) does not, live or replayed. `GET /api/session-hooks/runs` listed an interactive tab as a run with no end; runs carry the registry `entrypoint` (`sdk-cli` for `claude -p`, `claude-desktop`, `cli`), the ledger row remembers it so a restart cannot forget which sessions were tabs, and tabs are listed only with `?all=1`.
|
|
20
|
+
- Two defects the live proof found in the follow-up path, both older than 6.48.0. The drainer's loopback delivery sent a turn body without `epoch` and `targetKey`, which the turns route has required since the binding hardening, so every drained follow-up was refused `invalid_request`, refunded and held for its whole TTL: it now sends the epoch the attach minted, the server's own target key and the attach's `boundTo`. And the 30 s idle backstop delivered a follow-up INTO a turn whose tool was still running (a 45 s tool leaves the transcript untouched for 45 s): the open-turn evidence above holds it. A queued turn whose `cosSessionId` the attach route would refuse is refused at enqueue instead of sitting in the queue.
|
|
21
|
+
- `/api/health` `sessionHooks.drain` reports the kick's passes, kicks, folds and open waits.
|
|
22
|
+
|
|
1
23
|
## 6.48.0
|
|
2
24
|
|
|
3
25
|
Session state now comes from Claude Code itself. A hook script spools every session event (session start and end, prompt submitted, turn stopped, permission wanted) and every session row carries one derived state with its provenance. No client change is required: Control 0.5.230 and glasses 6.9.478 ignore the new keys; Control 0.5.231 and the next EHPK read them.
|
package/README.md
CHANGED
|
@@ -181,14 +181,25 @@ The install keeps every hook you already had, backs the file up, and copies a sm
|
|
|
181
181
|
POSIX sh script to `~/.cos-glasses/bin/cos-session-hook`. The script writes one file per
|
|
182
182
|
event into `~/.cos-glasses/data/hook-spool` and never contacts the server (a permission
|
|
183
183
|
request may, only after the desk has been idle for 90 s, for the approval feature that ships
|
|
184
|
-
next). Sessions
|
|
185
|
-
`/api/
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
next). Sessions report `state_source: hook` on `/api/agent-sessions` and
|
|
185
|
+
`/api/claude-sessions` from their next event on (Claude Code 2.1.272 reloads its hooks
|
|
186
|
+
when the settings file changes, so open tabs need no restart; they may show Claude's
|
|
187
|
+
"hooks modified externally" notice once, which is expected: the user-level file changed
|
|
188
|
+
under them). A later COS Control offers the same install from its Sessions tab. Rows
|
|
189
|
+
change only while the server runs with `COS_CLAUDE_SESSIONS_ENABLED=1`
|
|
188
190
|
(`--hooks status` prints `serverApplies`). Turn the ingestion off with `COS_SESSION_HOOKS=0`
|
|
189
191
|
(the spool is still drained and stamped, rows are exactly as before). Removing the package
|
|
190
192
|
does not remove the hooks: run `--hooks uninstall` first.
|
|
191
193
|
|
|
194
|
+
Since 6.48.1 the hooks also drive the follow-up queue (a queued Continue lands when the
|
|
195
|
+
engine closes the turn: its `Stop` hook followed by its registry record flipping idle,
|
|
196
|
+
which is when every Stop hook has returned), the attach gate (a Desktop holder whose
|
|
197
|
+
turn just ended reads idle at once), and the live session stream (every `status` draft
|
|
198
|
+
carries `agent_state` and friends; `?after=<epoch>.<cursor>` replays what the ring holds
|
|
199
|
+
after a reconnect and seeds afresh when it holds nothing, `?seed=turn` opens at the
|
|
200
|
+
current prompt). `COS_SESSION_HOOK_SSE=0` omits the extra status fields and the live
|
|
201
|
+
state drafts; the `cursor`, `epoch` and `id:` line stay.
|
|
202
|
+
|
|
192
203
|
## Configuration
|
|
193
204
|
|
|
194
205
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.48.
|
|
3
|
+
"version": "6.48.2",
|
|
4
4
|
"description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/server/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
|
|
|
23
23
|
import { createAttachedTurnStream } from './lib/session-stream-producer.js'
|
|
24
24
|
import { claudeSessionsRouter } from './routes/claude-sessions.js'
|
|
25
25
|
import { createSessionHooksRouter } from './routes/session-hooks.js'
|
|
26
|
-
import { startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
26
|
+
import { registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
|
|
27
27
|
import {
|
|
28
28
|
createAgentSessionBindingsRouter,
|
|
29
29
|
TargetGuard,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { AgentSessionBindingRegistry } from './lib/agent-session-binding-registry.js'
|
|
34
34
|
import { targetKey } from './lib/agent-session-binding-store.js'
|
|
35
35
|
import { cosSpawnedPids } from './lib/agent-session-ownership-store.js'
|
|
36
|
-
import { buildOccupancyProbes, realOccupancyDirs } from './lib/occupancy-probes.js'
|
|
36
|
+
import { buildOccupancyProbes, realOccupancyDirs, withHookTurnClock } from './lib/occupancy-probes.js'
|
|
37
37
|
import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './lib/attached-workspace.js'
|
|
38
38
|
import { deliverAttachedTurn, realAttachedTurnDeps } from './lib/attached-provider-adapter.js'
|
|
39
39
|
import { forkThread, realForkDeps } from './lib/fork-thread.js'
|
|
@@ -129,7 +129,9 @@ import {
|
|
|
129
129
|
|
|
130
130
|
const app = express()
|
|
131
131
|
import { createThreadTurnQueueRouter, drainAllThreads } from './routes/thread-turn-queue.js'
|
|
132
|
-
import { transcriptTurnEnded } from './lib/thread-turn-queue-store.js'
|
|
132
|
+
import { queuedThreadKeys, transcriptTurnEnded, transcriptTurnVerdict } from './lib/thread-turn-queue-store.js'
|
|
133
|
+
import { OPEN_TURN_CEILING_MS } from './lib/session-state-derive.js'
|
|
134
|
+
import { createDrainKick, kickPlanFor } from './lib/thread-drain-kick.js'
|
|
133
135
|
import { transcriptPathFor } from './lib/native-head.js'
|
|
134
136
|
import { deliverQueuedTurnOverLoopback } from './lib/thread-turn-queue-deliver.js'
|
|
135
137
|
import { readFences, writeFences } from './lib/thread-fence-store.js'
|
|
@@ -382,7 +384,12 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
|
|
|
382
384
|
* Read the canary evidence in `thread-occupancy.ts` under THE IDLE-HOLDER
|
|
383
385
|
* RELAXATION before changing this line.
|
|
384
386
|
*/
|
|
385
|
-
const occupancyProbes =
|
|
387
|
+
const occupancyProbes = sessionHooksEnabled()
|
|
388
|
+
// 6.48.1, the B6 clause: with the hooks on, a foreign Desktop holder whose newest hook
|
|
389
|
+
// event is a Stop reads idle at once instead of after the 30 s transcript window. The
|
|
390
|
+
// wrapper only adds a probe; the gate's precedence is unchanged. Off is 6.48.0.
|
|
391
|
+
? withHookTurnClock(buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled()), signalFor, registryIdleAfterStop)
|
|
392
|
+
: buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
|
|
386
393
|
|
|
387
394
|
// 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
|
|
388
395
|
// before any router is registered so the first list request already sees the replayed
|
|
@@ -622,11 +629,44 @@ if (threadAttachEnabled()) {
|
|
|
622
629
|
turnEnded: (provider: string, threadId: string) => {
|
|
623
630
|
if (provider !== 'claude' && provider !== 'codex') return false
|
|
624
631
|
try {
|
|
632
|
+
// 6.48.1: the engine's own end of turn: a Stop hook newer than the turn's prompt
|
|
633
|
+
// AND the registry flipped idle after it (the Stop hooks have returned; a prompt
|
|
634
|
+
// typed during them is queued and dequeues only then). The transcript tail
|
|
635
|
+
// (`turnFromTail`) answers for everything the hooks did not see. Both refuse on doubt.
|
|
636
|
+
if (provider === 'claude') {
|
|
637
|
+
const signal = signalFor(threadId)
|
|
638
|
+
if (signal && !signal.turnOpen && signal.lastEvent === 'Stop' && typeof signal.stopAt === 'number'
|
|
639
|
+
&& signal.stopAt >= (signal.turnStartedAt ?? 0) && signal.subagentsOpen === 0
|
|
640
|
+
&& registryIdleAfterStop(threadId, signal.stopAt) === true) return true
|
|
641
|
+
}
|
|
625
642
|
return transcriptTurnEnded(provider, transcriptPathFor(provider, threadId, nativeHeadDeps))
|
|
626
643
|
} catch {
|
|
627
644
|
return false
|
|
628
645
|
}
|
|
629
646
|
},
|
|
647
|
+
// 6.48.1: positive evidence the holder's turn is OPEN, which outranks the 30 s idle
|
|
648
|
+
// backstop (a long tool leaves the transcript untouched while it runs). Bounded: a
|
|
649
|
+
// signal or a tail older than OPEN_TURN_CEILING_MS is not evidence any more, so a
|
|
650
|
+
// crashed mid-tool session still drains by the backstop rather than holding for the TTL.
|
|
651
|
+
turnOpen: (provider: string, threadId: string): boolean => {
|
|
652
|
+
if (provider !== 'claude' && provider !== 'codex') return false
|
|
653
|
+
try {
|
|
654
|
+
const now = Date.now()
|
|
655
|
+
if (provider === 'claude') {
|
|
656
|
+
const signal = signalFor(threadId)
|
|
657
|
+
if (signal && signal.turnOpen && now - signal.lastEventAt <= OPEN_TURN_CEILING_MS) return true
|
|
658
|
+
}
|
|
659
|
+
const path = transcriptPathFor(provider, threadId, nativeHeadDeps)
|
|
660
|
+
const verdict = transcriptTurnVerdict(provider, path)
|
|
661
|
+
if (!verdict || verdict.ended) return false
|
|
662
|
+
if (verdict.reason !== 'tool_pending' && verdict.reason !== 'prompt_open') return false
|
|
663
|
+
const read = occupancyProbes.transcriptMtimeMs
|
|
664
|
+
const mtime = typeof read === 'function' ? read(provider, threadId) : null
|
|
665
|
+
return typeof mtime === 'number' && now - mtime <= OPEN_TURN_CEILING_MS
|
|
666
|
+
} catch {
|
|
667
|
+
return false
|
|
668
|
+
}
|
|
669
|
+
},
|
|
630
670
|
activity: (provider: string, threadId: string): 'working' | 'idle' | 'unknown' => {
|
|
631
671
|
try {
|
|
632
672
|
const read = occupancyProbes.transcriptMtimeMs
|
|
@@ -645,10 +685,33 @@ if (threadAttachEnabled()) {
|
|
|
645
685
|
// Every 20s. Fast enough that a freed thread drains while the user is still looking
|
|
646
686
|
// at the pending row, slow enough to be nothing: the sweep does no work at all when
|
|
647
687
|
// no queue file exists. Unref'd so it never holds the process open.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
688
|
+
//
|
|
689
|
+
// 6.48.1: the timer and the hooks' Stop share ONE in-flight guard (`thread-drain-kick`),
|
|
690
|
+
// so a Stop landing mid-sweep folds into exactly one follow-up pass and a sentence is
|
|
691
|
+
// never delivered twice. A Stop kicks only when a queue file names that session. A
|
|
692
|
+
// SubagentStop does NOT kick (QA, 2026-09-15): a background sub-agent finishing after
|
|
693
|
+
// the tab's Stop is answered by the model in a turn of its own, whose Stop kicks; and
|
|
694
|
+
// neither the hook short-circuit nor the B6 clause vouches for a session whose newest
|
|
695
|
+
// event is a SubagentStop, so a kick there could only fall through to the 30 s backstop.
|
|
696
|
+
const drainKick = createDrainKick({
|
|
697
|
+
drain: () => drainAllThreads(queueDeps),
|
|
698
|
+
queuedThreadIds: () => queuedThreadKeys().map(k => k.threadId),
|
|
699
|
+
})
|
|
700
|
+
registerDrainKickStats(() => drainKick.stats())
|
|
701
|
+
const queueDrainTimer = setInterval(() => { void drainKick.sweep() }, 20_000)
|
|
651
702
|
queueDrainTimer.unref()
|
|
703
|
+
if (sessionHooksEnabled()) {
|
|
704
|
+
sessionSignalStore.subscribe((signal, env, child) => {
|
|
705
|
+
// The rule is `kickPlanFor` (pure, pinned in thread-drain-kick.test.ts). The Stop is
|
|
706
|
+
// not the engine's end of turn: that is the registry flipping idle once every Stop
|
|
707
|
+
// hook has returned (12-38 s on this Mac). Wait for it, then sweep; a session with
|
|
708
|
+
// no registry record (a print run that exited) is ready at once.
|
|
709
|
+
const plan = kickPlanFor(signal, env.event, env.ts, child)
|
|
710
|
+
if (!plan) return
|
|
711
|
+
if (plan.kind === 'kick') drainKick.kick(signal.sessionId)
|
|
712
|
+
else drainKick.kickWhen(signal.sessionId, () => registryIdleAfterStop(signal.sessionId, plan.stopAt) !== false)
|
|
713
|
+
})
|
|
714
|
+
}
|
|
652
715
|
}
|
|
653
716
|
|
|
654
717
|
app.use('/api', createAgentSessionBindingsRouter({
|
|
@@ -61,9 +61,20 @@ export interface AttachedWorkspaceDeps {
|
|
|
61
61
|
cursorSpawnWorkspace?: (threadId: string) => string | null
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* The first window read for the cwd: enough to reach a Claude message row or the Codex
|
|
66
|
+
* session meta row in the common case. NOT enough on its own (6.48.2): a session whose
|
|
67
|
+
* first prompt carries pasted screenshots opens with a single row of ~1 MB, so 512 KiB
|
|
68
|
+
* held four bookkeeping rows and a torn fifth, no cwd, and every Continue on that
|
|
69
|
+
* session was refused `target_unresolvable` (Miles, 2026-09-15, from the lens; 55 of
|
|
70
|
+
* 758 transcripts on the release Mac). When a window ends inside a row before any cwd
|
|
71
|
+
* was seen, the read widens up to WORKSPACE_SCAN_BYTES_MAX.
|
|
72
|
+
*/
|
|
65
73
|
export const WORKSPACE_SCAN_BYTES = 512 * 1024
|
|
66
74
|
|
|
75
|
+
/** The widest head read: a first prompt with ten screenshots fits; a 13 GB rollout does not get read. */
|
|
76
|
+
export const WORKSPACE_SCAN_BYTES_MAX = 16 * 1024 * 1024
|
|
77
|
+
|
|
67
78
|
/** Rows to inspect before giving up. Bounds a pathological single-line file. */
|
|
68
79
|
export const WORKSPACE_SCAN_ROWS = 400
|
|
69
80
|
|
|
@@ -72,19 +83,37 @@ export function fingerprint(value: string): string {
|
|
|
72
83
|
}
|
|
73
84
|
|
|
74
85
|
/**
|
|
75
|
-
* Pull the recorded cwd out of transcript text.
|
|
86
|
+
* Pull the recorded cwd out of transcript text: the FIRST row that records one.
|
|
76
87
|
*
|
|
77
88
|
* Claude puts it at the row's top level; Codex puts it in the session meta row's
|
|
78
89
|
* `payload`. A torn trailing line is NORMAL — these files are appended to — so a
|
|
79
90
|
* parse failure on any single row is skipped rather than fatal.
|
|
80
91
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
92
|
+
* THE FIRST cwd IS THE WORKSPACE (6.48.2). Until 6.48.1 two different values in the
|
|
93
|
+
* window refused the attach as a disagreement. On the release Mac every such
|
|
94
|
+
* transcript was one session whose shell `cd`-ed inside a turn (`operations/scripts`,
|
|
95
|
+
* a weekly folder): Claude stamps each row with the process cwd of the moment, all
|
|
96
|
+
* under the directory the session was started in. That starting directory is the
|
|
97
|
+
* workspace: it keys the transcript's project folder, it is the only place
|
|
98
|
+
* `claude --resume` finds the session, and it is what the first row records.
|
|
99
|
+
*
|
|
100
|
+
* Returns null only when no row in the window records one.
|
|
83
101
|
*/
|
|
84
102
|
export function cwdFromTranscript(text: string): string | null {
|
|
85
|
-
|
|
103
|
+
return scanForCwd(text).cwd
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The scan with its outcome: the cwd, or whether the window ended INSIDE a row before
|
|
108
|
+
* any cwd was seen (a first prompt of pasted screenshots is one row of ~1 MB), which
|
|
109
|
+
* tells the resolver that a wider read can still answer.
|
|
110
|
+
*/
|
|
111
|
+
export function scanForCwd(text: string): { cwd: string | null; endedMidRow: boolean; rows: number } {
|
|
86
112
|
let rows = 0
|
|
87
|
-
|
|
113
|
+
const lines = text.split('\n')
|
|
114
|
+
// A window that does not end with a newline ended inside whatever row came last.
|
|
115
|
+
const endedMidRow = text.length > 0 && !text.endsWith('\n')
|
|
116
|
+
for (const line of lines) {
|
|
88
117
|
if (rows >= WORKSPACE_SCAN_ROWS) break
|
|
89
118
|
const trimmed = line.trim()
|
|
90
119
|
if (!trimmed) continue
|
|
@@ -94,18 +123,14 @@ export function cwdFromTranscript(text: string): string | null {
|
|
|
94
123
|
if (!row || typeof row !== 'object' || Array.isArray(row)) continue
|
|
95
124
|
const record = row as Record<string, unknown>
|
|
96
125
|
const direct = record.cwd
|
|
97
|
-
if (typeof direct === 'string' && direct.length > 0)
|
|
126
|
+
if (typeof direct === 'string' && direct.length > 0) return { cwd: direct, endedMidRow: false, rows }
|
|
98
127
|
const payload = record.payload
|
|
99
128
|
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
100
129
|
const nested = (payload as Record<string, unknown>).cwd
|
|
101
|
-
if (typeof nested === 'string' && nested.length > 0)
|
|
130
|
+
if (typeof nested === 'string' && nested.length > 0) return { cwd: nested, endedMidRow: false, rows }
|
|
102
131
|
}
|
|
103
|
-
// One consistent answer found early is enough; keep scanning only while it is
|
|
104
|
-
// still the only one, so a disagreement is detected rather than short-circuited.
|
|
105
|
-
if (seen.size > 1) return null
|
|
106
132
|
}
|
|
107
|
-
|
|
108
|
-
return [...seen][0]!
|
|
133
|
+
return { cwd: null, endedMidRow, rows }
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
/**
|
|
@@ -145,17 +170,27 @@ export function resolveAttachedWorkspace(
|
|
|
145
170
|
}
|
|
146
171
|
if (provider !== 'claude' && provider !== 'codex') return null
|
|
147
172
|
let path: string | null
|
|
148
|
-
let
|
|
173
|
+
let cwd: string | null = null
|
|
149
174
|
try {
|
|
150
175
|
path = deps.transcriptPath(provider, threadId)
|
|
151
176
|
if (path === null) return null
|
|
152
|
-
|
|
177
|
+
// Widen the head read while the window keeps ending inside a row with no cwd seen:
|
|
178
|
+
// 512 KiB, 2 MiB, 8 MiB, then the cap. A window that ended on a row boundary with
|
|
179
|
+
// no cwd is a transcript that records none, and widening would not change that.
|
|
180
|
+
let window = WORKSPACE_SCAN_BYTES
|
|
181
|
+
for (;;) {
|
|
182
|
+
const text = deps.readHead(path, window)
|
|
183
|
+
if (text === null) return null
|
|
184
|
+
const scan = scanForCwd(text)
|
|
185
|
+
cwd = scan.cwd
|
|
186
|
+
// The whole file came back (shorter than the window): there is nothing wider to read.
|
|
187
|
+
const wholeFile = Buffer.byteLength(text, 'utf8') < window
|
|
188
|
+
if (cwd !== null || wholeFile || !scan.endedMidRow || scan.rows >= WORKSPACE_SCAN_ROWS || window >= WORKSPACE_SCAN_BYTES_MAX) break
|
|
189
|
+
window = Math.min(window * 4, WORKSPACE_SCAN_BYTES_MAX)
|
|
190
|
+
}
|
|
153
191
|
} catch {
|
|
154
192
|
return null
|
|
155
193
|
}
|
|
156
|
-
if (text === null) return null
|
|
157
|
-
|
|
158
|
-
const cwd = cwdFromTranscript(text)
|
|
159
194
|
if (cwd === null) return null
|
|
160
195
|
// A relative cwd would resolve against the SERVER's working directory, which is
|
|
161
196
|
// not a location we control and is never what the user meant.
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
// nameSource entirely. Only `derived` is a function of the folder name.
|
|
30
30
|
|
|
31
31
|
/** Fields this module will read. Everything else in the file is ignored. */
|
|
32
|
+
import { homedir } from 'node:os'
|
|
33
|
+
import { join, resolve } from 'node:path'
|
|
34
|
+
|
|
32
35
|
export interface RawClaudeSession {
|
|
33
36
|
pid?: unknown
|
|
34
37
|
sessionId?: unknown
|
|
@@ -98,6 +101,22 @@ export interface PeerProbes {
|
|
|
98
101
|
/** A registry filename is exactly `<pid>.json`. Not `*.json`. */
|
|
99
102
|
export const REGISTRY_FILENAME = /^\d+\.json$/
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Where the registry lives.
|
|
106
|
+
*
|
|
107
|
+
* `COS_CLAUDE_SESSIONS_DIR` first because it is both the override for a non-standard
|
|
108
|
+
* install AND the test seam: `homedir()` is not mockable, so without an env hook the
|
|
109
|
+
* only testable path would be the real one. Then CLAUDE_CONFIG_DIR, which real
|
|
110
|
+
* installs do set; hardcoding ~/.claude breaks those. (Moved here from the route in
|
|
111
|
+
* 6.48.1 so the hooks runtime can read it without importing a router.)
|
|
112
|
+
*/
|
|
113
|
+
export function claudeSessionsDir(): string {
|
|
114
|
+
const explicit = process.env.COS_CLAUDE_SESSIONS_DIR
|
|
115
|
+
if (explicit) return resolve(explicit)
|
|
116
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR
|
|
117
|
+
return join(configDir ? resolve(configDir) : join(homedir(), '.claude'), 'sessions')
|
|
118
|
+
}
|
|
119
|
+
|
|
101
120
|
function str(value: unknown): string | null {
|
|
102
121
|
return typeof value === 'string' && value.length > 0 ? value : null
|
|
103
122
|
}
|
|
@@ -787,6 +787,49 @@ export function buildOccupancyProbes(
|
|
|
787
787
|
* adapter's pre-spawn preflight REFUSES a thenable rather than awaiting one —
|
|
788
788
|
* awaiting there would reopen the very race the check exists to close.
|
|
789
789
|
*/
|
|
790
|
+
/**
|
|
791
|
+
* Add the hook turn clock (the B6 clause, 6.48.1) to a probe set.
|
|
792
|
+
*
|
|
793
|
+
* `signalFor` is the signal store's reader, taking a full session id or the registry's
|
|
794
|
+
* 8-character form; `registryIdleAfterStop` reads the registry record for the session.
|
|
795
|
+
* The probe answers a Stop time only when BOTH vouch: the session's newest hook event is
|
|
796
|
+
* that Stop, the turn is closed, no sub-agent is open, the session has not ended, and
|
|
797
|
+
* the registry says idle at or after the Stop (the hooks have returned). Codex never
|
|
798
|
+
* asks (its ids are not Claude sessions, and a Codex thread id sharing a Claude prefix
|
|
799
|
+
* must not read a Claude signal); `readHolderActivity` guards the provider too.
|
|
800
|
+
*
|
|
801
|
+
* Wired at the composition root only when `COS_SESSION_HOOKS` is on. Absent, the
|
|
802
|
+
* gate is byte-for-byte 6.48.0.
|
|
803
|
+
*/
|
|
804
|
+
export function withHookTurnClock(
|
|
805
|
+
probes: OccupancyProbes,
|
|
806
|
+
signalFor: (sessionId: string) => HookTurnSignal | undefined,
|
|
807
|
+
registryIdleAfterStop: (sessionId: string, stopAt: number) => boolean | null,
|
|
808
|
+
): OccupancyProbes {
|
|
809
|
+
return {
|
|
810
|
+
...probes,
|
|
811
|
+
holderTurnEndedAtMs: (provider, threadId) => {
|
|
812
|
+
if (provider !== 'claude') return null
|
|
813
|
+
if (!NATIVE_THREAD_ID_RE.test(threadId)) return null
|
|
814
|
+
const signal = signalFor(threadId)
|
|
815
|
+
if (!signal || signal.lastEvent !== 'Stop' || signal.turnOpen || signal.subagentsOpen > 0 || signal.ended !== null) return null
|
|
816
|
+
if (typeof signal.stopAt !== 'number') return null
|
|
817
|
+
// The engine's own end of turn: the registry flipped idle AFTER this Stop, which
|
|
818
|
+
// happens only once every Stop hook has returned. Anything else is strict.
|
|
819
|
+
return registryIdleAfterStop(threadId, signal.stopAt) === true ? signal.stopAt : null
|
|
820
|
+
},
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** The facts the hook turn clock reads off a session signal. */
|
|
825
|
+
export interface HookTurnSignal {
|
|
826
|
+
lastEvent: string
|
|
827
|
+
turnOpen: boolean
|
|
828
|
+
subagentsOpen: number
|
|
829
|
+
ended: { at: number; reason: string } | null
|
|
830
|
+
stopAt: number | null
|
|
831
|
+
}
|
|
832
|
+
|
|
790
833
|
export function withTranscriptClock(
|
|
791
834
|
probes: OccupancyProbes,
|
|
792
835
|
headDeps: NativeHeadDeps,
|
|
@@ -182,6 +182,11 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
|
|
|
182
182
|
tool_target: toolTarget(payload.tool_input),
|
|
183
183
|
tool_fingerprint: toolFingerprint(toolName, payload.tool_input ?? null),
|
|
184
184
|
...(str('tool_use_id') ? { tool_use_id: str('tool_use_id') } : {}),
|
|
185
|
+
// A sub-agent's tool events carry these (docs; unrecorded as of 2026-09-15). The
|
|
186
|
+
// store reads `agent_id` to keep them from reopening the main turn, so the ledger
|
|
187
|
+
// must keep it or a replay reopens what the live path did not.
|
|
188
|
+
...(str('agent_id') ? { agent_id: str('agent_id') } : {}),
|
|
189
|
+
...(str('agent_type') ? { agent_type: str('agent_type') } : {}),
|
|
185
190
|
...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
|
|
186
191
|
? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
|
|
187
192
|
: {}),
|
|
@@ -20,6 +20,8 @@ export interface LedgerRow {
|
|
|
20
20
|
session_id: string
|
|
21
21
|
/** Classified at ingest, while the pid was alive: a COS-spawned child's event. */
|
|
22
22
|
child?: boolean
|
|
23
|
+
/** The registry's `entrypoint` for the session, once read (6.48.1); a replay restores it. */
|
|
24
|
+
entrypoint?: string
|
|
23
25
|
payload: Record<string, unknown>
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -54,7 +56,7 @@ export class SessionHookLedger {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/** Append one row. Returns false (and remembers why) when the disk refused. */
|
|
57
|
-
append(key: string, env: HookEnvelope, child = false): boolean {
|
|
59
|
+
append(key: string, env: HookEnvelope, child = false, entrypoint: string | null = null): boolean {
|
|
58
60
|
let row: LedgerRow
|
|
59
61
|
try {
|
|
60
62
|
row = {
|
|
@@ -64,6 +66,7 @@ export class SessionHookLedger {
|
|
|
64
66
|
event: env.event,
|
|
65
67
|
session_id: env.sessionId,
|
|
66
68
|
...(child ? { child: true } : {}),
|
|
69
|
+
...(entrypoint ? { entrypoint } : {}),
|
|
67
70
|
payload: projectHookPayload(env.event, env.payload),
|
|
68
71
|
}
|
|
69
72
|
} catch (error) {
|
|
@@ -96,7 +99,7 @@ export class SessionHookLedger {
|
|
|
96
99
|
* hide the rows that matter most, and forget their keys). Malformed lines are skipped;
|
|
97
100
|
* a boot must never fail on a torn last line.
|
|
98
101
|
*/
|
|
99
|
-
replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean) => void): { rows: number; applied: number; keys: Set<string> } {
|
|
102
|
+
replay(sinceMs: number, apply: (env: HookEnvelope, key: string, child: boolean, entrypoint: string | null) => void): { rows: number; applied: number; keys: Set<string> } {
|
|
100
103
|
const keys = new Set<string>()
|
|
101
104
|
let rows = 0
|
|
102
105
|
let applied = 0
|
|
@@ -115,7 +118,8 @@ export class SessionHookLedger {
|
|
|
115
118
|
keys.add(r.key)
|
|
116
119
|
if (r.ts < sinceMs) continue
|
|
117
120
|
const payload = r.payload && typeof r.payload === 'object' ? r.payload as Record<string, unknown> : {}
|
|
118
|
-
|
|
121
|
+
const entrypoint = typeof r.entrypoint === 'string' && r.entrypoint ? r.entrypoint : null
|
|
122
|
+
apply({ ts: r.ts, ppid: typeof r.ppid === 'number' ? r.ppid : null, event: r.event, sessionId: r.session_id.toLowerCase(), payload: { session_id: r.session_id, ...payload } }, r.key, r.child === true, entrypoint)
|
|
119
123
|
applied++
|
|
120
124
|
}
|
|
121
125
|
return { rows, applied, keys }
|
|
@@ -45,9 +45,15 @@ export interface SpoolIngesterOptions {
|
|
|
45
45
|
dir: string
|
|
46
46
|
ledger: SessionHookLedger
|
|
47
47
|
/** Apply a parsed envelope to the store. Absent when the feature is off. */
|
|
48
|
-
apply?: (env: HookEnvelope, child: boolean) => void
|
|
48
|
+
apply?: (env: HookEnvelope, child: boolean, entrypoint: string | null) => void
|
|
49
49
|
/** Classify the envelope's ppid at INGEST time, while the pid is still alive. */
|
|
50
50
|
isChild?: (env: HookEnvelope) => boolean
|
|
51
|
+
/**
|
|
52
|
+
* The session's registry `entrypoint`, read at INGEST time while the record is alive
|
|
53
|
+
* (6.48.1). Written onto the ledger row so a replay after a restart knows a print run
|
|
54
|
+
* from a tab without a registry that has since been reaped.
|
|
55
|
+
*/
|
|
56
|
+
entrypointOf?: (env: HookEnvelope, child: boolean) => string | null
|
|
51
57
|
/** Keys already in the ledger at boot, so a replayed file is not applied twice. */
|
|
52
58
|
seenKeys?: Set<string>
|
|
53
59
|
sweepMs?: number
|
|
@@ -175,15 +181,17 @@ export function startSpoolIngester(options: SpoolIngesterOptions): SpoolIngester
|
|
|
175
181
|
// replay after a restart (when the spawn ledger is empty) applies the same rule.
|
|
176
182
|
let child = false
|
|
177
183
|
try { child = options.isChild?.(env) ?? false } catch { child = false }
|
|
184
|
+
let entrypoint: string | null = null
|
|
185
|
+
try { entrypoint = options.entrypointOf?.(env, child) ?? null } catch { entrypoint = null }
|
|
178
186
|
let appended = false
|
|
179
|
-
try { appended = ledger.append(name, env, child) } catch (error) { stats.lastError = errorCode(error) }
|
|
187
|
+
try { appended = ledger.append(name, env, child, entrypoint) } catch (error) { stats.lastError = errorCode(error) }
|
|
180
188
|
if (!appended) {
|
|
181
189
|
// Keep the file: the ledger is the durable record and it refused. Health shows why.
|
|
182
190
|
stats.lastError = ledger.stats().lastError ?? stats.lastError
|
|
183
191
|
return false
|
|
184
192
|
}
|
|
185
193
|
seen.add(name)
|
|
186
|
-
try { options.apply?.(env, child) } catch (error) {
|
|
194
|
+
try { options.apply?.(env, child, entrypoint) } catch (error) {
|
|
187
195
|
stats.applyErrors++
|
|
188
196
|
console.error(`[hook-spool] apply failed for ${name}: ${error instanceof Error ? error.message : error}`)
|
|
189
197
|
}
|