@gotcos/glasses-server 6.58.1 → 6.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +4 -0
- package/docs/work-task-runtime.md +16 -6
- package/package.json +4 -2
- package/server/index.ts +12 -1
- package/server/lib/agent-session-store.ts +11 -1
- package/server/lib/claude-bridge.ts +32 -1
- package/server/lib/claude-cli-support.ts +37 -0
- package/server/lib/query-job-coordinator.ts +7 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/query-job-types.ts +51 -1
- package/server/lib/work-activity.ts +377 -39
- package/server/lib/work-board-reader.ts +63 -0
- package/server/lib/work-handoff-requests.ts +426 -0
- package/server/routes/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,43 @@
|
|
|
1
|
+
## 6.59.0
|
|
2
|
+
|
|
3
|
+
### Work on the glasses: progress, everything open, and a request inbox COS Control works through
|
|
4
|
+
|
|
5
|
+
- Miles, 2026-09-30: "Can we make sure that our EHPK work tab has the core updates that make these so useful." His decisions the same morning: the glasses get progress and start work; evidence text may come back to the glasses, up to 280 characters, never the prompt, detail or result. The contract all three builds share is `PLAN_work_on_glasses_6_9_561_2026-09-30.md` (v1 S1 to S4, then v2 items 1 to 11 and v3 items 13 to 21 from QA round 1). Glasses 6.9.561 and COS Control 0.5.252 build on it.
|
|
6
|
+
- Before this the glasses could see one status word per handoff and nothing else: the server projected only id, status, provider and session from COS Control's journal, and there was no way for the glasses to start tracked Work. Control's journal, its one-active-handoff fence and its COS-WORK tracker live on the Mac, so starting Work from the glasses has to go through Control.
|
|
7
|
+
- **Progress on an item.** `GET /api/work-board/activity` now answers `capabilities`, the task's own `taskRevision`, a `savedDestination` when Control saved one, and per handoff `mode`, `model` (a New session's; Control's `existing-session` placeholder on a Continue or Fork is left out), `channel`, `createdAt`, `updatedAt` (the newest progress event Control recorded, else `createdAt`), `progress` (`reported` done, needs input, blocked or null; `reportedBy` session or Jev; `evidence`; `receivedAt`; `lastMove { from, to, at, undone }`; `paused`), `acknowledged`, `appOpened`, `serverHold`, `waitingInApp`, `sending` (a send in flight: preparing or sending, made in the last 10 minutes), `requestedFrom` and `requestId`. Each field maps from Control's own `WorkProgress` and receipt rules, read from Control 0.5.250's source, never guessed.
|
|
8
|
+
- `appOpened`, `serverHold` and `waitingInApp` belong to the session, across every receipt that names it, with Control's `sameSession` rule: a Continue into a session another handoff opened in its app is the app's, as Control treats it. A handoff with no session owns nothing.
|
|
9
|
+
- Evidence is cleaned as a 6.58.2 session name is (the shared `flattenDisplayText`) and kept to 280 whole characters and 1,200 UTF-16 units; it appears only with a report.
|
|
10
|
+
- Lenient for everything new: a malformed `progress` block reads `{ unreadable: true }` on its own, a malformed `appOpen` or `acknowledgedAt` reads as absent, a time no date can hold is left out. The journal is never made unavailable by them. Every existing check (O_NOFOLLOW, symlinked folders, owner, size, the isolated-runtime refusal, the strict receipt, session and draft checks) is unchanged.
|
|
11
|
+
- A 0.5.248 tab that never reached a session reads as `canceled`, as Control reads it on load.
|
|
12
|
+
- The board row behind `taskRevision` and `savedDestination` is read within 3 s; past that the activity answers without them. The glasses routes share one board read, kept for 10 s after it finished and dropped at once by a Work stage or meeting write.
|
|
13
|
+
- **Everything open.** New `GET /api/work-board/activity/open` with `total` and `truncated`: every board task whose newest handoff in the last 14 days needs input or is blocked, needs attention (failed, refused, unknown, completed with no report), is running (queued, running, delivered, or a send in flight: `preparing` or `sending` made in the last 10 minutes), or reports done, and is not acknowledged. Items carry `group`, `taskRevision` and the board's title, ordered by group and then by the newest progress, cut to 50 after sorting. A report counts only on a receipt that did not fail and was not refused or canceled (Control's `WorkTracking.latest`), and a checked task shows only while its handoff is queued or running (Control's board). Read-only: no provider call, no Jev.
|
|
14
|
+
- **Task revision.** `taskRevision` (on the activity, each open item and every `GET /api/work-board` task) is Control's own `WorkSource.taskSnapshot` revision of that one task, so a request is refused only when that task changed, not when another card in the domain file did. It is pinned to Control's Swift by `server/lib/__fixtures__/control-task-snapshot` (Control's code from 0.5.250, 12 rows, `generate.sh`), outside the package.
|
|
15
|
+
- **Saved destination.** `savedDestination { mode, provider, model?, sessionId? }` from Control's draft for the task's current revision, only when Control's `sendPlan` would send exactly that and a request with it would pass: a non-empty prompt within Control's draft limit, Continue on claude, codex or cursor, a native Fork of claude or codex (whatever provider the draft still holds), a Fork to the other of the two with one of its model slots, a New session whose slot matches its provider. Never the prompt.
|
|
16
|
+
- **The handoff request inbox.** The glasses `POST /api/work-board/handoff-requests` `{ clientRequestId, domain, workIdentity, expectedTaskRevision, intent, mode, sessionId?, model?, note?, destinationSource, replyTo? }` and poll `GET .../:clientRequestId`. COS Control, on the Mac only (403 `local_only` otherwise), lists `?state=pending`, claims (`POST .../:id/claim`: 200 with a `claimToken`, 409, 410) and reports under that token (`POST .../:id/result`: `sent` with its receipt, `refused` with a reason, `unresolved` with its receipt). The server never runs a provider for a request.
|
|
17
|
+
- Idempotent by `clientRequestId` (the same request again is 200, answered before the board is read; a different one is 409 `request_id_conflict`); one pending or claimed request per item (409 `request_pending`); 409 `revision_changed` when this task changed; 409 `task_complete` for a checked task or one at stage Complete (Control refuses both); 40 a day by the Mac's local day (429 `daily_cap` with `resetsAt` and `Retry-After`; the message names no time). The board read is bounded at 5 s (503 `work_board_unavailable` with its `cause`; nothing is created).
|
|
18
|
+
- Reply by voice and Not done yet are `intent: reply | notDone` with `replyTo`: the item's newest receipt, continuing the session it went to, with a note (409 `reply_target_invalid` otherwise).
|
|
19
|
+
- Time: a pending request expires 10 minutes after it was made and can never be claimed after; a claim carries `claimExpiresAt` (10 minutes on) and with no result by then reads `unconfirmed`, which still takes one late result; a time more than 5 minutes ahead of the clock ends the wait at once (pending reads expired, claimed reads unconfirmed, never expired). All follow from stored times, so a read never writes, and each transition is logged once.
|
|
20
|
+
- `capabilities.requests` is 1 only while Control listed pending requests in the last 120 s (in memory); `requestsInbox` and `requestsConsumerSeenAt` say the rest, so the glasses can tell Control 0.5.250 (which never consumes it) from a live inbox.
|
|
21
|
+
- Validation: UUID v4, safe domain, 12-hex identity, 64-hex task revision, the intent and mode enums, a full `provider:native` session UUID (native-thread-id rule, no nested provider) for claude, codex or cursor on Continue and claude or codex on Fork, a Fork model only of the other platform, no model on Continue, a model slot this server knows (a New session without one takes `COS_G2_DEFAULT_MODEL`, else sonnet), and a note of at most 2,000 characters in which control characters (except newline and tab), U+2028 and U+2029, invisible format characters (except the joiners) and `COS-WORK` (read as Control reads a status line, so `COS**-WORK` too) are refused, not cleaned.
|
|
22
|
+
- Stored in `work-handoff-requests/requests.json` with one writer lock and durable atomic writes, like work intake; a bad record (a New session without its model among them) is quarantined on its own and ages out with the requests after 7 days; Control's list reports the count. A locked or unreadable file disables the inbox without stopping the server. Writes take the usual `/api` maintenance lease, and nothing here holds a restart.
|
|
23
|
+
- Control's claim and result refusals, every 5xx and the board failures on the activity routes are logged with codes, never a note.
|
|
24
|
+
- Also: `package.json` describes the server without an em dash.
|
|
25
|
+
- QA round 1 (Completionist, User, Skeptic and Ghost Hunter): no blocker for the glasses; the Skeptic's one blocker (a late true "sent" refused after the claim timeout) is the `unconfirmed` state. Everything above marked v2 or v3 in the contract came from it, with the Ghost Hunter's independent check of the gate and of the Swift hashes.
|
|
26
|
+
- QA round 2 (one verifier, on 98b8298): shippable. Its last items are in: four guards pinned with tests (the domain in both row lookups, the 10 s glance age, the newer-read-wins rule), a long reason that survives a restart, the placeholder model left out, a claim ahead of the clock reading unconfirmed, the board read dropped after a stage or meeting write and aged from its end, the normalized `COS-WORK` check, sends in flight shown as running, and the zone-independent daily-cap test.
|
|
27
|
+
- Docs: new `docs/work-handoff-requests.md` (in the package); `docs/work-task-runtime.md` says what the projection now carries and names its two pieces of session text (the session title, and the evidence Miles approved on 2026-09-30); `docs/work-intake.md` points to it.
|
|
28
|
+
- Tests: the projection over fixture journals (each field, per-session ownership with short ids and other providers, an unreadable block beside a readable one, the evidence rules and both caps, times out of range, the strict checks unchanged, no private text in any serialized answer); the open list (groups, the 14-day edge, acknowledgement, failed reports, checked tasks, newest receipt, progress order before the cut, total and truncated); the task revision against the Swift harness; the saved destination against each `sendPlan` case; the bounded, shared board read; both routes over HTTP with the real API token (loopback only for Control, claim race, claim tokens, late results, daily cap, timeouts with causes, replies); the store (validation table, idempotency, conflict, one per item, revision, local-day cap, expiry at exactly 10 minutes, skew, claim timeout, persistence across a reload, a crash in the middle of a write, 7-day retention and quarantine). `node server/scripts/work-progress-mutation-gate.mjs` runs its mutants from a green baseline; each must fail the test named for it.
|
|
29
|
+
|
|
30
|
+
## 6.58.2
|
|
31
|
+
|
|
32
|
+
### A New session from Work is named after its task, and visible while it runs
|
|
33
|
+
|
|
34
|
+
- Miles, 2026-09-29 at 20:40, on the first COS Control 0.5.249 New session (job c3878966, 20:36:49 to 20:39:11): Control opened it in Claude 31 s after the run ended, and it sat at the top of the MU-Chief-Staff sidebar as "General coding session". "This is super generic. It should be based off of the same name of the task that we're sending in." And while it ran: "I don't see anything in the sidebar ... We should probably show the session behind this." The card said "session live status unavailable" and Sessions listed the run as a COS server job.
|
|
35
|
+
- **A new Claude session is named.** A query job may carry `sessionName`. The server cleans it: control characters, zero-width spaces and direction marks and overrides become spaces (the joiners U+200C and U+200D stay), whitespace collapses, and a name over 100 characters is cut at 100, back to its last space when that space is in the second half; the cut counts whole characters, so it never splits an emoji. When the job starts a Claude session of its own, the server runs `claude --name=<name>`, which writes the session's title; the Claude app shows it in the sidebar. Never on a Continue into a session the COS session already has, never on a read-only dispatch, and a named session never takes the session the server warmed at boot (that one opens with "ready" under the glasses prompt); it starts clean. The server asks `claude --help` once whether the installed CLI takes `--name`; one that does not runs the session unnamed, as before. The name is outside the request fingerprint, so a retry that renames the same job is the same job, and an older server drops the field.
|
|
36
|
+
- **The job names its Claude session while it runs.** Claude announces its session id in its first stream event (a hook or init line, before any answer text); before this the server recorded it only with the final result, so a New session from Work could not be linked, shown or opened while it ran. The server now records it on the run and on the job (`cliSessionId`, through the same link update as the provider process) from that first event, only while the job is running; a job that already ended keeps what it had. At completion the result's id is recorded as before. Claude only: Codex, Cursor and Ollama jobs are unchanged.
|
|
37
|
+
- **Session titles in long transcripts.** The session list reads a Claude session's custom title from the end of its transcript; a transcript longer than the 256 KB window whose only title sits near the top now falls back to the top.
|
|
38
|
+
- Proven on this Mac first (claude 2.1.285): `claude -p --name` writes `{"type":"custom-title",...}` into the transcript (and again as the session goes on), the same line the Claude app shows as a session's name; `--resume <id> --name` renames; a name that starts with a dash, in the `--name=` form, is taken as the name; and every stream event, from the first hook line, carries the session id.
|
|
39
|
+
- Tests: the name's cleaning, joiners kept, the cut (at the last space, exactly at the limit, a run of emoji and of joined family emoji cut in whole characters), the request carrying it outside the fingerprint; `--name=` only for a session the COS session starts (not a Continue, not a dispatch, not the warmed session, not on a CLI without the option, a dash-leading name kept as one argument); the help probe (asked once, a CLI or help failure is a no); the first event with an id linking once (not an event without one, not a dispatch); the job showing the session while still running and keeping it at completion; a link after the job ended changing nothing; the runtime passing the name down and the early id up; a title at either end of a long transcript.
|
|
40
|
+
|
|
1
41
|
## 6.58.1
|
|
2
42
|
|
|
3
43
|
### A copy that lost its audio no longer holds the ring
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Work progress and handoff requests (6.59.0)
|
|
2
|
+
|
|
3
|
+
The glasses see how Work handoffs are going, and can ask COS Control to start one. The server only reads COS Control's handoff journal and keeps a small inbox of requests. It never runs a provider for a request and never asks Jev on these routes. Every route here uses the existing `/api` authentication; COS Control's own inbox routes also answer only on the Mac itself.
|
|
4
|
+
|
|
5
|
+
## Capabilities
|
|
6
|
+
|
|
7
|
+
The activity and open-work answers carry `capabilities`:
|
|
8
|
+
|
|
9
|
+
| Field | Meaning |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `progress: 1` | This server projects progress (below). An older server sends no `capabilities`. |
|
|
12
|
+
| `requests` | 1 only while COS Control is taking glasses requests: it listed pending requests in the last 120 s. Kept in memory, so after a restart it reads 0 until Control polls again. Offer Start work only on 1. |
|
|
13
|
+
| `requestsInbox` | 1 when the request inbox opened on this server. 1 with `requests: 0` means COS Control (0.5.252 or newer) is not running or not polling. |
|
|
14
|
+
| `requestsConsumerSeenAt` | When COS Control last listed pending requests (ISO), or null. |
|
|
15
|
+
|
|
16
|
+
Any future change to the request schema moves `requests` to 2.
|
|
17
|
+
|
|
18
|
+
## Progress on an item: `GET /api/work-board/activity?domain=<domain>&workIdentity=<12hex>`
|
|
19
|
+
|
|
20
|
+
Unchanged fields stay as they were (`id`, `workId`, `domain`, `status`, `provider`, `sessionId`, `sessionTitle`, `error`). The result gains `capabilities`, `taskRevision` (the task's own revision, below) and, when Control saved one, `savedDestination`, both at the top level. Each activity may also carry:
|
|
21
|
+
|
|
22
|
+
| Field | Meaning | From COS Control's journal |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `mode` | `continueSession`, `fork` or `newSession` | `mode` |
|
|
25
|
+
| `model` | The model slot a New session was sent with. Never on a Continue or a Fork (Control records the placeholder `existing-session` there) | `modelID` |
|
|
26
|
+
| `createdAt` | ISO time the handoff was sent | `createdAt` (seconds since 1970) |
|
|
27
|
+
| `updatedAt` | The newest progress event, never earlier than `createdAt` | `progress.events[].at` |
|
|
28
|
+
| `progress.reported` | `done`, `needsInput`, `blocked` or `null` | `progress.reported` (an unknown kind reads as `null`, as Control reads it) |
|
|
29
|
+
| `progress.reportedBy` | `session` (its own status line) or `jev` (Jev read the reply) | `progress.reportedBy` |
|
|
30
|
+
| `progress.evidence` | At most 280 characters and 1,200 UTF-16 units | `progress.evidence` |
|
|
31
|
+
| `progress.receivedAt` | When the session was seen to have the work | `progress.receivedAt` |
|
|
32
|
+
| `progress.lastMove` | `{ from, to, at, undone }`: the newest move COS made | the newest `moved` event and its `undoneAt` |
|
|
33
|
+
| `progress.paused` | A manual move back (or an Undo) paused automatic moves | `progress.paused` |
|
|
34
|
+
| `progress: { unreadable: true }` | The progress block is there but the server cannot read it: there may be a report it cannot show | a malformed `progress` |
|
|
35
|
+
| `acknowledged` | You reviewed or acknowledged it | `acknowledgedAt` set, or status `reviewed` |
|
|
36
|
+
| `appOpened` | Its SESSION is owned by its app | any receipt naming the same session (Control's `sameSession`) was opened in its app (`appOpen.openedAt`), came from a 0.5.248 tab, or went to the app (channel `app`) |
|
|
37
|
+
| `serverHold` | The COS server is still running a Work New session on its SESSION | any receipt on the same session with channel `job`, mode `newSession`, not terminal |
|
|
38
|
+
| `waitingInApp` | A note for its SESSION is queued in its app; nothing reached the session yet | any receipt on the same session with channel `app` and status `queued` |
|
|
39
|
+
| `channel` | How Control delivered it: `job`, `fork`, `app`, `turn`, `queue` or `tab` | `channel` |
|
|
40
|
+
| `sending: true` | A send in flight. Its `status` still reads `unknown` (as for older clients); with this flag say "Sending", not an unresolved delivery. Absent otherwise | status `preparing` or `sending`, made in the last 10 minutes (an older one was interrupted) |
|
|
41
|
+
| `requestedFrom` | `glasses` when Control sent it from a glasses request | `requestedFrom` |
|
|
42
|
+
| `requestId` | The handoff request it answered | `requestId` (Control 0.5.252) |
|
|
43
|
+
|
|
44
|
+
`appOpened`, `serverHold` and `waitingInApp` look at every receipt in the journal that names the session, as Control's `appOwner` and `serverHold` do, and are false for a handoff with no session. `reportedBy` and `evidence` appear only with a report; show Jev's evidence as a reading, not as the session's words. Evidence is cleaned as a session name is (control characters, zero-width space, direction marks and overrides as spaces; joiners kept; whitespace, U+2028 and U+2029 included, collapsed).
|
|
45
|
+
|
|
46
|
+
Parsing is lenient for everything new. A malformed `progress` block reads `{ unreadable: true }` on its own (its `updatedAt` is then its `createdAt`); a malformed `appOpen` or `acknowledgedAt` reads as absent; a time no date can hold is left out. None of them makes the journal unavailable. Every check the server already made on the rest of the journal is unchanged, and so are the file protections (no symlinked file or folder, owner only, under 10 MB, never read in an isolated runtime).
|
|
47
|
+
|
|
48
|
+
A handoff COS Control 0.5.248 opened as a tab and never linked to a session (channel `tab`, `queued`, no session) reads as `canceled`, as Control reads it.
|
|
49
|
+
|
|
50
|
+
The board row behind `taskRevision` and `savedDestination` is read within 3 s. One read is shared with the other glasses routes and kept for 10 s after it finished; a Work stage or meeting write through this server drops it at once. Past the 3 s, or with no board, the activity still answers, without those two fields: treat a missing `taskRevision` as "refresh, no Start".
|
|
51
|
+
|
|
52
|
+
## Everything open: `GET /api/work-board/activity/open`
|
|
53
|
+
|
|
54
|
+
`{ version: 1, available, reason?, capabilities, items, total, truncated }`. Each item is an activity (as above) plus `workIdentity`, `domain`, `title` (the board row's; the receipt's work title is never returned), `taskRevision`, `group` and, when saved, `savedDestination`. It takes no parameters (400 `invalid_open_activity_request`).
|
|
55
|
+
|
|
56
|
+
An item is a board task (`task:<domain>:<12 hex>` with a row on the board) whose newest receipt (by time, then id; any revision) was sent in the last 14 days and falls in a group:
|
|
57
|
+
|
|
58
|
+
| `group` | When |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| `needsInput` | It reports needs input or blocked, and is not acknowledged |
|
|
61
|
+
| `attention` | It failed, was refused, is unknown (delivery unresolved), or completed with no report (an unreadable one included), and is not acknowledged |
|
|
62
|
+
| `running` | It is queued, running or delivered, or a send in flight (`sending: true`; its `status` still reads `unknown`, so label these by `group` or the flag). An older one was interrupted and is `attention` |
|
|
63
|
+
| `done` | It reports done, and is not acknowledged |
|
|
64
|
+
|
|
65
|
+
- A report counts only on a receipt that did not fail and was not refused or canceled, as Control's `WorkTracking.latest` reads it; such a receipt goes to `attention` (or nowhere, when canceled).
|
|
66
|
+
- A checked task shows only while its newest receipt is `queued` or `running`, as Control's board does. A task whose stage is Complete but that is not checked still shows.
|
|
67
|
+
- Order: the groups in the order above; within each, the newest `updatedAt` first, then the newest send. The list is cut to 50 after sorting; `total` counts every item and `truncated` says whether any were cut.
|
|
68
|
+
- The board is read within 5 s. Past that, or when the journal or board cannot be read, `available: false` with a reason.
|
|
69
|
+
- Read-only: no provider call, no Jev, no write.
|
|
70
|
+
|
|
71
|
+
`GET /api/work-board` also carries `taskRevision` on every task.
|
|
72
|
+
|
|
73
|
+
## Task revision
|
|
74
|
+
|
|
75
|
+
`taskRevision` is COS Control's own revision of the task: the SHA-256 of the context `WorkSource.taskSnapshot` builds from the row (its words, domain, Done when, source and meeting links). It changes only when that task changes, never when another card in the same domain file does. The server's computation is pinned to Control's Swift by `server/lib/__fixtures__/control-task-snapshot` (its `generate.sh` rebuilds the goldens).
|
|
76
|
+
|
|
77
|
+
## Saved destination
|
|
78
|
+
|
|
79
|
+
`savedDestination: { mode, provider, model?, sessionId? }` comes from COS Control's draft for the task's current revision; a draft saved before the task changed does not count, as in Control. It is present only when Control's `sendPlan` would send exactly that and a handoff request with it would pass the rules below:
|
|
80
|
+
|
|
81
|
+
- the draft's prompt is not empty and within Control's draft limit (31,520 UTF-16 units);
|
|
82
|
+
- Continue: a claude, codex or cursor session;
|
|
83
|
+
- a native Fork: a claude or codex session, whatever other provider the draft still holds;
|
|
84
|
+
- a Fork to the other platform: a claude or codex source, the other of the two as target, and a model slot of that target;
|
|
85
|
+
- New: a model slot whose provider is the draft's provider.
|
|
86
|
+
|
|
87
|
+
`provider` is always set. The draft's prompt is never returned.
|
|
88
|
+
|
|
89
|
+
## Handoff requests
|
|
90
|
+
|
|
91
|
+
The glasses leave a request, COS Control claims it and runs its own send with every guard it has (the journal fence, one active handoff per item, the status line, the session name, the server hold, the app owner, open in app). The requests live in `work-handoff-requests/requests.json` in the server data directory.
|
|
92
|
+
|
|
93
|
+
### Glasses: `POST /api/work-board/handoff-requests`
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
{ clientRequestId, domain, workIdentity, expectedTaskRevision, intent, mode, sessionId?, model?, note?, destinationSource, replyTo? }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
- `clientRequestId`: a UUID v4 (either case; stored in lowercase). `domain`: a safe domain name. `workIdentity`: 12 hex. `expectedTaskRevision`: the task's `taskRevision`.
|
|
100
|
+
- `intent`: `start`, `reply` (Reply by voice) or `notDone` (Not done yet). Reply and Not done yet name `replyTo`, the item's newest receipt, continue the session that receipt went to, and carry a note; Start names no `replyTo`.
|
|
101
|
+
- `mode`: `continueSession`, `fork` or `newSession`. `sessionId` is exactly `provider:native`, with provider claude, codex or cursor and native a full lowercase session UUID (never a short id, a path, or another `provider:` inside). A live Claude session listed by its 8-character id cannot be named, so do not offer it. Continue works with claude, codex and cursor sessions and takes no model. Fork works from claude and codex sessions only; a model on a Fork is a Fork to the other platform and must be a slot of that platform. A New session names no session; without a model it takes the server's default (`COS_G2_DEFAULT_MODEL`, else `sonnet`), and the stored request names it. A model is a slot this server knows (`opus`, `fable`, `sonnet`, `haiku`, `codex-frontier`, `codex-balanced`, `cursor-grok`, `cursor-composer`, `ollama`).
|
|
102
|
+
- `note`: at most 2,000 characters after trimming. Newlines, tabs and the joiners U+200C and U+200D are kept. Refused (400, never cleaned): any other control character, U+2028 and U+2029, any other invisible format character (direction marks and overrides, zero-width space, soft hyphen, byte-order mark), a broken surrogate, and the text `COS-WORK` in any case (an agent quoting it back could move the card). `COS-WORK` is looked for as COS Control reads a status line, with `*`, `_`, backticks and joiners taken out first, so `COS**-WORK` is refused too.
|
|
103
|
+
- `destinationSource`: `saved`, `jev` or `user`. A hint for COS Control's copy; Control never trusts it.
|
|
104
|
+
- An empty string or `null` counts as not given for `sessionId`, `model`, `note` and `replyTo`. Any other field, or a body over 16 KB, is refused.
|
|
105
|
+
|
|
106
|
+
Answers:
|
|
107
|
+
|
|
108
|
+
| Status | Code | When |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| 201 | | Recorded as `pending`: `{ request }` |
|
|
111
|
+
| 200 | | The same `clientRequestId` and the same request again: the stored request, in whatever state it is now. Answered before the board is read |
|
|
112
|
+
| 400 | `invalid_handoff_request` | Anything malformed |
|
|
113
|
+
| 404 | `task_not_found` | No board row for that domain and work identity |
|
|
114
|
+
| 409 | `task_complete` | The task is checked, or at stage Complete (COS Control refuses both) |
|
|
115
|
+
| 409 | `request_id_conflict` | The same `clientRequestId` with a different request |
|
|
116
|
+
| 409 | `revision_changed` | This task changed since `expectedTaskRevision` |
|
|
117
|
+
| 409 | `reply_target_invalid` | `replyTo` is not this task's newest receipt, or the request continues a different session |
|
|
118
|
+
| 409 | `request_pending` | The item already has a pending or claimed request |
|
|
119
|
+
| 429 | `daily_cap` | 40 requests already made today, counted by the Mac's local day whatever became of them. The error carries `resetsAt` (the Mac's next local midnight as an ISO instant, for the client to format; the message names no time) and the answer a `Retry-After` |
|
|
120
|
+
| 503 | `work_board_unavailable` | The board did not answer within 5 s or failed; `cause` says why (`work_board_timeout`, `cos_pipeline_not_configured`, ...). Nothing was created |
|
|
121
|
+
| 503 | `work_history_unavailable` | A reply's receipt cannot be checked: COS Control's journal cannot be read here |
|
|
122
|
+
| 503 | `handoff_requests_unavailable` | The inbox did not open on this server |
|
|
123
|
+
| 503 | `handoff_store_closed` | The server is shutting down |
|
|
124
|
+
| 503 | `maintenance_drain_active` | COS Control is draining the server for an update (the usual `/api` maintenance lease). This body is the maintenance shape, `{ error: "maintenance_drain_active", message, retryable: true, retryAfterSeconds? }`, with `Retry-After` when known; try again after it. Nothing was created |
|
|
125
|
+
|
|
126
|
+
After a transport failure or a timeout, GET the same `clientRequestId` before telling anyone anything: 404 means nothing was recorded.
|
|
127
|
+
|
|
128
|
+
### Glasses: `GET /api/work-board/handoff-requests/:clientRequestId`
|
|
129
|
+
|
|
130
|
+
`{ request: { clientRequestId, state, receiptId?, reason?, createdAt, expiresAt, claimedAt?, claimExpiresAt?, resultAt? } }`, never the note. `expiresAt` is `createdAt` plus 10 minutes, the last moment it can be claimed; `claimExpiresAt` is `claimedAt` plus 10 minutes, the last moment COS Control may send it. 404 `request_not_found` means the server no longer has the request (it keeps requests for 7 days).
|
|
131
|
+
|
|
132
|
+
| `state` | Meaning |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| `pending` | Waiting for COS Control on the Mac |
|
|
135
|
+
| `claimed` | COS Control took it and is sending |
|
|
136
|
+
| `sent` | Control sent it; `receiptId` names the handoff |
|
|
137
|
+
| `refused` | Control did not send it; `reason` says why |
|
|
138
|
+
| `unresolved` | Control sent it but cannot confirm delivery; `receiptId` names the handoff. "Delivery is unresolved. Check Work on the Mac." |
|
|
139
|
+
| `unconfirmed` | Control claimed it and did not report back within 10 minutes: it may or may not have been sent. "COS Control did not confirm. Check Work on the Mac before trying again." Never "Not sent" |
|
|
140
|
+
| `expired` | Nobody claimed it within 10 minutes. Nothing was sent |
|
|
141
|
+
|
|
142
|
+
### COS Control (on the Mac only: any other caller gets 403 `local_only`)
|
|
143
|
+
|
|
144
|
+
- `GET /api/work-board/handoff-requests?state=pending` returns `{ version: 1, requests, quarantined }`: the requests in full (intent, mode, session, model, note, reply target, destination source, claim deadline), oldest first, and how many unreadable records are set aside. `state` may be any state or left out; listing `pending` is what tells the glasses Control is taking requests.
|
|
145
|
+
- `POST /api/work-board/handoff-requests/:id/claim` (no body) is atomic: 200 `{ request, claimToken }` with the request now `claimed`; the same `{ claimToken }` again is 200 while the claim holds; 409 `already_claimed` otherwise; 410 `request_expired`; 404 `request_not_found`. Check `claimExpiresAt` immediately before sending, and never send after it.
|
|
146
|
+
- `POST /api/work-board/handoff-requests/:id/result` `{ state, receiptId?, reason?, claimToken }`:
|
|
147
|
+
- `sent` needs `receiptId`; `refused` needs `reason` (and a `receiptId` when there is one); `unresolved` needs `receiptId`.
|
|
148
|
+
- Taken for a claimed request, and once for an `unconfirmed` one (a result that arrives late).
|
|
149
|
+
- The same result again is 200 with no change; any other is 409 `request_not_claimed`; a wrong token is 409 `claim_token_mismatch`; 410 `request_expired`.
|
|
150
|
+
- The reason is cleaned (control characters as spaces) and kept to 500 characters.
|
|
151
|
+
- Control should retry a result until the server accepts it.
|
|
152
|
+
|
|
153
|
+
### Time
|
|
154
|
+
|
|
155
|
+
- A pending request expires 10 minutes after `createdAt` and can never be claimed after that, so a Control that starts tomorrow never fires yesterday's tap.
|
|
156
|
+
- A claimed request with no result 10 minutes after the claim becomes `unconfirmed` and still takes one late result.
|
|
157
|
+
- A time more than 5 minutes ahead of the server's clock (a clock that jumped) ends the wait at once: a pending request is `expired`; a claimed one is `unconfirmed`, never expired, and still takes its one late result.
|
|
158
|
+
- These follow from the stored times on every read; the next write stores them. A read never writes. Each transition is logged once, with the request id and never the note.
|
|
159
|
+
- A request survives a restart and stays claimable until `createdAt` plus 10 minutes, whatever happened in between.
|
|
160
|
+
|
|
161
|
+
### Storage and recovery
|
|
162
|
+
|
|
163
|
+
One server process owns the file (`writer.lock`); writes are durable and atomic (a private temp file, fsync, rename). Requests and set-aside records older than 7 days are dropped on the next write; at 40 a day that is at most a few hundred. An unreadable record is set aside on its own (`quarantined`) and logged at start. An unreadable or unsafe file disables the inbox (503, `capabilities.requestsInbox: 0`) without stopping the server: keep the file for diagnosis, then move it aside to start empty.
|
|
164
|
+
|
|
165
|
+
Writes take the same maintenance lease as every short `/api` write, so they are refused while COS Control drains the server for an update. Nothing here holds a restart: a request that waits through one simply expires if it is not claimed in time.
|
|
166
|
+
|
|
167
|
+
Rolling back to a server before 6.59.0 leaves the file unread and these routes answer 404 there; COS Control and the glasses should read that 404 as a server without the inbox.
|
|
168
|
+
|
|
169
|
+
### Logs
|
|
170
|
+
|
|
171
|
+
Every refusal of a claim or a result, every 5xx (with the board's `cause`), each expiry and each claim that went unconfirmed are logged as `[work-handoff-requests] ...` with the request id and code, and the activity routes log `[work-board] ...: answered without the board (<cause>)`. A note is never logged.
|
|
172
|
+
|
|
173
|
+
## Privacy
|
|
174
|
+
|
|
175
|
+
Responses never carry a receipt's `prompt`, `detail`, `result` or work title, a progress event's text, or a draft's prompt. Two projected fields are session text: `sessionTitle` (projected since 6.56.0) and `evidence`, up to 280 characters of the session's status line or Jev's reading (Miles approved this on 2026-09-30). A request's note goes back only to COS Control's list and claim, and to the glasses that sent it (in the create answer), never in the status view.
|
|
176
|
+
|
|
177
|
+
A Continue into a busy session queues on the Mac and may reach the session later; `claimExpiresAt` bounds when Control sends, not when a queued turn runs.
|
|
178
|
+
|
|
179
|
+
## Verification
|
|
180
|
+
|
|
181
|
+
`npx vitest run server/lib/work-activity.test.ts server/routes/work-board.test.ts server/lib/work-handoff-requests.test.ts server/routes/work-handoff-requests.test.ts` covers the projection, the open list, the task revision (against Control's own Swift output), the saved destination, the inbox and its routes. `node server/scripts/work-progress-mutation-gate.mjs` runs its mutations in a private copy, refuses to start on a red baseline, and requires each mutant to fail the test named for it.
|
package/docs/work-intake.md
CHANGED
|
@@ -34,3 +34,7 @@ The journal is `work-intake/intake.json` in the server data directory, owned by
|
|
|
34
34
|
Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{ reviewId, sessions }` for a meeting review (`reviewId` = `wr_` + 32 hex). The server reads the review's meeting title and reviewed follow-up from its own store; the client never sends work text. Errors: 400 `invalid_review_request` (bad id or session list), 404 `review_not_found`, 404 `reviews_unavailable` (reviews off), 503 `review_store_unavailable` (defensive; the stock lookup reads memory).
|
|
35
35
|
|
|
36
36
|
Since 6.58.0 `POST /api/work-board/completion-check` takes exactly `{ domain, id, provider, sessionId }` plus an optional `after` (ISO time). It asks Jev whether the session's replies since `after` (or its newest reply) show the task finished, judged against its Done when or, without one, its own text. The server reads the task from the board and the replies from its own transcripts; the client sends names and a time only. It answers `{ provider: "jev", verdict: "done" | "not_done" | "unclear", confidence, basis: "done_when" | "task", model, cached }` or `{ provider: "none", reason }` (`no_reply`, `no_task_text`, a Jev condition, `completion_unavailable`). Errors: 400 `invalid_completion_request`, 404 `task_not_found`, 404 `session_not_found`. COS Control 0.5.247 asks only while the session is idle and its newest reply since the handoff has no `COS-WORK` status line, at most twice per handoff, and moves a card to QA only on done at 0.8 (0.85 without a Done when).
|
|
37
|
+
|
|
38
|
+
Since 6.58.2 `POST /api/query-jobs` also takes an optional `sessionName` (a string; the server cleans it as the 6.58.2 changelog says and keeps at most 100 characters; anything else is no name, never an error). It names only a Claude session the job starts itself (`claude --name=<name>`, when the installed CLI lists `--name`); a Continue, a dispatch, a Codex, Cursor or Ollama job, and an older server ignore it, and it is not part of the request fingerprint. A Claude job's snapshot carries `cliSessionId` from Claude's first stream event while the job is still `running`, not only at completion. COS Control 0.5.250 sends the Work item's title for New session, Start work and Fork to Claude, and links its receipt as soon as the running job names the session.
|
|
39
|
+
|
|
40
|
+
Since 6.59.0 `GET /api/work-board/activity` also projects each handoff's progress (the report, its evidence of at most 280 characters, the newest move), the task's own revision and a saved destination, `GET /api/work-board/activity/open` lists every open handoff across the board, and `POST /api/work-board/handoff-requests` lets the glasses ask COS Control to start Work, reply, or say it is not done yet, which Control claims on this Mac and sends with its own guards. The server runs no provider and no Jev for any of it. See `docs/work-handoff-requests.md`.
|
|
@@ -62,12 +62,22 @@ platforms, isolated runtimes, missing/unsafe/malformed journals report
|
|
|
62
62
|
`available:false`, not a successful empty history. Custom data roots are treated
|
|
63
63
|
as isolated for native-journal reading. There is no request-supplied file path.
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
ownership
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
65
|
+
Receipt identity/status/provider/session fields are projected, and since 6.59.0
|
|
66
|
+
also mode, model slot, channel, times, acknowledgement, the session's app and server
|
|
67
|
+
ownership (per session, across every receipt that names it), the request a receipt
|
|
68
|
+
answered, and COS Control's own progress record: the report (done, needs input,
|
|
69
|
+
blocked), who made it, its evidence, when the session received the work, the newest
|
|
70
|
+
automatic move and whether moves are paused (`docs/work-handoff-requests.md`). Two
|
|
71
|
+
projected fields are session-derived text: the session title (since 6.56.0) and the
|
|
72
|
+
evidence, which Miles approved returning on 2026-09-30, at most 280 characters and
|
|
73
|
+
1,200 UTF-16 units, cleaned as a session name is. Prompt, detail, result, work title,
|
|
74
|
+
draft and source text and progress event text are never returned. Error text is
|
|
75
|
+
generic. Session ownership is never inferred from titles. `sending`/`preparing`
|
|
76
|
+
project as unknown; queued/delivered receipts are not task completion. `updatedAt` is
|
|
77
|
+
the newest progress event Control recorded, else the receipt's `createdAt`; nothing
|
|
78
|
+
is fabricated. A malformed progress block reads `{ unreadable: true }` on its own and
|
|
79
|
+
never makes the journal unavailable. This endpoint does not poll providers, resend work or mutate
|
|
80
|
+
the native journal.
|
|
71
81
|
|
|
72
82
|
## Qualification
|
|
73
83
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
4
|
-
"description": "COS Glasses
|
|
3
|
+
"version": "6.59.0",
|
|
4
|
+
"description": "COS Glasses: 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": {
|
|
7
7
|
"glasses-server": "bin/cli.cjs",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"!server/scripts/work-review-candidate.ts",
|
|
45
45
|
"!server/scripts/work-review-mutation-gate.mjs",
|
|
46
46
|
"!server/scripts/work-intake-mutation-gate.mjs",
|
|
47
|
+
"!server/scripts/work-progress-mutation-gate.mjs",
|
|
47
48
|
"!server/scripts/mutation-gate.mjs",
|
|
48
49
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
49
50
|
"!server/lib/__fixtures__/**",
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
"docs/control2-task-runtime.md",
|
|
65
66
|
"docs/work-reviews.md",
|
|
66
67
|
"docs/work-intake.md",
|
|
68
|
+
"docs/work-handoff-requests.md",
|
|
67
69
|
"work-task-runtime",
|
|
68
70
|
"docs/work-task-runtime.md",
|
|
69
71
|
"!server/scripts/build-work-task-runtime.py"
|
package/server/index.ts
CHANGED
|
@@ -125,9 +125,13 @@ import { createQueryJobsRouter } from './routes/query-jobs.js'
|
|
|
125
125
|
import { createWorkReviewsRouter } from './routes/work-reviews.js'
|
|
126
126
|
import { createWorkBoardRouter } from './routes/work-board.js'
|
|
127
127
|
import { createWorkIntakeRouter } from './routes/work-intake.js'
|
|
128
|
+
import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
|
|
128
129
|
import { createJevRouter } from './routes/jev.js'
|
|
129
130
|
import { dataPath } from './lib/data-dir.js'
|
|
130
131
|
import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
|
|
132
|
+
import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
|
|
133
|
+
import { createWorkBoardReader } from './lib/work-board-reader.js'
|
|
134
|
+
import { listBoard } from './lib/task-store.js'
|
|
131
135
|
import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
|
|
132
136
|
import { createMorningBriefRouter } from './routes/morning-brief.js'
|
|
133
137
|
import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
|
|
@@ -625,7 +629,13 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
|
625
629
|
}))
|
|
626
630
|
const workReviewRuntime = createDefaultWorkReviewRuntime()
|
|
627
631
|
app.use('/api', createWorkReviewsRouter(workReviewRuntime))
|
|
628
|
-
|
|
632
|
+
// Work handoff requests (6.59.0): the glasses ask for Work to start and COS Control claims them. Optional: a bad
|
|
633
|
+
// journal answers 503 and `capabilities.requests: 0`, never blocks boot. The server runs no provider for a request.
|
|
634
|
+
const workHandoffRequestStore = createOptionalWorkHandoffRequestStore(() => new WorkHandoffRequestStore(dataPath('work-handoff-requests')))
|
|
635
|
+
// One bounded board read (5 s) shared by the glasses' Work glances and the request inbox; GET /api/work-board primes it.
|
|
636
|
+
const workBoardReader = createWorkBoardReader(() => listBoard())
|
|
637
|
+
app.use('/api', createWorkBoardRouter({ readBoard: workBoardReader, requests: () => handoffRequestCapabilities(workHandoffRequestStore, Date.now()) }))
|
|
638
|
+
app.use('/api', createWorkHandoffRequestsRouter({ store: workHandoffRequestStore, readBoard: workBoardReader }))
|
|
629
639
|
// Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
|
|
630
640
|
const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
|
|
631
641
|
app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
|
|
@@ -948,6 +958,7 @@ async function gracefulShutdown(): Promise<void> {
|
|
|
948
958
|
stopMorningBriefScheduler()
|
|
949
959
|
await workReviewRuntime?.close()
|
|
950
960
|
workIntakeStore?.close()
|
|
961
|
+
workHandoffRequestStore?.close()
|
|
951
962
|
stopMeetingImportScheduler()
|
|
952
963
|
stopMeetingMergeScheduler()
|
|
953
964
|
sessionHooksRuntime.stop()
|
|
@@ -580,7 +580,17 @@ export function parseJsonLine(line: string): Record<string, unknown> | null {
|
|
|
580
580
|
}
|
|
581
581
|
|
|
582
582
|
export async function lastCustomTitle(path: string): Promise<string | null> {
|
|
583
|
-
|
|
583
|
+
// The CLI re-writes its custom-title line as a session goes on, so the tail normally holds the
|
|
584
|
+
// newest. 6.58.2: a transcript longer than the window whose only title sits near the top (a
|
|
585
|
+
// `claude -p --name` run that wrote a long answer) falls back to the head.
|
|
586
|
+
const tail = customTitleIn(await readWindow(path, true))
|
|
587
|
+
if (tail) return tail
|
|
588
|
+
const st = await fileStat(path)
|
|
589
|
+
if (!st?.isFile || st.size <= HEAD_BYTES) return null
|
|
590
|
+
return customTitleIn(await readWindow(path, false))
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function customTitleIn(text: string): string | null {
|
|
584
594
|
let found: string | null = null
|
|
585
595
|
for (const line of text.split('\n')) {
|
|
586
596
|
if (!line.includes('custom-title')) continue
|
|
@@ -60,6 +60,7 @@ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
|
|
|
60
60
|
import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
|
|
61
61
|
import { terminateProviderProcess } from './provider-process-lifecycle.js'
|
|
62
62
|
import { teeJobTrail, type JobTrailDraft } from './job-trail.js'
|
|
63
|
+
import { claudeSupportsSessionName } from './claude-cli-support.js'
|
|
63
64
|
|
|
64
65
|
// Inactivity = no stdout data for this long → kill (catches stalls)
|
|
65
66
|
const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
|
|
@@ -340,6 +341,12 @@ export interface StreamCallbacks {
|
|
|
340
341
|
/** 6.52.0: readable steps of a durable Messages job (lib/job-trail.ts). Optional, and
|
|
341
342
|
* absent on every path that is not a durable job with the trail on. */
|
|
342
343
|
onTrail?: (draft: JobTrailDraft) => void
|
|
344
|
+
/**
|
|
345
|
+
* 6.58.2: the provider named its own session, at the start of the run. Claude announces
|
|
346
|
+
* its session id in the first stream event; before this the id reached the job only with
|
|
347
|
+
* the result, so COS Control could not show a New session from Work while it ran.
|
|
348
|
+
*/
|
|
349
|
+
onNativeSession?: (linkage: { cliSessionId: string }) => void
|
|
343
350
|
}
|
|
344
351
|
|
|
345
352
|
/** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
|
|
@@ -395,6 +402,8 @@ export interface CallOptions {
|
|
|
395
402
|
sessionLockHeld?: boolean
|
|
396
403
|
/** Read-only scheduled-task dispatch. Same shape as QueryJobRequest.dispatch. */
|
|
397
404
|
dispatch?: { restricted: true; tools: readonly string[] }
|
|
405
|
+
/** 6.58.2: `claude --name` for a session this COS session starts fresh (QueryJobRequest.sessionName). */
|
|
406
|
+
sessionName?: string
|
|
398
407
|
}
|
|
399
408
|
|
|
400
409
|
export async function callClaudeStreaming(
|
|
@@ -426,6 +435,9 @@ export async function callClaudeStreaming(
|
|
|
426
435
|
// Pass existing CLI session ID if resuming (new sessions get it after first result)
|
|
427
436
|
const resolvedCliKey = cliSessionKey(sid, resolvedModel)
|
|
428
437
|
let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
|
|
438
|
+
// 6.58.2: decided before the pre-warmed session below stands in: a COS session that has no
|
|
439
|
+
// Claude session of its own yet starts one here, and only that one may be named.
|
|
440
|
+
const startsOwnCliSession = !existingCliSession
|
|
429
441
|
callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
|
|
430
442
|
clientJobId: options?.clientJobId,
|
|
431
443
|
generation: options?.generation,
|
|
@@ -531,7 +543,10 @@ export async function callClaudeStreaming(
|
|
|
531
543
|
// Check if we have a prior CLI session for this COS session.
|
|
532
544
|
// If not, use the pre-warmed session (eliminates 2-15s cold start on first query).
|
|
533
545
|
// Restricted dispatch never steals the pre-warmed unrestricted session.
|
|
534
|
-
|
|
546
|
+
// 6.58.2: nor does a named New session (COS Control Work). The warmed session opens with the
|
|
547
|
+
// boot prompt "ready" under the glasses prompt; a session someone will open in the Claude app
|
|
548
|
+
// under its task's name starts clean.
|
|
549
|
+
if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
|
|
535
550
|
// Only the default model consumes the pre-warmed session.
|
|
536
551
|
// Hey Even (Haiku) cold-starts its own session to avoid model contamination.
|
|
537
552
|
existingCliSession = preWarmedCliSessionId
|
|
@@ -576,6 +591,11 @@ export async function callClaudeStreaming(
|
|
|
576
591
|
// Resume prior CLI session — reuses cached context, avoids cold start
|
|
577
592
|
args.push('--resume', existingCliSession)
|
|
578
593
|
}
|
|
594
|
+
// 6.58.2: name the session this COS session starts (never a Continue, never a dispatch), when
|
|
595
|
+
// the installed CLI takes --name. The `=` form keeps a name that starts with a dash a value.
|
|
596
|
+
if (startsOwnCliSession && !options?.dispatch && options?.sessionName && await claudeSupportsSessionName()) {
|
|
597
|
+
args.push(`--name=${options.sessionName}`)
|
|
598
|
+
}
|
|
579
599
|
|
|
580
600
|
// Strip CLAUDECODE env var so claude -p doesn't think it's nested
|
|
581
601
|
const env = { ...process.env }
|
|
@@ -856,6 +876,8 @@ export async function callClaudeStreaming(
|
|
|
856
876
|
|
|
857
877
|
// ─── Process stdout ───
|
|
858
878
|
|
|
879
|
+
// 6.58.2: onNativeSession fires once per run.
|
|
880
|
+
let nativeSessionAnnounced = false
|
|
859
881
|
proc.stdout.on('data', (chunk: Buffer) => {
|
|
860
882
|
resetInactivity()
|
|
861
883
|
buffer += chunk.toString()
|
|
@@ -871,6 +893,15 @@ export async function callClaudeStreaming(
|
|
|
871
893
|
try {
|
|
872
894
|
const event = JSON.parse(trimmed)
|
|
873
895
|
|
|
896
|
+
// 6.58.2: the first event that carries the session id (a hook or init line, before any
|
|
897
|
+
// answer text) links the run and the job to it. At completion the result's id is
|
|
898
|
+
// recorded as before, so the finished job names the session the result names.
|
|
899
|
+
if (!nativeSessionAnnounced && !options?.dispatch && typeof event.session_id === 'string' && event.session_id) {
|
|
900
|
+
nativeSessionAnnounced = true
|
|
901
|
+
updateClaudeRun(run.runId, { cliSessionId: event.session_id })
|
|
902
|
+
callbacks.onNativeSession?.({ cliSessionId: event.session_id })
|
|
903
|
+
}
|
|
904
|
+
|
|
874
905
|
if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
|
|
875
906
|
updateClaudeRun(run.runId, { resolvedModelId: event.model })
|
|
876
907
|
} else if (event.type === 'stream_event') {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 6.58.2: does the installed Claude CLI take `--name`? A CLI that predates the option rejects
|
|
5
|
+
* it as unknown and the whole run fails, so the server asks `claude --help` once, in the
|
|
6
|
+
* background (it answers in about 50 ms on this Mac), and names a session only when the help
|
|
7
|
+
* lists the option. Any failure to ask is a no: the session then runs unnamed, as before 6.58.2.
|
|
8
|
+
*/
|
|
9
|
+
let sessionNameSupport: Promise<boolean> | null = null
|
|
10
|
+
|
|
11
|
+
const HELP_TIMEOUT_MS = 5_000
|
|
12
|
+
|
|
13
|
+
export function claudeSupportsSessionName(): Promise<boolean> {
|
|
14
|
+
if (!sessionNameSupport) {
|
|
15
|
+
sessionNameSupport = new Promise<boolean>(resolve => {
|
|
16
|
+
try {
|
|
17
|
+
execFile('claude', ['--help'], { timeout: HELP_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
|
|
18
|
+
const supported = !error && helpListsNameOption(`${stdout}\n${stderr}`)
|
|
19
|
+
if (!supported) console.warn('[claude-bridge] this Claude CLI does not list --name; new sessions stay unnamed')
|
|
20
|
+
resolve(supported)
|
|
21
|
+
})
|
|
22
|
+
} catch {
|
|
23
|
+
resolve(false)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
return sessionNameSupport
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The help line reads `-n, --name <name>` on Claude Code 2.1.285. */
|
|
31
|
+
export function helpListsNameOption(help: string): boolean {
|
|
32
|
+
return /(^|[\s,])--name[\s=<[]/m.test(help)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetClaudeCliSupportForTests(): void {
|
|
36
|
+
sessionNameSupport = null
|
|
37
|
+
}
|
|
@@ -39,6 +39,10 @@ export interface QueryJobRunnerCallbacks {
|
|
|
39
39
|
/** True only when provider ownership was durably fsynced for this active
|
|
40
40
|
* generation. False means cancel/shutdown won; no prompt may be written. */
|
|
41
41
|
onProviderProcess: (linkage: QueryJobProviderLinkage) => boolean | Promise<boolean>
|
|
42
|
+
/** 6.58.2: a link the provider names mid-run (the Claude session id). Persisted only while
|
|
43
|
+
* the job runs (like every link update it restamps providerOwnershipConfirmedAt); unlike
|
|
44
|
+
* onProviderProcess it gates nothing, and a job that already ended keeps what it had. */
|
|
45
|
+
onLinkage?: (linkage: QueryJobProviderLinkage) => Promise<void>
|
|
42
46
|
onChunk: (text: string) => void
|
|
43
47
|
onToolStatus: (text: string) => void
|
|
44
48
|
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => void
|
|
@@ -340,6 +344,9 @@ export class QueryJobCoordinator {
|
|
|
340
344
|
})
|
|
341
345
|
},
|
|
342
346
|
onProviderProcess: linkage => this.providerProcessReady(active, linkage),
|
|
347
|
+
onLinkage: linkage => this.enqueueCallback(active, async () => {
|
|
348
|
+
await this.store.updateLinkage(active.jobId, linkage)
|
|
349
|
+
}),
|
|
343
350
|
onChunk: (text) => { this.queuePartial(active, text) },
|
|
344
351
|
onToolStatus: (text) => {
|
|
345
352
|
if (active.request.activityToolMode === 'off') text = 'Processing...'
|
|
@@ -234,6 +234,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
234
234
|
? { ollamaRunId: metadata.runId }
|
|
235
235
|
: { codexRunId: metadata.runId }),
|
|
236
236
|
}),
|
|
237
|
+
// 6.58.2: the provider's own session id, as soon as it names it (Claude: first stream event).
|
|
238
|
+
onNativeSession: linkage => { void callbacks.onLinkage?.({ provider: 'claude', cliSessionId: linkage.cliSessionId }) },
|
|
237
239
|
onChunk: text => { callbacks.onChunk(text) },
|
|
238
240
|
onToolStatus: toolName => {
|
|
239
241
|
const message = request.activityToolMode === 'off'
|
|
@@ -349,6 +351,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
349
351
|
attachmentPromptBlock: resolvedAttachments.promptBlock,
|
|
350
352
|
sessionLockHeld: true,
|
|
351
353
|
...(request.dispatch ? { dispatch: request.dispatch } : {}),
|
|
354
|
+
...(request.sessionName ? { sessionName: request.sessionName } : {}),
|
|
352
355
|
},
|
|
353
356
|
)
|
|
354
357
|
}
|