@gotcos/glasses-server 6.58.2 → 6.60.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 +57 -0
- package/README.md +79 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +16 -6
- package/managed-runtime-contract.json +2 -0
- package/package.json +5 -2
- package/server/index.ts +35 -3
- package/server/lib/claude-hooks-installer.ts +120 -17
- package/server/lib/client-liveness.ts +92 -4
- package/server/lib/permission-broker.ts +560 -31
- package/server/lib/query-job-types.ts +11 -2
- package/server/lib/session-hooks-runtime.ts +23 -0
- 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/permission-broker.ts +37 -3
- package/server/routes/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
+
## 6.60.0
|
|
2
|
+
|
|
3
|
+
### A question that starts while you are away waits up to 10 minutes for the glasses you are wearing
|
|
4
|
+
|
|
5
|
+
- 2026-09-30: a Claude question reached the glasses at 07:28:34 with 109 s left; the ring double tap did not work (a glasses bug, fixed in 6.9.563), so Miles answered on the phone at 07:29:30. More generally a question that starts while he is away was answerable from the glasses only for the broker's 110 s, and when no glasses or phone was polling at that moment it went straight to the Mac's dialog (`no_client`) and the glasses never saw it. His words: questions that begin while the user is away should have a second option to be reclaimed from the glasses if possible; else the user does not know the Mac is waiting on answers. He approved this at 18:40 CDT ("Yes, up to 10 min"), set the rule at 19:54 ("Hold only if the glasses or phone were connected in the last 30 min. A Claude Remote Control session with no glasses gets its question right away, as before."), and approved the QA round 1 fix pass at 21:04.
|
|
6
|
+
- **The away hold.** While the desk is idle past the existing threshold (`COS_PERMISSION_BROKER_DESK_IDLE_S`, default 90 s, the one the gate already uses), a question or approval is held for up to 10 minutes from when its hook started, but only when all of these hold: the hold is on; the installed hook waits longer than 6.59.0 could ever hold (after Install hooks); and the glasses or phone that show held questions reported you present within 30 minutes. Held that way, it stays answerable while nothing is polling, so glasses or a phone that come back can still answer it.
|
|
7
|
+
- **The contract shared with COS Glasses 6.9.563.** The questions poll becomes `GET /api/session-questions?client=glasses|phone&hold=1&presenceAgeMs=<int>`. `hold=1` says this client can show held questions (6.9.563 and later). `presenceAgeMs` is the ms since the latest real wearer evidence (glasses: a ring or temple input, or a device status with wearing true; phone: a foreground touch), a whole number from 0 to 24 h, left out when there is none since the app started. Only a poll with `hold=1` and a valid `presenceAgeMs` counts toward the hold. The server keeps the newest such presence in memory, forward-only, on the monotonic and the wall clock, and reads its age as the larger of the two, so a Mac that slept never makes it look newer. Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800, clamped to 0 through 86400; under 30 it means the 30 s live window). After a restart nothing is recent until such a poll.
|
|
8
|
+
- **Older clients keep 6.59.0.** A poll without `hold=1` (6.9.538, 6.9.561, 6.9.562, any phone build before 6.9.563) keeps the 6.59.0 liveness exactly (live for 30 s, a 110 s hold, handed back when it goes quiet) and never enables the away hold, even with `presenceAgeMs`. A `hold=1` poll without presence (a connected lens nobody wears, QA round 1 Skeptic W1) makes no client live and holds nothing: `{}` at once. A `hold=1` client counts as live only while its wearer's presence is recent.
|
|
9
|
+
- **Re-checked every desk poll (Skeptic W2).** Once no client is live and the wearer's latest presence is older than the window, a held question goes to the Mac's dialog (`no_client`) within 1.5 s, so one glance never holds a question 10 minutes. Touching the Mac still hands it back within 1.5 s, and a request that arrives while you are at the desk still goes straight to the dialog.
|
|
10
|
+
- **Before Install hooks the away hold is off (User W6).** While the hook waits no longer than 6.59.0 could hold (its 120 s clamp), away semantics are off entirely: no presence hold, quiet clients hand back, 110 s. One 6.60.0 rule still applies: a `hold=1` client (6.9.563 and later) counts as live only while its wearer's presence is 30 minutes old or less (next item).
|
|
11
|
+
- **A lens nobody has touched for 30 minutes gets no question card (QA round 2, W-A).** With 6.9.563 or later, the glasses count as live only while a ring or temple input, a worn report or a phone touch is 30 minutes old or less. That holds in every setting: before Install hooks, and with the away hold off. The wearing flag reads false almost all the time, so in a long meeting or a drive with no ring input a question goes straight to the Mac's dialog (`no_client`) instead of the lens. On 6.59.0 the same lens showed it for 110 s first. This is the QA round 1 Skeptic W1 decision: a connected lens nobody wears no longer delays the Mac's dialog.
|
|
12
|
+
- `COS_PERMISSION_BROKER_AWAY_HOLD_S` sets the hold: default 600, clamped to 30 through 600, never shorter than the base 110 s; `0`, `off` or `false` turns the away hold off (the 6.59.0 broker, except that a `hold=1` lens still needs recent presence to count as live). Junk is the default. Both settings are in `managed-runtime-contract.json`; COS Control's environment allowlist needs them in a Control build, until then set them in `~/.cos-glasses/.env`.
|
|
13
|
+
- **What it changes for a session driven from elsewhere.** While the desk is idle and you wore the glasses or touched the phone (with 6.9.563 or later) in the last 30 minutes, a permission prompt from a session you drive from another surface (Claude Remote Control, say) waits up to 10 minutes for the glasses before the Mac's dialog shows, unless you answer it from the glasses or phone, leave it for the Mac, touch the Mac, or your presence lapses. With no recent presence it shows at once, as before. `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` narrows the window; `COS_PERMISSION_BROKER_AWAY_HOLD_S=0` turns the hold off.
|
|
14
|
+
- **Never past the hook's own wait.** The hold is clamped by the hook that is actually installed: the PermissionRequest `timeout` in `~/.claude/settings.json` (read through the 5 s hook-status cache) and the wait the hook stamped on this request, the lesser, minus 10 s (5 s for the script's curl to give up before Claude Code stops the hook, 5 s more so the server's `{}` lands first: the 130, 125, 120 chain every release since 6.52.0 kept). If the settings file cannot be read, the stamp alone bounds it (Skeptic N4). A request with no stamp (the earlier script, or an earlier hook block) waited 130 s with a 125 s curl, so it is never held past 120 s.
|
|
15
|
+
- **The hook.** `--hooks install` writes the PermissionRequest block with `timeout: 630` and passes 630 to the script as its second argument (an argument, never an environment variable, so it can only come from that block). The script gives up its curl 5 s before that wait (it was a fixed 125 s) and stamps it on the request as `hookWaitS`; an invocation with no usable argument keeps the earlier 130 and 125 and sends no stamp. Only a whole number from 20 to 3,600 with no leading zero counts. The curl now runs as a tracked child, and the script stops it when the script itself is stopped (TERM, INT, HUP), so the server sees the hook leave (`hook_gone`) at once and never accepts an answer the session can no longer receive (Skeptic W7).
|
|
16
|
+
- **After updating.** Neither Update Server (`cos-control-helper update`) nor a server boot rewrites the Claude settings file or replaces an installed hook script, so until Install hooks runs once the broker is 6.59.0. `--hooks status` and `/api/session-hooks/status` read `drift` with `priorWaitOnly: true` and `permissionHookTimeoutS: 130`; COS Control shows its "Reinstall hooks" banner. Cancel from the lens keeps working meanwhile: `hookHaltReady` accepts that one drift on this package's script or a halt-capable earlier one, and the script 6.53.3 through 6.59.0 shipped (`df54677f...`) joins the halt-capable list, pinned against its bytes. Any other drift is still not halt-ready. Canary C below found that a running Claude session picked up a changed hook timeout.
|
|
17
|
+
- **Rolling back to 6.59.0 (Completionist W5, Skeptic N7).** After Install hooks, 6.59.0 reads the 630 block as `drift` and is not halt-ready, so cancel from the lens stays off for desk runs until Install hooks is run again under 6.59.0 (it writes the 130 block back). Questions stay safe meanwhile: the 6.60.0 hook script under a 6.59.0 server stamps a wait the old server ignores, and 6.59.0 holds at most 120 s, well inside the 630 s hook.
|
|
18
|
+
- **What the clients see.** Additive, every earlier field and meaning unchanged. Each pending item gains `answerable` (true while listed), `awayHold` and `waitingAtMac` (false while listed); `deadlineAt` is the latest it stays answerable, sooner if the wearer's presence lapses with no client polling. `GET /api/session-questions` gains `awayHoldMs` (how long a request that starts now is held at most with recent presence; 0 when off or before Install hooks), `awayRecentClientMs` (the window; 0 when off), `settled` (recently settled items, newest first, codes and times only: every one the Mac still waits on, and the newest others up to 32), `waitingAtMac` (how many), `serverStartedAt` (this process's start) and `waitingAtMacForgottenBefore`. `/api/models` `capabilities.sessionQuestions.awayHoldMs` and `awayRecentClientMs` carry the same numbers. `/api/health` `sessionHooks` gains `permissionHookTimeoutS` and `priorWaitOnly`.
|
|
19
|
+
- **Waiting at the Mac.** An item is `waitingAtMac` when the server wrote `{}` to its listening hook (back at the desk, expired, no client, drained), so the Mac's own dialog is up, and that request has not been finished since: its own tool has not finished or been denied (matched by its own fingerprint only, never by a denial of another dialog of the same tool, Skeptic N3), its turn has not ended, and its session row does not read ended (cleared at once, Skeptic N2). A `{}` that could not be written, an answer, a hook that left, or a retraction is never waiting. No hook marks the moment a dialog is approved, so an approved tool that is still running reads as waiting: the safe direction, never "not waiting" while a dialog may be up. Kept an hour, or up to a day while the session's own row still waits on that very request.
|
|
20
|
+
- **Forgot vs moved past (Ghost Hunter W1).** `waitingAtMacForgottenBefore` starts at the server's start and moves only when the server drops an item the Mac may still wait on. An entry a client saw waiting that is missing from `settled` moved past if it settled after that time; at or before it, or from a server with another `serverStartedAt`, the server forgot it and the Mac may still be waiting.
|
|
21
|
+
- **Telemetry (Completionist N9, Ghost Hunter W5).** Every request answered `{}` logs one line: `fast path <reason>`, and for `no_client` and `desk_active` `not_away=hold_off|ceiling_at_base|no_hold_client|stale_presence|desk_active`, with `presenceAgeMs`, `windowMs` and `clientLive`. Each held request logs once the numbers its deadline came from: `hold={"installedHookTimeoutS","hookWaitS","awayHoldS","ceilingS","effectiveDeadlineS","deskIdleMs","clientLive","presenceAgeMs","recentWindowS","notAwayReason"}`. Never text. The broker's authenticated counters gain `awayHeld` and `parkedWithoutClient`.
|
|
22
|
+
- **Canaries (Claude Code 2.1.286, 2026-09-30, throwaway `claude -p` sessions in temporary projects with `--setting-sources project`, an empty `--strict-mcp-config`, `--no-session-persistence` and haiku, so no user hook and no COS spool saw them, cleaned up after).** The claim the hold rests on: Claude Code waits on a PermissionRequest command hook for its configured timeout when that is above 130 s, and applies its decision. A630, timeout 630 and a hook that slept 610 s then allowed: the hook ran 21:06:28 to 21:16:38 and the tool ran the same second; not killed. A, timeout 300 and a 200 s sleep: the same, 19:12:11 to 19:15:31. B, the control, timeout 150: stopped at 150 s, the tool denied. C, a session started at 150 whose first request rewrote its own setting to 300: the second request's hook ran the full 200 s.
|
|
23
|
+
- Smoke, the real 6.60.0 script with `630` against a scratch server (the real routes and liveness module, wired as `index.ts`): a 6.9.563 poll with `presenceAgeMs=0`, then 35 s quiet (presence recent, no client live): held with a 600 s deadline, answered at 140 s, past the earlier curl's 125 s, and the script printed the allow and exited 0. No poll at all: `{}` in 120 ms, `no_client`, `not_away=no_hold_client`. A poll with `hold=1` and no `presenceAgeMs` (a lens nobody wears): `{}` in 118 ms, the same.
|
|
24
|
+
- The existing mutation gate's cases on the code this release reshaped (the no-client gate, the desk gate, the retraction rule, the quiet-client tick, the poll stamps, `hookHaltReady`) are retargeted to the new lines with the same mutations.
|
|
25
|
+
- **Exactly once, against the real registry (Ghost Hunter W7).** The glasses' saved-turn drain re-sends a turn under one `clientTurnId`; new tests run it against the real `AgentSessionBindingRegistry`: sequential (replays), two concurrent POSTs while the first is in flight (one delivery; the other `native_turn_in_progress`, not delivered; a later retry replays), across a binding retired and re-created on the same thread (replays), and a thread busy with the first send (a new attach `native_target_busy`, the same id `native_turn_in_progress`, then a replay through a new binding). No gap was found.
|
|
26
|
+
- Docs: the README's session questions section (the contract, the rule, the clamp, the fields, forgot vs moved past), the settings list and the `no_client` troubleshooting line.
|
|
27
|
+
- Tests: the contract parse and the liveness module (hold and legacy polls apart, presence only with `hold=1`, out-of-range ages, forward-only on both clocks, the sleep case, a reset); a lens polling with no presence, an older client, presence 10 minutes, exactly 30 minutes, 30 minutes and 1 ms, 31 minutes, a window of 0; the tick re-check at 30:00 and a wearer who came back; a worn lens as a live client until its presence lapses; the ceiling at base; never shorter than the base; unreadable settings with and without a stamp; no client ever (also over the real route under 100 ms) and a restart; every fast path logged; the real defaults wired as `index.ts`; desk return within one 1.5 s production poll; `no_client` and `drained` leave the dialog up; fingerprint-only and ended-row clearing; the listing keeps every waiting item past the cap; forgot vs moved past and the day-long keep; the hook script's stamp, its curl limit and a TERM that leaves no curl and a `hook_gone`; the installer's 630 block, the prior-wait drift and halt readiness; the runtime contract keys. `node server/scripts/away-hold-mutation-gate.mjs` (repository only, not in the package) runs its mutants from a green baseline, each target matched exactly once, and each must fail the test named for it: 68 of 68 killed, including the Ghost Hunter's two survivors (`no_client` and `drained` dropped from the dialog-up set, the never-below-base guard removed). The existing gate's 13 retargeted cases: 13 of 13 killed.
|
|
28
|
+
|
|
29
|
+
## 6.59.0
|
|
30
|
+
|
|
31
|
+
### Work on the glasses: progress, everything open, and a request inbox COS Control works through
|
|
32
|
+
|
|
33
|
+
- 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.
|
|
34
|
+
- 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.
|
|
35
|
+
- **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.
|
|
36
|
+
- `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.
|
|
37
|
+
- 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.
|
|
38
|
+
- 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.
|
|
39
|
+
- A 0.5.248 tab that never reached a session reads as `canceled`, as Control reads it on load.
|
|
40
|
+
- 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.
|
|
41
|
+
- **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.
|
|
42
|
+
- **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.
|
|
43
|
+
- **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.
|
|
44
|
+
- **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.
|
|
45
|
+
- 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).
|
|
46
|
+
- 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).
|
|
47
|
+
- 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.
|
|
48
|
+
- `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.
|
|
49
|
+
- 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.
|
|
50
|
+
- 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.
|
|
51
|
+
- Control's claim and result refusals, every 5xx and the board failures on the activity routes are logged with codes, never a note.
|
|
52
|
+
- Also: `package.json` describes the server without an em dash.
|
|
53
|
+
- 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.
|
|
54
|
+
- 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.
|
|
55
|
+
- 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.
|
|
56
|
+
- 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.
|
|
57
|
+
|
|
1
58
|
## 6.58.2
|
|
2
59
|
|
|
3
60
|
### A New session from Work is named after its task, and visible while it runs
|
package/README.md
CHANGED
|
@@ -246,7 +246,8 @@ The client contract (also at `/api/models` `capabilities.sessionQuestions`):
|
|
|
246
246
|
- Poll `GET /api/session-questions?client=glasses` (or `client=phone`) with X-Cos-Token
|
|
247
247
|
every `pollIntervalMs` (10,000). Only those two `client` values count as a live
|
|
248
248
|
answerer; a poll without `client`, or with any other value, is answered but never
|
|
249
|
-
counts. A client quiet for `liveWindowMs` (30,000) hands every held request back
|
|
249
|
+
counts. A client quiet for `liveWindowMs` (30,000) hands every held request back,
|
|
250
|
+
except one the away hold holds (6.60.0, below).
|
|
250
251
|
- The response carries `enabled`, `mode`, `protocolVersion` (1), `pollIntervalMs`,
|
|
251
252
|
`liveWindowMs`, `pending` and `items` (a question's `questions`, or an approval's
|
|
252
253
|
`approval`: `{tool, summary, detail}`, the command, path or URL exactly as it will run,
|
|
@@ -259,7 +260,69 @@ The client contract (also at `/api/models` `capabilities.sessionQuestions`):
|
|
|
259
260
|
404 `not_found` after a server restart or once a settled item is pruned (10 minutes);
|
|
260
261
|
503 during a maintenance drain, when every held item has already gone back to the Mac.
|
|
261
262
|
|
|
262
|
-
Touching the Mac hands a held request back to its dialog at once; the deadline is 110 s
|
|
263
|
+
Touching the Mac hands a held request back to its dialog at once; the deadline is 110 s,
|
|
264
|
+
or the away hold's.
|
|
265
|
+
|
|
266
|
+
**The away hold (6.60.0).** A request that starts while you are away (the desk idle 90 s,
|
|
267
|
+
the same threshold as above) is held for up to 10 minutes, but only when the glasses or the
|
|
268
|
+
phone that show held questions report that you wore or touched them in the last 30 minutes.
|
|
269
|
+
The client contract (COS Glasses 6.9.563 and later):
|
|
270
|
+
|
|
271
|
+
- Poll `GET /api/session-questions?client=glasses|phone&hold=1&presenceAgeMs=<int>`.
|
|
272
|
+
`hold=1` says this client shows held questions. `presenceAgeMs` is the milliseconds since
|
|
273
|
+
the latest real wearer evidence (glasses: a ring or temple input, or a device status with
|
|
274
|
+
wearing true; phone: a foreground touch), a whole number from 0 to 86,400,000, and is left
|
|
275
|
+
out when there is none since the app started.
|
|
276
|
+
- Only a poll with `hold=1` AND a valid `presenceAgeMs` counts toward the hold. The server
|
|
277
|
+
keeps the newest such presence in memory, on the monotonic clock and the wall clock, and
|
|
278
|
+
reads its age as the larger of the two (so a Mac that slept never makes it look newer).
|
|
279
|
+
Recent means within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (default 1800, clamped to 0
|
|
280
|
+
through 86400; under 30 it means the 30 s live window). After a server restart nothing is
|
|
281
|
+
recent until such a poll.
|
|
282
|
+
- A `hold=1` poll without presence (a connected lens nobody wears) makes no client live and
|
|
283
|
+
holds nothing. A poll without `hold=1` (COS Glasses 6.9.562 and earlier, every older phone
|
|
284
|
+
build) keeps the 6.59.0 meaning exactly: live for 30 s, a 110 s hold, and never the away
|
|
285
|
+
hold.
|
|
286
|
+
|
|
287
|
+
Held that way, a request stays answerable while no client is polling, so glasses or a phone
|
|
288
|
+
that come back can still answer it. It is re-checked every 1.5 s: once no client is live and
|
|
289
|
+
your latest presence is older than the window, it goes to the Mac's dialog. Touching the Mac
|
|
290
|
+
still hands it back within 1.5 s, and a request that arrives while you are at the desk still
|
|
291
|
+
goes straight to the dialog. With no recent presence (a Claude Remote Control session with
|
|
292
|
+
no glasses, say) nothing changes from 6.59.0: `{}` at once and the Mac's dialog.
|
|
293
|
+
|
|
294
|
+
It is never held past the hook's own wait: the PermissionRequest `timeout` in
|
|
295
|
+
`~/.claude/settings.json` (630 s after Install hooks; 130 s on an install from before 6.60.0)
|
|
296
|
+
and the wait the hook stamps on the request, the lesser, minus 10 s. Until Install hooks has
|
|
297
|
+
run, the hook waits no longer than 6.59.0 could hold, so the away hold is off entirely and the
|
|
298
|
+
broker is 6.59.0. After updating, `--hooks status` reads `drift` with `priorWaitOnly: true`
|
|
299
|
+
until Install hooks (COS Control) or `npx --yes @gotcos/glasses-server@latest --hooks install`
|
|
300
|
+
is run once; cancel from the lens keeps working meanwhile. `COS_PERMISSION_BROKER_AWAY_HOLD_S`
|
|
301
|
+
sets the hold (default 600, clamped to 30 through 600; `0` turns the away hold off; a `hold=1` lens still needs recent presence to count as live).
|
|
302
|
+
Additive fields, every earlier one unchanged:
|
|
303
|
+
|
|
304
|
+
- each item: `answerable` (true while listed), `awayHold` (held by the away hold) and
|
|
305
|
+
`waitingAtMac` (false while listed). `deadlineAt` is the latest it stays answerable; it goes
|
|
306
|
+
sooner when your presence lapses with no client polling.
|
|
307
|
+
- the response: `awayHoldMs` (how long a request that starts now is held at most with recent
|
|
308
|
+
presence; 0 when off or before Install hooks), `awayRecentClientMs` (the presence window,
|
|
309
|
+
0 when the hold is off), `settled` (recently settled items, newest first: every one the Mac
|
|
310
|
+
still waits on, and the newest others up to 32; `id`, `sessionId`, `provider`, `kind`,
|
|
311
|
+
`tool`, `createdAt`, `deadlineAt`, `settledAt`, `resolution`, `answerable: false`,
|
|
312
|
+
`awayHold`, `waitingAtMac`; never the questions, the card or the answer), `waitingAtMac`
|
|
313
|
+
(how many of them the Mac's own dialog still waits on), `serverStartedAt` (when this server
|
|
314
|
+
process started) and `waitingAtMacForgottenBefore`.
|
|
315
|
+
- `waitingAtMac` is true when the server handed the request to the Mac's dialog (back at the
|
|
316
|
+
desk, expired, no client, drained) and that request has not been finished since: its own
|
|
317
|
+
tool has not finished or been denied, its turn has not ended, and its session row does not
|
|
318
|
+
read ended. No hook marks the moment a dialog is approved, so an approved tool that is
|
|
319
|
+
still running reads as waiting. It is kept an hour, or up to a day while the session's own
|
|
320
|
+
row still waits on that request.
|
|
321
|
+
- Forgot vs moved past: an entry you saw waiting that is no longer in `settled` was moved past
|
|
322
|
+
if its `settledAt` is after `waitingAtMacForgottenBefore`; at or before it (or from a server
|
|
323
|
+
with another `serverStartedAt`), the server forgot it, and the Mac may still be waiting.
|
|
324
|
+
- `/api/models` `capabilities.sessionQuestions.awayHoldMs` and `awayRecentClientMs`, the same numbers.
|
|
325
|
+
|
|
263
326
|
Allow once or deny only: no permission rule is ever written. Rows carry
|
|
264
327
|
`pending_question_id` or `pending_permission_id` while a request is held, and a question
|
|
265
328
|
row reads `waiting_kind: question` whatever the switch says; `/api/health` reports
|
|
@@ -402,8 +465,13 @@ machine-wide rollback for build 204+ server-owned query recovery),
|
|
|
402
465
|
`COS_MESSAGES_TRAIL=0` (6.52.0: no Messages trail; every job stream and snapshot is the
|
|
403
466
|
6.51.0 one), `COS_PERMISSION_BROKER` (6.52.0: `0` answers every permission request with
|
|
404
467
|
the Mac's own dialog, `questions` holds questions but not tool approvals; unset holds
|
|
405
|
-
both), `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30)
|
|
468
|
+
both), `COS_PERMISSION_BROKER_DESK_IDLE_S` (default 90, never below 30),
|
|
406
469
|
`COS_PERMISSION_BROKER_TIMEOUT_S` (default 110, clamped to 5 through 120),
|
|
470
|
+
`COS_PERMISSION_BROKER_AWAY_HOLD_S` (6.60.0: default 600, clamped to 30 through 600, never
|
|
471
|
+
past the installed hook's wait; `0` turns the away hold off),
|
|
472
|
+
`COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S` (6.60.0: default 1800; the away hold applies
|
|
473
|
+
only when a `hold=1` client reported its wearer present within it; under 30 means the 30 s
|
|
474
|
+
live window),
|
|
407
475
|
`COS_MEDIA_ROOT` (optional image/video store location; default
|
|
408
476
|
`~/.cos-glasses/data/media`), and `COS_VIDEO_UPLOAD_V2=1` (private 6.27.3+
|
|
409
477
|
resumable-video canary, managed by COS Control 0.5.20). The V2 canary retains
|
|
@@ -726,13 +794,17 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
726
794
|
back, set `COS_DURABLE_QUERY_JOBS=0`; accepted jobs still drain while new prompts use legacy streaming.
|
|
727
795
|
- *Session questions never reach the glasses?* The server holds one only when every
|
|
728
796
|
gate passes. `/api/health` `permissionBroker.lastFastPath` names the last reason a
|
|
729
|
-
request went straight to the Mac's dialog: `no_client` (no poll
|
|
730
|
-
`client=glasses|phone` in the last 30 s
|
|
731
|
-
|
|
797
|
+
request went straight to the Mac's dialog: `no_client` (no client could answer: no poll
|
|
798
|
+
with `client=glasses|phone` and without `hold=1` in the last 30 s, no `hold=1` poll in the
|
|
799
|
+
last 30 s whose wearer was present within the window, and (6.60.0) no away hold: the app
|
|
800
|
+
build has no question cards, its timers are frozen, nobody wore the glasses or touched the
|
|
801
|
+
phone recently, or Install hooks has not run; the server log line `fast path no_client
|
|
802
|
+
not_away=...` names which), `desk_active` (the Mac saw input), `approvals_off`
|
|
732
803
|
(`COS_PERMISSION_BROKER=questions`), `broker_off`, `cursor`, `unsupported_tool`
|
|
733
804
|
(ExitPlanMode is never held). `lastQuestionsPollAt` says when a client last counted.
|
|
734
805
|
`--hooks status` must read `installed` (right after updating to 6.53.4 it reads
|
|
735
|
-
`script_outdated` until Install hooks is run once
|
|
806
|
+
`script_outdated` until Install hooks is run once, and right after updating to 6.60.0
|
|
807
|
+
`drift` with `priorWaitOnly: true`; its `advice` line says so). To turn it off with no reinstall, set
|
|
736
808
|
`COS_PERMISSION_BROKER=0` in `~/.cos-glasses/.env` and restart. For the Messages
|
|
737
809
|
trail, `/api/health` `messages_trail.readerErrors` counts lines a reader could not map.
|
|
738
810
|
- *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0; single entry 6.53.3). Installed into ~/.claude/settings.json by
|
|
2
|
+
# COS session hook (glasses-server 6.48.0; Cursor Stop follow-up 6.51.0; halt check 6.53.0; single entry 6.53.3; away hold wait 6.60.0). Installed into ~/.claude/settings.json by
|
|
3
3
|
# `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
|
|
4
4
|
# payload on stdin. It spools one JSON file per event into the hook spool and exits 0
|
|
5
5
|
# on every path. Only PermissionRequest ever talks to the server, and only when the
|
|
@@ -115,7 +115,17 @@ else
|
|
|
115
115
|
{ head -c 1048576 > "$T.in"; cat >/dev/null; }
|
|
116
116
|
fi
|
|
117
117
|
case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
|
|
118
|
-
|
|
118
|
+
# 6.60.0: the installer passes the PermissionRequest block's own timeout as $2. Claude Code
|
|
119
|
+
# stops this hook at that many seconds, so curl below gives up 5 s before it, and the
|
|
120
|
+
# request carries it (`hookWaitS`) so the server never holds it longer. No usable $2 (an
|
|
121
|
+
# earlier block) is the earlier wait, 130, with no stamp.
|
|
122
|
+
WAIT=130; STAMP_WAIT=''
|
|
123
|
+
if [ "$EVENT" = PermissionRequest ]; then
|
|
124
|
+
case "${2:-}" in ''|0*|*[!0-9]*) ;; *)
|
|
125
|
+
if [ ${#2} -le 4 ] && [ "$2" -ge 20 ] && [ "$2" -le 3600 ]; then WAIT=$2; STAMP_WAIT=",\"hookWaitS\":$2"; fi ;;
|
|
126
|
+
esac
|
|
127
|
+
fi
|
|
128
|
+
{ printf '{"ts":%s,"ppid":%s,"event":"%s"%s,"payload":' "$TS" "$PPID" "$EVENT" "$STAMP_WAIT"; cat "$T.in"; printf '}\n'; } > "$T"
|
|
119
129
|
rm -f "$T.in"
|
|
120
130
|
# 6.51.0, CURSOR ONLY. Cursor runs this hook from ~/.claude/settings.json for its own
|
|
121
131
|
# composers, and submits a Stop hook's {"followup_message":...} as that composer's next
|
|
@@ -153,8 +163,18 @@ DESK_S=$(cat "$GH/hook-desk-idle-s" 2>/dev/null || echo 90)
|
|
|
153
163
|
{ [ -n "$IDLE_NS" ] && [ "$(( ${IDLE_NS%.*} / 1000000000 ))" -ge "$DESK_S" ]; } || { cleanup; exit 0; }
|
|
154
164
|
TOK=$(cat "$GH/hook-token" 2>/dev/null); [ -n "$TOK" ] || { cleanup; exit 0; } # no token, no request
|
|
155
165
|
PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
|
|
156
|
-
|
|
157
|
-
|
|
166
|
+
# 6.60.0 (QA round 1, W7): curl runs as a tracked child, not only inside $(...), and this
|
|
167
|
+
# script stops it when it is stopped itself (Claude Code's timeout, an interrupt). The
|
|
168
|
+
# broker then sees the socket close at once (`hook_gone`) and never accepts an answer the
|
|
169
|
+
# session can no longer receive.
|
|
170
|
+
rm -f "$T.out"
|
|
171
|
+
curl -s --connect-timeout 2 --max-time $((WAIT - 5)) -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
|
|
172
|
+
-o "$T.out" --data-binary @"$T.req" "http://127.0.0.1:$PORT/api/permission-requests/ask" &
|
|
173
|
+
CURL=$!
|
|
174
|
+
trap 'kill "$CURL" 2>/dev/null; rm -f "$T.req" "$T.out"; exit 0' TERM INT HUP
|
|
175
|
+
wait "$CURL" || { trap - TERM INT HUP; cleanup; rm -f "$T.out"; exit 0; }
|
|
176
|
+
trap - TERM INT HUP
|
|
177
|
+
OUT=$(cat "$T.out" 2>/dev/null); rm -f "$T.out"
|
|
158
178
|
cleanup
|
|
159
179
|
case "$OUT" in '{"hookSpecificOutput"'*) printf '%s' "$OUT" ;; esac
|
|
160
180
|
exit 0
|
|
@@ -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
|
@@ -36,3 +36,5 @@ Since 6.57.1 `POST /api/work-board/session-recommendation` also takes exactly `{
|
|
|
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
37
|
|
|
38
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
|
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"COS_PERMISSION_BROKER",
|
|
36
36
|
"COS_PERMISSION_BROKER_DESK_IDLE_S",
|
|
37
37
|
"COS_PERMISSION_BROKER_TIMEOUT_S",
|
|
38
|
+
"COS_PERMISSION_BROKER_AWAY_HOLD_S",
|
|
39
|
+
"COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S",
|
|
38
40
|
"COS_LENS_GIST_ENGINE",
|
|
39
41
|
"COS_LENS_GIST_MODEL",
|
|
40
42
|
"COS_LENS_GIST_DAILY_CAP"
|
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.60.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,8 @@
|
|
|
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",
|
|
48
|
+
"!server/scripts/away-hold-mutation-gate.mjs",
|
|
47
49
|
"!server/scripts/mutation-gate.mjs",
|
|
48
50
|
"!server/lib/meeting-engine/__fixtures__/**",
|
|
49
51
|
"!server/lib/__fixtures__/**",
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
"docs/control2-task-runtime.md",
|
|
65
67
|
"docs/work-reviews.md",
|
|
66
68
|
"docs/work-intake.md",
|
|
69
|
+
"docs/work-handoff-requests.md",
|
|
67
70
|
"work-task-runtime",
|
|
68
71
|
"docs/work-task-runtime.md",
|
|
69
72
|
"!server/scripts/build-work-task-runtime.py"
|