From 4f671453c982733040eac74d727ca360c1c6dac7 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 11:08:04 +0800 Subject: [PATCH 001/168] chore(hub): platform-neutral companion pairing label --- hub/src/startHub.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts index 0410cf1d1b..42bcf347cd 100644 --- a/hub/src/startHub.ts +++ b/hub/src/startHub.ts @@ -349,7 +349,7 @@ export async function startHub(options: StartHubOptions = {}): Promise Date: Mon, 17 Aug 2026 11:15:00 +0800 Subject: [PATCH 002/168] docs(api): add native client contract (auth, rest, errors) --- docs/.vitepress/config.ts | 20 ++- docs/api/client-contract/auth.md | 137 ++++++++++++++++ docs/api/client-contract/errors.md | 67 ++++++++ docs/api/client-contract/index.md | 46 ++++++ docs/api/client-contract/rest.md | 244 +++++++++++++++++++++++++++++ 5 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/api/client-contract/auth.md create mode 100644 docs/api/client-contract/errors.md create mode 100644 docs/api/client-contract/index.md create mode 100644 docs/api/client-contract/rest.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 94a4373deb..d00c6c1bee 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -5,6 +5,12 @@ export default defineConfig({ description: 'Control your AI agents from anywhere', base: '/docs/', + // Temporary: client-contract sse/pagination/messages are being written in + // parallel (WP K2). Remove this once those pages land. + ignoreDeadLinks: [ + /^\.\/(sse|pagination|messages)(#.*)?$/ + ], + head: [ ['link', { rel: 'icon', href: '/docs/favicon.ico' }], ], @@ -52,7 +58,19 @@ export default defineConfig({ { text: 'API', items: [ - { text: 'Native Companion Contract', link: '/api/native-companion-contract' } + { text: 'Native Companion Contract', link: '/api/native-companion-contract' }, + { + text: 'Client contract', + items: [ + { text: 'Overview', link: '/api/client-contract/' }, + { text: 'Auth', link: '/api/client-contract/auth' }, + { text: 'REST', link: '/api/client-contract/rest' }, + { text: 'SSE', link: '/api/client-contract/sse' }, + { text: 'Pagination', link: '/api/client-contract/pagination' }, + { text: 'Messages', link: '/api/client-contract/messages' }, + { text: 'Errors', link: '/api/client-contract/errors' } + ] + } ] } ], diff --git a/docs/api/client-contract/auth.md b/docs/api/client-contract/auth.md new file mode 100644 index 0000000000..4b1bb1d06a --- /dev/null +++ b/docs/api/client-contract/auth.md @@ -0,0 +1,137 @@ +# Auth & pairing + +How a native client obtains and maintains credentials for one hub. A client may be paired with multiple hubs; everything below is **per hub URL**. + +## Credential model + +Source of truth: `hub/src/config/cliApiToken.ts`, `hub/src/web/routes/auth.ts`. + +| Credential | Lifetime | Where it comes from | What it's for | +|------------|----------|--------------------|---------------| +| **Access token** | Long-lived (until the operator rotates `CLI_API_TOKEN`) | Pairing QR / deeplink, or typed in manually | Exchanged for a JWT via `POST /api/auth`; the client's durable secret | +| **JWT** | 4 hours | Response of `POST /api/auth` | `Authorization: Bearer` on every `/api/*` request | + +The hub's base token (`CLI_API_TOKEN`) is auto-generated on first run (32 random bytes, base64url, ~43 chars) and persisted in the hub's `settings.json`; an operator-provided env token overrides it. The configured base token never contains a `:` — the hub refuses to start otherwise (`validateCliApiToken`). + +## Pairing + +Source of truth: `hub/src/startHub.ts` (lines ~317–366), `web/src/components/settings/CompanionPairing.tsx`. + +The hub terminal (when started with `--relay`) prints two QR codes; the web app's Settings → Companion pairing screen renders the second one as well: + +| QR | Format | Params | +|----|--------|--------| +| Web direct-access | `https://app.hapi.run/?hub=&token=` | `hub`, `token` | +| Companion deeplink | `hapicompanion://bind?hub=&code=` | `hub`, `code` | + +Native clients register the `hapicompanion://` scheme and parse `bind` links: `hub` is the hub base URL, `code` is the access token. Note the param-name mismatch: the web QR carries the same value under `token=`, the companion deeplink under `code=`. A robust scanner may accept both forms; the deeplink form is the canonical one for natives. Always provide a manual fallback (type hub URL + access token) for `--relay`-less local hubs. + +## Access-token grammar + +Source of truth: `hub/src/utils/accessToken.ts`. + +``` +accessToken = base [":" namespace] +``` + +- Split on the **last** `:`. No colon → namespace defaults to `"default"`. +- After splitting, both parts must be non-empty and contain no leading/trailing whitespace, else the token is invalid. +- The whole input is trimmed before parsing. + +Clients must treat the access token as an **opaque string** and pass it through unchanged to `POST /api/auth` — never split it client-side to "normalize" it. The hub does the splitting; the namespace part selects which sessions the resulting JWT can see (see [Namespaces](#namespaces)). + +## Token exchange + +Source of truth: `hub/src/web/routes/auth.ts`. + +``` +POST /api/auth +Content-Type: application/json + +{ "accessToken": "" } +``` + +Success (200): + +```json +{ + "token": "", + "user": { "id": 1, "firstName": "Web User" } +} +``` + +Failures: `400 {"error": "Invalid body"}`, `401 {"error": "Invalid access token"}`. Request schema: `AuthRequestSchema` in `shared/src/apiTypes.ts` (the `initData` variant is Telegram-only). + +`POST /api/bind` (`hub/src/web/routes/bind.ts`) is **Telegram-only** — it binds a Telegram identity to a namespace and requires Telegram `initData`. Native clients never call it. + +## The JWT + +Source of truth: `hub/src/web/routes/auth.ts` (signing), `hub/src/web/middleware/auth.ts` (verification), `hub/src/config/jwtSecret.ts` (key). + +- HS256, signed with a hub-local 32-byte secret (`/jwt-secret.json`). +- Payload: `{ "uid": , "ns": }` plus standard `iat`/`exp`. +- **Expires 4 hours** after issue. + +Treat the token as opaque for auth purposes, but clients may base64url-decode the payload to read `exp` for proactive refresh scheduling (the web client does exactly this — `decodeJwtExpMs` in `web/src/hooks/useAuth.ts`). + +## Sending the token + +Source of truth: `hub/src/web/middleware/auth.ts`. + +- Every `/api/*` request: `Authorization: Bearer `. +- Exceptions: `/api/auth` and `/api/bind` are unauthenticated; `GET /health` is outside `/api` and unauthenticated. +- `GET /api/events` (SSE) **additionally** accepts `?token=` as a query param, for HTTP stacks whose EventSource cannot set headers. The header wins when both are present. No other endpoint accepts query-param auth. + +## Silent re-auth (401 handling) + +Reference behavior: `web/src/api/client.ts` (`request()`), `web/src/hooks/useAuth.ts` (`refreshAuth`). + +The JWT expires every 4 hours, so 401s are routine, not exceptional. The contract: + +1. On any 401 from an `/api/*` call, re-exchange the **stored access token** via `POST /api/auth`. +2. If the exchange succeeds, retry the original request **exactly once** with the new JWT. +3. If the exchange fails (or the retry 401s again), surface "signed out" and require re-pairing — the access token was rotated or revoked. + +Implementation notes (all present in the web reference and recommended for natives): + +- **Single-flight** the refresh: concurrent 401s must share one in-flight `POST /api/auth` promise, not race N exchanges (`refreshPromiseRef` in `useAuth.ts`). +- Throttle failed refresh attempts (web: 15 s between attempts) so a dead hub doesn't cause a refresh storm. +- Optionally refresh proactively: the web schedules a refresh 60 s before `exp` and on app-foreground when remaining TTL < 60 s. This keeps the SSE connection (which authenticates once, at connect time) from dying mid-stream with a stale token on reconnect. + +## Namespaces + +Source of truth: `hub/src/web/middleware/auth.ts` (sets `namespace` from `ns`), `hub/src/web/routes/guards.ts`, `hub/src/web/routes/{usage,storage,hubSettings,voice}.ts`. + +Every request executes in the JWT's namespace (`ns` claim, derived from the access-token suffix). Sessions and machines are namespace-scoped: a session in another namespace answers `403 Session access denied` / `404 Session not found` per the guard logic. + +`ns === "default"` is the **hub owner**. Owner-only surfaces (403 for any other namespace): + +| Endpoint | Check | +|----------|-------| +| `GET /api/usage/summary` | `hub/src/web/routes/usage.ts` | +| `GET /api/storage/sqlite` | `hub/src/web/routes/storage.ts` | +| `PUT /api/hub-settings` (write; read is open to all namespaces) | `hub/src/web/routes/hubSettings.ts` | +| `GET`/`PUT /api/voice/transcription/credentials` | `hub/src/web/routes/voice.ts` | + +Clients should hide the usage/storage screens entirely when the paired namespace is not `default` (the namespace is known client-side: it's the part after the last `:` of the access token, or `default`). + +## Credential storage guidance + +- Store the **access token** in platform-secure storage: iOS Keychain, Android `EncryptedSharedPreferences` (behind an interface so the mechanism can be swapped). Never plain files, never logs. +- Key credentials **per hub base URL** (normalized), since a client can pair with several hubs. Web reference: localStorage key `hapi_access_token::` (`web/src/hooks/useAuth.ts`, `web/src/components/settings/CompanionPairing.tsx`). +- The JWT is a cache, not a secret worth keeping: it is fine to hold it in memory only and re-exchange on cold start. If persisted (to save one round-trip at launch), store it alongside the access token with the same protection. +- On unpair/sign-out: delete both credentials, and unregister FCM (`DELETE /api/devices/register`) first while you still hold a valid JWT. + +## 401 error bodies + +All are JSON with an `error` string; none carry a `code` field except Telegram's `not_bound` (which reuses `error` as the discriminator — natives never see it): + +| Origin | Body | Meaning | +|--------|------|---------| +| Middleware, any `/api/*` | `{"error": "Missing authorization token"}` | No bearer header (and no `?token=` on `/api/events`) | +| Middleware, any `/api/*` | `{"error": "Invalid token"}` | JWT signature/expiry verification failed → run silent re-auth | +| Middleware, any `/api/*` | `{"error": "Invalid token payload"}` | JWT valid but payload not `{uid, ns}` (foreign/ancient token) | +| `POST /api/auth` | `{"error": "Invalid access token"}` | Access token wrong or rotated → require re-pairing | +| `POST /api/auth` (Telegram path) | `{"error": "not_bound"}` | Telegram-only; not reachable with `accessToken` auth | + +The re-auth loop must distinguish the middleware 401s (recoverable via re-exchange) from the `/api/auth` 401 (terminal — do not loop). diff --git a/docs/api/client-contract/errors.md b/docs/api/client-contract/errors.md new file mode 100644 index 0000000000..49d738a1cb --- /dev/null +++ b/docs/api/client-contract/errors.md @@ -0,0 +1,67 @@ +# Errors + +Error semantics for all `/api/*` endpoints. Grounded in `hub/src/web/routes/guards.ts` and the individual route files under `hub/src/web/routes/`; reference consumer `web/src/api/client.ts` (`ApiError`, `parseErrorCode`). + +## Body shape + +Error responses are JSON: + +```json +{ "error": "Session is inactive", "code": "session_inactive" } +``` + +- `error` — human-readable message. **Never match on it**: consumers i18n it and the hub may reword it (this rule is stated in `guards.ts` itself). +- `code` — optional stable machine-readable discriminator. Clients branch on `(status, code)`. +- `issues` — present on some 400s: Zod validation details (`parsed.error.issues` or `.flatten()` output). Useful for logging, not for branching. +- A few endpoints add context fields (e.g. 413 export adds `count`/`limit`; 422 reopen adds `missing[]`). + +When no `code` is present, branch on status alone and treat the failure generically. The web reference falls back to using `error` as a pseudo-code when `code` is absent (`parseErrorCode`) — acceptable for logging, not for logic. + +## Status × code table + +| Status | `code` | Where (source) | Meaning / client action | +|--------|--------|----------------|-------------------------| +| 400 | — | All routes with Zod bodies (`{error: 'Invalid body'}`, some with `issues`) | Client bug — fix the request; do not retry | +| 400 | — | Flavor gates (`sessions.ts`: wrong-flavor model/mode endpoints) | Hide the control for this flavor | +| 400 | `scratchlist_attachment_invalid`, `scratchlist_entry_empty`, attachment-limit codes from `validateScratchlistAttachmentsForWrite` | `sessions.ts` scratchlist routes | Surface validation message | +| 401 | — | Middleware (`middleware/auth.ts`) and `POST /api/auth` (`routes/auth.ts`) | See [Auth → 401 bodies](./auth.md#401-error-bodies); middleware 401 → silent re-auth once, `/api/auth` 401 → re-pair | +| 403 | — | `guards.ts` (`Session access denied`, `Machine access denied`); owner-only routes (`usage.ts`, `storage.ts`, `hubSettings.ts`, `voice.ts`) | Namespace mismatch / not hub owner — hide the surface, don't retry | +| 403 | `access_denied` | RPC-flow results mapped in `sessions.ts` (resume/reopen/cursor-chat-store) | Same as above | +| 404 | — | `guards.ts` (`Session not found`, `Machine not found`); `permissions.ts` (`Request not found`); scratchlist entry/attachment; `events.ts` visibility (`Subscription not found`) | Stale reference — refresh the parent list | +| 404 | `session_not_found`, `machine_not_found` | Coded variants from resume/reopen/restart-runner result mapping | Same | +| 409 | `session_inactive` | `guards.ts` `requireSession(requireActive)` — send/steer/abort/approve/deny/config on an inactive session | Offer **Reopen** (the web router does exactly this on this code) | +| 409 | `scratchlist_at_cap` | `sessions.ts` scratchlist create (200-entry cap) | Show cap notice; do not retry | +| 409 | `scratchlist_attachment_in_use` | `sessions.ts` attachment delete while still referenced | Detach from entry first | +| 409 | `resume_unavailable` | `sessions.ts` resume/reopen result mapping | Session can't be resumed (e.g. unsupported state) | +| 409 | `metadata_conflict` | `sessions.ts` reopen result mapping | Refetch session, retry once at most | +| 409 | — (version conflict) | `sessions.ts` PATCH rename/summary, `machines.ts` PATCH rename — message mentions `version`/`concurrently`; **no code** | Concurrent edit — refetch and reapply | +| 409 | — | `sessions.ts` delete-while-active, archive of plain inactive row, fork/rewind refusals, remote-only config on terminal-controlled sessions (`controlledByUser`) | Surface message; refresh session state | +| 413 | — | `sessions.ts` upload (> 50 MB decoded), export too large (`{error, count, limit}`); `voice.ts` transcription (`Audio file too large`, 25 MB audio / ~26 MB body) | Reduce payload | +| 413 | `scratchlist_attachment_too_large` | `sessions.ts` scratchlist upload | Reduce attachment | +| 422 | — | `sessions.ts` reopen with incomplete metadata (`{error, missing[]}`); title-suggestion pass-through (`TitleSuggestionError`, statuses 422/429/502/503) | Not reopenable / feature unavailable | +| 429 | — | Title suggestion (provider rate limit) | Back off | +| 500 | — | Catch-all in most routes (`{error: message}`) | Log; generic failure UI | +| 502 | — | Title suggestion upstream failure; restart-runner unknown error | Retry later | +| 503 | — | `guards.ts` `requireSyncEngine` → `{error: 'Not connected'}` — hub subsystems not up (startup/shutdown window); also Telegram-disabled on `/api/auth` initData path | Retry with backoff | +| 503 | `no_machine_online` | resume/reopen/spawn-flow result mapping (`sessions.ts`) | The machine that owns the session is offline — tell the user to start the runner | +| 503 | `machine_offline` | `machines.ts` restart-runner | Same | +| 503 | `rpc_target_missing` | `machines.ts` pi/codex model catalogs (`RPC_TARGET_MISSING_ERROR_CODE` in `shared/src/rpcMethods.ts` — RPC handler unregistered or socket disconnected) | CLI-side target gone — treat as offline | + +## RPC-wrapped endpoints + +Many endpoints do not answer from hub state — the hub relays the request over Socket.IO to the session's CLI process (or the machine's runner) and forwards the result: git/file/directory/search, generated images, uploads, model catalogs, slash-commands, skills, spawn, list-directory, paths/exists. (The mode/model/effort config endpoints are RPC-backed too, but map apply-failures to 409 with a message.) Their failure modes differ from plain endpoints: + +1. **CLI reachable, command failed** → HTTP **200** with `{success: false, error}` (e.g. `runRpc` in `hub/src/web/routes/git.ts` catches RPC errors, including the 30 s RPC timeout, and returns them as a JSON envelope). Clients must check the `success` field on every RPC-shaped response; HTTP 200 alone means nothing. +2. **CLI offline / handler missing** → depends on the route: the model-catalog routes in `machines.ts` map `RpcTargetMissingError` to **503 `rpc_target_missing`**; `git.ts`-style routes fold it into the 200 `{success: false}` envelope; resume/reopen surface **503 `no_machine_online`**. +3. **Hub subsystems not up** → **503 `Not connected`** from `requireSyncEngine` (brief startup/shutdown window). + +Practical rule: treat `success: false`, 503 `rpc_target_missing`, and 503 `no_machine_online` as the same user-facing condition — "the computer running this session is not reachable" — with the raw `error` string available in a details view. + +## Retry guidance + +| Class | Retry? | +|-------|--------| +| 400 / 403 / 404 / 409 / 413 / 422 | No (fix input, refresh state, or hide surface) | +| 401 (middleware) | Once, after silent re-auth ([Auth](./auth.md#silent-re-auth-401-handling)) | +| 429 / 502 / 503 | Yes, with backoff | +| 200 `{success: false}` | Manual retry only (user-initiated) — the CLI answered and said no | diff --git a/docs/api/client-contract/index.md b/docs/api/client-contract/index.md new file mode 100644 index 0000000000..952ac7c8ee --- /dev/null +++ b/docs/api/client-contract/index.md @@ -0,0 +1,46 @@ +# Native client contract + +**Audience:** Implementers of native HAPI clients — the iOS app (`ios/`), the Android app (`android/`), and any other non-web client that talks to a hub's client API. These pages are the primary spec for that work: every claim is grounded in hub/web source, and each section names its source file so implementers (human or AI agent) can verify against code. + +**Scope:** The HTTP contract between a client and one hub — pairing and auth, REST endpoints, SSE streaming, message pagination, message decoding, and error semantics. A client using only this contract can replicate the web app's core feature set over **REST + SSE alone** (no Socket.IO — that transport is CLI↔hub internal). + +## Pages + +| Page | Contents | +|------|----------| +| [Auth](./auth.md) | Pairing deeplink, access-token grammar, JWT exchange, silent re-auth, namespaces, credential storage | +| [REST](./rest.md) | Endpoint tables (v1-required and out-of-scope), request/response shapes, gzip negotiation | +| [SSE](./sse.md) | `GET /api/events` stream: subscription modes, resume handshake, event ids, reconnect policy | +| [Pagination](./pagination.md) | Message window: composite cursors, epoch reset, optimistic-send reconciliation | +| [Messages](./messages.md) | `DecryptedMessage.content` decoding tree (`codex` / `output` / `event` families) | +| [Errors](./errors.md) | `{status, code}` table, error body shapes, RPC-wrapped failure modes | + +## Versioning + +Source of truth: `hub/src/web/server.ts` (`/health` route), `shared/src/version.ts`. + +`GET /health` requires no auth and returns: + +```json +{ + "status": "ok", + "protocolVersion": 1, + "capabilities": { + "workGraph": true, + "titleSuggestion": false + } +} +``` + +- `protocolVersion` is the wire-protocol generation (`PROTOCOL_VERSION` in `shared/src/version.ts`, currently `1`). A client built against this contract targets version 1 and should surface an "update required" state if it ever sees a higher value. +- `capabilities` is **additive**: new hub features appear as new keys. Clients must ignore unknown keys and treat missing keys as "not supported". Feature-gate on capability keys, never on hub build versions. + +## Executable spec: golden fixtures + +The prose in [Messages](./messages.md) describes the decoding tree, but the *normative* artifact is `shared/fixtures/` — machine-generated golden files produced from the web implementation's chat pipeline (`web/src/chat/`). A native client's protocol module must reproduce those fixtures exactly; CI regenerates them whenever the web pipeline changes, so drift is caught automatically. + +`shared/fixtures/` is a companion deliverable of this contract and may not exist yet when you first read this — the fixture generator and batches land in later work packages of the same track. Until then, `web/src/chat/` itself is the reference implementation. + +## Relationship to the companion push contract + +[`docs/api/native-companion-contract.md`](../native-companion-contract.md) is the **FCM push contract**: device registration (`POST /api/devices/register`) and the outbound push payload the hub sends through Firebase. It predates this contract and is unchanged. A native client implements *both*: this contract for everything interactive, the companion contract for background push. Where the two overlap (auth, send-message, approve/deny), this contract is the more detailed spec. diff --git a/docs/api/client-contract/rest.md b/docs/api/client-contract/rest.md new file mode 100644 index 0000000000..067fe997ad --- /dev/null +++ b/docs/api/client-contract/rest.md @@ -0,0 +1,244 @@ +# REST endpoints + +Endpoint tables for native clients, grouped by feature. Request/response shapes reference the Zod schemas in `shared/src/schemas.ts` and `shared/src/apiTypes.ts` (package `@hapi/protocol`) — those schemas, not the prose here, are the field-level source of truth. Route behavior is grounded in `hub/src/web/routes/*.ts`; `web/src/api/client.ts` is the reference consumer. + +## Conventions + +- All paths below are relative to the hub base URL. Everything under `/api` requires `Authorization: Bearer ` ([Auth](./auth.md)). +- Path params (`:id`, `:messageId`, …) must be URL-encoded (the web client uses `encodeURIComponent` throughout). +- Request bodies are JSON (`content-type: application/json`) with **one exception**: `POST /api/voice/transcription` is `multipart/form-data`. Responses are JSON unless noted (generated images and scratchlist attachments return raw bytes). +- Bodies are validated with Zod; failures return `400` (see [Errors](./errors.md)). +- **gzip:** the hub gzips `/api/*` JSON responses when `Accept-Encoding` accepts gzip. Negotiation is q-value aware (`acceptsGzip` in `hub/src/web/sseCompression.ts`): `gzip;q=0` is honored as a refusal, `*` counts unless a `gzip` entry overrides it. Send a normal `Accept-Encoding: gzip` and decompress transparently. The SSE stream is gzip-compressed separately with per-event flush — see [SSE](./sse.md). Source: `hub/src/web/server.ts`. +- Several endpoints are **RPC-wrapped**: the hub forwards to the session's CLI process over Socket.IO and relays the result. These can fail with HTTP 200 + `{success: false, error}` or with 503 — see [Errors](./errors.md#rpc-wrapped-endpoints). + +## Tier 1 — required for v1 clients + +### Health + +Source: `hub/src/web/server.ts`. + +| Method & path | Request | Response | +|---|---|---| +| `GET /health` (no auth) | — | `{status: 'ok', protocolVersion: number, capabilities: {workGraph?, titleSuggestion?}}` — additive capabilities, ignore unknown keys | + +### Sessions — list & detail + +Source: `hub/src/web/routes/sessions.ts`; shapes `SessionSchema` (`shared/src/schemas.ts`), `SessionSummary` (`shared/src/sessionSummary.ts`). + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/sessions` | Query: `limit?` (1–500), `order?=updatedAt` | `{sessions: (SessionSummary & {futureScheduledMessageCount, nextScheduledAt})[]}` | +| `GET /api/sessions/:id` | — | `{session: Session}` (full record incl. `metadata`, `agentState`, `todos`, versions) | + +Default list order: globalPinned → pinned → active → pending-request count → `updatedAt` desc; `order=updatedAt` gives pure recency. List badges come from `SessionSummary.pendingRequestsCount` (authoritative total) and `pendingRequests` (capped at 5, oldest-first) — do not derive counts from `pendingRequests.length`. + +### Sessions — lifecycle + +Source: `hub/src/web/routes/sessions.ts`; request schemas in `shared/src/apiTypes.ts`. + +| Method & path | Request | Response | +|---|---|---| +| `POST /api/sessions/:id/resume` | `{permissionMode?}` (`ResumeSessionRequestSchema`) | `{type: 'success', sessionId}` | +| `POST /api/sessions/:id/reopen` | `{}` | `{ok: true, sessionId, resumed: boolean, cursorSessionProtocol?}` (`ReopenSessionResponseSchema`); `422 {error, missing[]}` if metadata is incomplete | +| `POST /api/sessions/:id/abort` | `{}` | `{ok: true}` (active sessions only) | +| `POST /api/sessions/:id/archive` | `{}` | `{ok: true}` or `{ok: true, alreadyArchived: true}`; 409 for a plain inactive session | +| `DELETE /api/sessions/:id` | — | `{ok: true}`; 409 while active (archive first) | +| `PATCH /api/sessions/:id` | `{name}` (1–255 chars) | `{ok: true}` (rename) | +| `PATCH /api/sessions/:id/summary` | `{text}` (1–255 chars) | `{ok: true}` | +| `PUT /api/sessions/:id/pin` | `{mode: 'none'\|'project'\|'global'}` | `{ok: true}` | +| `POST /api/sessions/:id/switch` | `{}` | `{ok: true}` — hands terminal-controlled session over to remote control | +| `POST /api/sessions/:id/title-suggestion` | — | `{title}`; errors pass through 422/429/502/503 | +| `GET /api/sessions/:id/slash-commands` | — | `SlashCommandsResponse` `{success, commands?, error?}` | +| `GET /api/sessions/:id/skills` | — | `SkillsResponse`-shaped `{success, ...}` | + +::: warning resume / reopen may return a different sessionId +Both endpoints return the id of the session that now carries the conversation — which **may differ from the id you called them on** (fresh spawn under a new id; the old row is superseded). Clients must migrate composer drafts and replace navigation to the returned id. Reference: `web/src/routes/sessions/followSupersedingSession.ts`; the durable link also appears as `metadata.supersededBySessionId` on the old session. +::: + +Optional for v1 (endpoints exist; the v1 native scope does not require them): `POST /api/sessions/:id/fork` `{messageLocalId?}` → `{sessionId}`, `POST /api/sessions/:id/rewind` `{messageLocalId}` → `{success: true}`, `GET /api/sessions/:id/export` (413 when too large). + +### Messages + +Source: `hub/src/web/routes/messages.ts`; schemas `MessagesQuerySchema`, `SendMessageRequestSchema`, `QueuedStateRequestSchema` (`shared/src/apiTypes.ts`), responses `CancelMessageResponseSchema`, `SteerQueuedMessageResponseSchema` (`shared/src/schemas.ts`). + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/sessions/:id/messages` | Query: `limit?` (1–200, default 50), cursor pairs `beforeSeq+beforeAt` \| `afterSeq+afterAt` (+ optional `untilSeq+untilAt`, `epoch` with `after`) | `MessagesResponse` `{messages: DecryptedMessage[], page: {direction, limit, epoch, reset, nextBefore*/nextAfter*, snapshotHead*, hasMore}}` — full cursor semantics in [Pagination](./pagination.md) | +| `POST /api/sessions/:id/messages` | `{text, localId?, attachments?, scheduledAt?, deliveryMode?: 'queue'\|'steer'}` — text or attachments required; `scheduledAt` requires `localId`, must be ≤ 7 days out, excludes attachments and steer | `{ok: true}` — the message itself arrives via SSE (`message-received`), reconciled by `localId` | +| `DELETE /api/sessions/:id/messages/:messageId` | — | `{status: 'cancelled', localId}` \| `{status: 'invoked', message}` (cancel a queued message; `invoked` = too late) | +| `POST /api/sessions/:id/messages/:messageId/steer` | — | `{status: 'steered', localId}` \| `{status: 'invoked', message}` \| `{status: 'failed', error, localId}` | +| `POST /api/sessions/:id/messages/queued-state` | `{localIds: string[]}` (≤ 1000, deduped) | `{queuedLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}` — resync optimistic sends after reconnect | + +The hub stamps `sentFrom: 'webapp'` on REST-sent messages server-side; the request body has no such field. + +### Permissions + +Source: `hub/src/web/routes/permissions.ts`. Pending requests are **not messages**: they live in `session.agentState.requests` (keyed by request id) and move to `agentState.completedRequests` when resolved — schemas `AgentStateRequestSchema` / `AgentStateCompletedRequestSchema` in `shared/src/schemas.ts`. + +| Method & path | Request | Response | +|---|---|---| +| `POST /api/sessions/:id/permissions/:requestId/approve` | `{mode?, allowTools?: string[], decision?, answers?}` | `{ok: true}` | +| `POST /api/sessions/:id/permissions/:requestId/deny` | `{decision?}` | `{ok: true}` | + +- `decision`: `'approved' | 'approved_for_session' | 'denied' | 'abort'`. +- `mode`: optionally switch permission mode while approving; validated against the session flavor. +- `answers` has **two formats**, matching the requesting tool: flat `Record` (AskUserQuestion) or nested `Record` (request_user_input). Both routes 404 with `Request not found` if the id is not currently pending, and 409 `session_inactive` when the session is inactive. + +### Session config — mode / model / effort (per flavor) + +Source: `hub/src/web/routes/sessions.ts`; flavor gates in `shared/src/modes.ts` (`getPermissionModesForFlavor`) and `shared/src/flavors.ts` (`supportsModelChange`, `supportsEffort`). Wrong-flavor calls return 400; several are additionally rejected with 409 when the session is terminal-controlled (`agentState.controlledByUser === true`). + +| Method & path | Request | Applies to | +|---|---|---| +| `POST /api/sessions/:id/permission-mode` | `{mode: PermissionMode}` | All flavors except `pi` (per-flavor allowed sets in `modes.ts`) | +| `POST /api/sessions/:id/model` | `{model: string \| {provider, modelId} \| null}` | All flavors (`supportsModelChange` is true for every current flavor); remote-only for codex/cursor/grok | +| `POST /api/sessions/:id/effort` | `{effort: string \| null}` | claude, grok, pi (`supportsEffort`) | +| `POST /api/sessions/:id/model-reasoning-effort` | `{modelReasoningEffort: string \| null}` | codex, opencode (remote-only) | +| `POST /api/sessions/:id/service-tier` | `{serviceTier: 'fast' \| 'standard'}` | codex (remote-only) | +| `POST /api/sessions/:id/collaboration-mode` | `{mode: 'default' \| 'plan'}` | codex (remote-only) | +| `POST /api/sessions/:id/copilot-agent-mode` | `{mode}` | copilot (remote-only) | + +All respond `{ok: true}`; apply-failures return 409 with a message. Model/effort **catalogs** (RPC-wrapped; all return `{success, ...} \| {success: false, error}`): + +| Method & path | Notes | +|---|---| +| `GET /api/sessions/:id/codex-models`, `/opencode-models`, `/cursor-models`, `/grok-models`, `/copilot-models`, `/pi-models` | Active session of the matching flavor; 400 otherwise | +| `GET /api/sessions/:id/opencode-reasoning-effort-options`, `/grok-reasoning-effort-options` | Same pattern | +| `GET /api/machines/:id/agy-models`, `/pi-models`, `/codex-models`, `/cursor-models` | Machine-level (pre-spawn pickers) | +| `GET /api/machines/:id/opencode-models?cwd=`, `/grok-models?cwd=`, `/copilot-models?cwd=` | `cwd` query required (400 without) | + +### Machines & spawning + +Source: `hub/src/web/routes/machines.ts`; schemas `SpawnSessionRequestSchema`, `MachineListDirectoryRequestSchema`, `MachinePathsExistsRequestSchema`, `RenameMachineRequestSchema` (`shared/src/apiTypes.ts`), `MachineSchema` (`shared/src/schemas.ts`). + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/machines` | — | `{machines: Machine[]}` (online machines in the caller's namespace) | +| `PATCH /api/machines/:id` | `{displayName}` (trimmed; ≤ 64 chars; empty clears back to hostname) | `{ok: true}` | +| `POST /api/machines/:id/spawn` | `{directory, agent?, model?, effort?, modelReasoningEffort?, yolo?, permissionMode?, sessionType?: 'simple'\|'worktree', worktreeName?, serviceTier?, collaborationMode?, copilotAgentMode?, startingMode?: 'remote'\|'pty'}` | `{type: 'success', sessionId}` \| `{type: 'error', message}` (agy accepts only `remote`) | +| `POST /api/machines/:id/list-directory` | `{path, includeHidden?}` | `{success, entries?: (DirectoryEntry & {isGitRepo?})[], error?}` | +| `POST /api/machines/:id/paths/exists` | `{paths: string[]}` (≤ 1000) | `{exists: Record}` | +| `POST /api/machines/:id/restart-runner` | `{}` | `{message}`; errors carry `code: 'machine_not_found' \| 'machine_offline'` | + +Note the spawn response is discriminated on `type`, not HTTP status — a failed spawn is still HTTP 200. + +### Git & files (RPC-wrapped) + +Source: `hub/src/web/routes/git.ts`. Git endpoints return the **raw command output** — `GitCommandResponse` `{success, stdout?, stderr?, exitCode?, error?}` — and the client parses `stdout` itself (reference parsers: `web/src/lib/gitParsers.ts`). + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/sessions/:id/git-status` | — | `GitCommandResponse` (raw `git status` stdout) | +| `GET /api/sessions/:id/git-diff-numstat` | Query: `staged=true\|false` | `GitCommandResponse` (raw `git diff --numstat` stdout) | +| `GET /api/sessions/:id/git-diff-file` | Query: `path` (required), `staged?` | `GitCommandResponse` (raw unified diff) | +| `GET /api/sessions/:id/file` | Query: `path` (required) | `{success, content?, size?, modified?, error?}` — `content` is **base64** (decode before display; web ref: `web/src/routes/sessions/file.tsx`) | +| `GET /api/sessions/:id/files` | Query: `query?`, `limit?` (1–500, default 200) | `{success, files: [{fileName, filePath, fullPath, fileType: 'file', size?, modified?}]}` (ripgrep-backed search) | +| `GET /api/sessions/:id/directory` | Query: `path?` (empty = session root) | `{success, entries?: [{name, type: 'file'\|'directory'\|'other', size?, modified?}], error?}` | + +When the session has no `metadata.path` yet, these return HTTP 200 `{success: false, error: 'Session path not available'}`. + +### Generated images + +Source: `hub/src/web/routes/git.ts` (same file). + +| Method & path | Response | +|---|---| +| `GET /api/sessions/:id/generated-images/:imageId` | **Raw bytes** with `Content-Type`, `Content-Disposition`, `ETag: ""`, `Cache-Control: private, max-age=31536000, immutable`; `404` JSON when missing | + +The image id is an immutable content fingerprint, so it doubles as the ETag: send `If-None-Match` and the hub answers `304` *without* the CLI round-trip. Cache aggressively (iOS: URLCache honors this automatically; Android: OkHttp cache). + +### Uploads (message attachments) + +Source: `hub/src/web/routes/sessions.ts` (`UploadFileRequestSchema`). + +| Method & path | Request | Response | +|---|---|---| +| `POST /api/sessions/:id/upload` | JSON `{filename, content, mimeType}` — `content` is **base64**; decoded size limit 50 MB → `413` | `{success, path?, error?}` — pass the resulting metadata in `attachments` of send-message | +| `POST /api/sessions/:id/upload/delete` | `{path}` | `{success, error?}` | + +Uploads are JSON+base64, **not** multipart. Both require an active session. + +### Scratchlist + +Source: `hub/src/web/routes/sessions.ts` (scratchlist section); schemas `ScratchlistEntryCreateRequestSchema`, `ScratchlistEntryUpdateRequestSchema`, caps `SCRATCHLIST_MAX_ENTRIES = 200`, `SCRATCHLIST_MAX_TEXT_LENGTH = 10000` (`shared/src/apiTypes.ts`). + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/sessions/:id/scratchlist` | — | `{entries: ScratchlistEntry[]}` (`{entryId, text, createdAt, updatedAt, attachments[]}`) | +| `POST /api/sessions/:id/scratchlist` | `{text, entryId?, createdAt?, attachments?}` (text ≤ 10 000; text or attachments required) | `201 {entry}`; `200 {entry}` when `entryId` already exists (idempotent retry); `409 code: 'scratchlist_at_cap'` at 200 entries | +| `PUT /api/sessions/:id/scratchlist/:entryId` | `{text?, attachments?}` (at least one) | `{entry}` | +| `DELETE /api/sessions/:id/scratchlist/:entryId` | — | `{ok: true}` | +| `GET /api/sessions/:id/scratchlist/limits` | — | `{limits}` (attachment size/count/byte budgets) | +| `POST /api/sessions/:id/scratchlist/upload` | JSON `{filename, content (base64), mimeType}` | `{success, attachment}`; `413 code: 'scratchlist_attachment_too_large'` | +| `GET /api/sessions/:id/scratchlist/attachments/:attachmentId` | — | **Raw bytes** (`Content-Type` from stored metadata) | +| `DELETE /api/sessions/:id/scratchlist/attachments/:attachmentId` | — | `{ok: true}`; `409 code: 'scratchlist_attachment_in_use'` while referenced by an entry | + +Mutations bump `scratchlistUpdatedAt` in the session's SSE patch — use it as a refetch trigger, not as data. + +### Voice dictation (the one multipart endpoint) + +Source: `hub/src/web/routes/voice.ts`. + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/voice/transcription/providers` | — | `{providers: [{id, label, modes}]}` — only providers whose keys are configured on the hub | +| `POST /api/voice/transcription` | `multipart/form-data`: `file` (audio, ≤ 25 MB, `audio/*` or webm/mp4), `provider` (`openai`\|`elevenlabs`\|`deepgram`\|`groq`\|`openai-compatible`), `mode` = `standard`, `language?` (BCP-47-ish, ≤ 35 chars) | `{text, language?}`; `413` body too large, `400` bad field | + +The realtime-token, voice-assistant token, and WebSocket-proxy endpoints under `/api/voice/*` belong to the live voice assistant — out of scope for v1. + +### Usage & storage (owner-only) + +Source: `hub/src/web/routes/usage.ts`, `hub/src/web/routes/storage.ts`. Both `403` unless namespace is `default` ([Auth → Namespaces](./auth.md#namespaces)). + +| Method & path | Request | Response | +|---|---|---| +| `GET /api/usage/summary` | Query: `range=7d\|30d\|all` (default 7d), `timeZone` (IANA, validated) | `UsageSummaryResponse` `{range, totals, daily[], byAgent[], byModel[], updatedAt}` | +| `GET /api/storage/sqlite` | — | `{path, databaseBytes, walBytes, shmBytes, totalBytes}` | + +### Devices (FCM push) + +Source: `hub/src/web/routes/devices.ts`; full push contract in [`native-companion-contract.md`](../native-companion-contract.md). + +| Method & path | Request | Response | +|---|---|---| +| `POST /api/devices/register` | `{token, platform: 'phone'\|'wear', deviceId}` (deviceId: any stable 1–128-char install id) | `{ok: true}` (upsert) | +| `DELETE /api/devices/register` | `{token}` | `{ok: true}` | + +### Visibility + +Source: `hub/src/web/routes/events.ts` (`POST /visibility`). + +| Method & path | Request | Response | +|---|---|---| +| `POST /api/visibility` | `{subscriptionId, visibility: 'visible'\|'hidden'}` | `{ok: true}`; `404` when the SSE subscription is gone | + +`subscriptionId` comes from the SSE `connection-changed` event ([SSE](./sse.md)). Report foreground/background transitions so the hub can suppress redundant push notifications while the app is visibly connected. + +### Hub settings (read) + +Source: `hub/src/web/routes/hubSettings.ts`. + +| Method & path | Response | +|---|---| +| `GET /api/hub-settings` | `{sessionSummaryContract: boolean, sessionSummaryInChat: boolean}` — readable by any namespace | + +### SSE + +`GET /api/events` is the realtime channel — subscription params, resume handshake, and reconnect policy are specified in [SSE](./sse.md). Its gzip behavior differs from the JSON endpoints (streaming compression with per-event flush), also covered there. + +## Tier 2 — out of scope for v1 + +These exist on the hub but v1 native clients must not implement or call them: + +| Area | Paths | Why out of scope | +|------|-------|------------------| +| Codex Desktop import | `/api/codex/*` (`hub/src/web/routes/codexDesktop.ts`) | Desktop-import tooling | +| Pi session import | `/api/pi/*`, `/api/sessions/:id/pi-*` (`hub/src/web/routes/piSessions.ts`, `sessions.ts`) | Import tooling (the `pi-models` catalog above is the one exception) | +| Work graph | `/api/work-graph/*` (`hub/src/web/routes/workGraph.ts`) | Web-only feature | +| Web Push | `/api/push/*` (`hub/src/web/routes/push.ts`) | Browser Push API; natives use `/api/devices` (FCM) | +| Hub settings write | `PUT /api/hub-settings` | Owner-only hub administration | +| Telegram | `POST /api/bind` | Telegram Mini App binding only | +| Voice assistant | `/api/voice/token`, `/voices`, `/backend`, `/gemini-token`, `/qwen-token`, `/qwen-ws`, `/gemini-ws`, `/transcription/realtime-token`, `/telemetry`, credentials endpoints | Realtime assistant, not v1 dictation | +| Cursor maintenance | `/api/sessions/:id/migrate-to-acp`, `/cursor-chat-store` | Desktop store migration | +| Session export | `GET /api/sessions/:id/export` | Feeds the share/export feature, excluded from v1 | +| **CLI plane** | `/cli/*` (`hub/src/web/routes/cli.ts`) | **Forbidden for clients** — internal CLI↔hub surface; authenticates with the raw access token instead of a JWT and bypasses the client middleware. Never call it from a client, and never send the access token as a bearer anywhere except `POST /api/auth`'s JSON body | From 1b9ca34fb5c84c67529dabcd8aefabd6f3452240 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 11:18:14 +0800 Subject: [PATCH 003/168] docs(api): add native client contract (sse, pagination, messages) --- docs/api/client-contract/messages.md | 249 +++++++++++++++++++++++++ docs/api/client-contract/pagination.md | 191 +++++++++++++++++++ docs/api/client-contract/sse.md | 202 ++++++++++++++++++++ 3 files changed, 642 insertions(+) create mode 100644 docs/api/client-contract/messages.md create mode 100644 docs/api/client-contract/pagination.md create mode 100644 docs/api/client-contract/sse.md diff --git a/docs/api/client-contract/messages.md b/docs/api/client-contract/messages.md new file mode 100644 index 0000000000..eb2d41a801 --- /dev/null +++ b/docs/api/client-contract/messages.md @@ -0,0 +1,249 @@ +# Message decode tree + +**Audience:** Implementers of native HAPI clients (iOS / Android). This page specifies how to decode `DecryptedMessage.content` into renderable chat structure. This is the largest porting surface — the reference pipeline is `web/src/chat/` (~4600 lines); this page is its wire-level contract. Companion pages: [pagination](./pagination.md) (how messages arrive), [sse](./sse.md) (live delivery). + +Source of truth: `shared/src/schemas.ts` (`DecryptedMessageSchema`), `shared/src/messages.ts` (envelope helpers), `web/src/chat/normalize.ts`, `web/src/chat/normalizeUser.ts`, `web/src/chat/normalizeAgent.ts`, `web/src/chat/types.ts`, `hub/src/store/contentCodec.ts`. + +--- + +## Wire shape + +```ts +type DecryptedMessage = { + id: string // server uuid (optimistic rows: == localId until echoed) + seq: number | null // per-session insert counter + localId: string | null // client-generated id for optimistic reconciliation + content: unknown // the role-wrapped envelope — everything below + createdAt: number // hub receive time (epoch ms) + invokedAt?: number | null // when the agent consumed it (null = still queued) + scheduledAt?: number | null // future-scheduled sends +} +``` + +`content` is deliberately `unknown` on the wire. **Decoding must be total**: malformed content degrades to a stringified fallback — a client must never drop or crash on a message it does not recognize (with the two precise exceptions listed in [Fallback rules](#fallback-rules)). + +--- + +## Envelope + +`content` is a role-wrapped envelope: + +```ts +{ role: 'user' | 'agent', content: , meta?: unknown } +``` + +Unwrap algorithm (`unwrapRoleWrappedRecordEnvelope`, `shared/src/messages.ts`) — a record qualifies when it has a string `role` and a `content` key; if `content` itself is not one, also probe, in order: + +1. `content.message` +2. `content.data.message` +3. `content.payload.message` + +No envelope found ⇒ render the **whole** `content` as stringified agent text. `role` other than `user`/`agent` ⇒ stringify `record.content` as agent text. + +### `meta` + +Opaque record; carry it through. Known keys: + +| Key | Values | Meaning | +|---|---|---| +| `sentFrom` | `'webapp'`, `'telegram-bot'`, `'cli'`, … | Origin of a user message. **`'cli'` marks CLI-echo traffic**: user/assistant text containing ``, ``, ``, or `` tags renders as a monospace *cli-output* block instead of a chat bubble, and a `` block merges with its `` follow-up (`web/src/chat/reducerCliOutput.ts`). | +| `deliveryMode` | `'queue'` \| `'steer'` | Durable delivery intent of a user send (see [pagination](./pagination.md#send-constraints)). Absent = `queue`. | + +--- + +## Decode tree + +``` +DecryptedMessage.content +└─ unwrap envelope → { role, content: payload, meta? } + ├─ role: 'user' + │ ├─ payload is string → user text + │ ├─ payload {type:'text', text, attachments?} → user text (+ attachments) + │ └─ anything else → user text (stringified payload) + └─ role: 'agent' — dispatch on payload.type + ├─ 'codex' → generic agent family (payload.data.type dispatch) + ├─ 'output' → Claude SDK passthrough + agy (payload.data.type dispatch) + ├─ 'event' → AgentEvent union (payload.data) + └─ unknown → agent text (stringified payload) +``` + +--- + +## `role: 'user'` payloads + +Reference: `web/src/chat/normalizeUser.ts`. + +| Payload | Result | +|---|---| +| bare `string` | user text | +| `{type:'text', text: string, attachments?: AttachmentMetadata[]}` | user text with attachments. Each attachment is accepted only when `id`, `filename`, `mimeType` (strings), `size` (number), `path` (string) are all present; optional `previewUrl`. Invalid entries are skipped, an empty result means "no attachments". | +| anything else | user text = stringified payload (**never drop**) | + +--- + +## `role: 'agent'` — family `'codex'` + +`payload.type === 'codex'` (`AGENT_MESSAGE_PAYLOAD_TYPE`, `shared/src/modes.ts:8`) is the **generic agent envelope** used by the non-Claude-SDK flavors (codex, gemini, cursor, copilot, grok, opencode, pi, kimi; agy uses the `'output'` family below). Dispatch on `payload.data.type` (`web/src/chat/normalizeAgent.ts`): + +| `data.type` | Payload fields | Renders as | +|---|---|---| +| `message` | `message: string`, `id?` (stream id), `streamSnapshot?` | Agent text. If the text is a bare JSON object with review markers (`findings` / `overall_correctness` / `overall_explanation`), parse it as a **codex review** block instead — unless it is a Pi stream snapshot (`streamSnapshot: true` or `id` matching `/^pi-.+-turn-\d+-message-\d+-text-\d+$/`), which is always plain text. | +| `reasoning` | `message: string`, `id?` | Reasoning (thinking) block. | +| `error` | `message: string` | Error event row. | +| `tool-call` | `callId`, `name?`, `input?`, `description?`, `nativeTitle?/title?`, `nativeKind?/kind?`, `progress?` | Open a tool card keyed by `callId`. | +| `tool-call-result` | `callId`, `output`, `is_error?` | Complete the tool card with the same `callId`. | +| `generated-image` | `imageId`/`image_id`, `fileName`/`file_name`, `mimeType`/`mime_type`, `id?`, `source?` | Inline generated image (fetch via the images REST endpoint). Missing `imageId` ⇒ drop. | +| `context_compacted` | `trigger?`, `preTokens`/`pre_tokens` | `compact` event row. | +| `compact-summary` | `summary`, `tokensBefore?`, `estimatedTokensAfter?` | `compact-summary` event row. | +| `token_count` | `info: {last \| total \| …}`, `thread_id?`, `scope?` | Usage sample (event). Prefer `info.last*` over `info.total*`; `context_tokens` falls back to `input_tokens`; `modelContextWindow` → `context_window`. Unparseable usage ⇒ drop. | +| `thread_goal_updated` | `goal {threadId, objective, status, tokenBudget?, tokensUsed?, timeUsedSeconds?, createdAt?, updatedAt?}`, `threadId?`, `turnId?` | `thread-goal-updated` event. `status` ∈ `active\|paused\|budgetLimited\|usageLimited\|blocked\|complete`; invalid goal ⇒ drop. | +| `thread_goal_cleared` | `threadId?` | `thread-goal-cleared` event. | +| `plan` | `entries`/`items`/`steps`: list of steps | Synthetic completed `update_plan` tool pair (cursor flavor). Steps accept `step\|content\|text\|title\|description` + `status\|state` (normalized to `pending\|in_progress\|completed`). Empty plan ⇒ drop. | +| `plan_update` | `plan`/`update`/`items`/`steps` | Same, codex flavor. | +| `agent-run-start` / `agent-run-update` / `agent-run-trace` | run payload | Background agent-run event rows (windowed separately, see [pagination](./pagination.md#client-windowing-normative-recommendation)). | +| *anything else* | — | **Drop silently** (`normalize.ts`: unknown codex content returns `null`, not a stringified bubble). | + +Snake_case/camelCase field pairs above are both accepted — always probe both. + +--- + +## `role: 'agent'` — family `'output'` (Claude SDK passthrough) + +`payload.data` is a Claude Code SDK log entry, forwarded verbatim. Envelope-level fields on `data`: `uuid`, `parentUuid`, `isSidechain?`, `parentToolUseId?`, `timestamp?` (ISO-8601 execution-machine clock — parse to epoch ms, fall back to `createdAt`), and the flags below. + +**Skip filters — evaluate first** (`isSkippableAgentContent` / `isClaudeChatVisibleMessage`): + +- `data.isMeta` or `data.isCompactSummary` truthy ⇒ hidden. +- `data.type === 'rate_limit_event'` or `'tool_progress'` ⇒ hidden. +- `data.type === 'system'` with `subtype` **not** in `{api_error, turn_duration, microcompact_boundary, compact_boundary, away_summary}` ⇒ hidden. +- Empty `away_summary` / empty `agy_message` ⇒ hidden. + +Then dispatch on `data.type`: + +### `assistant` + +`data.message` = `{model?, content, usage?}`. `content` is a string (⇒ one text block) or an array of blocks: + +| Block | Fields | Renders as | +|---|---|---| +| `text` | `text` | agent text | +| `thinking` | `thinking` | reasoning | +| `tool_use` | `id`, `name`, `input` | tool card open (`description` convention: `input.description` when present) | + +Other block types are ignored. `message.usage` carries `input_tokens`, `output_tokens`, `cache_creation_input_tokens?`, `cache_read_input_tokens?`, `service_tier?`, `context_window?`. + +### `user` + +Despite the name, these arrive through the agent path (tool results and system-injected turns). `data.message.content` cases: + +| Case | Renders as | +|---|---| +| array with `tool_result` blocks `{tool_use_id, content, is_error?, permissions?}` | tool card completion. Prefer entry-level `data.toolUseResult` over the block's `content` when present. `permissions` = `{date, result:'approved'\|'denied', mode?, allowedTools?, decision?}` — merge into the tool card's permission state. | +| string content (any), or sidechain array-of-text | **sidechain marker** `{prompt}` — subagent prompts and system-injected turns; group under the parent Task tool card via `parentToolUseId` (fallback: exact prompt match). | +| non-sidechain array that is *entirely* `text` blocks | a real user message the CLI wrapped as output ⇒ render in the **user** lane. | +| `text` blocks mixed with tool results | agent text blocks. | + +### `system` subtypes → event rows + +| `subtype` | Fields | Event | +|---|---|---| +| `api_error` | `retryAttempt`, `maxRetries`, `error` | `api-error` | +| `turn_duration` | `durationMs`, `messageId?` | `turn-duration {durationMs, targetMessageId?}` | +| `microcompact_boundary` | `microcompactMetadata {trigger, preTokens, tokensSaved}` | `microcompact` | +| `compact_boundary` | `compactMetadata {trigger, preTokens}` | `compact` | +| `away_summary` | `content: string` | `recap {text}` | + +### `summary` + +`data.summary: string` ⇒ conversation-summary content block. + +### agy (Antigravity) — also in the `'output'` family + +| `data.type` | Renders as | +|---|---| +| `agy_message` | Agent text (`data.content`, per-turn `data.model?`). Empty ⇒ skip. Text starting `Inside the task-NNN log` ⇒ compact `AgyTaskLog` chip (synthetic completed tool pair). Echoed raw task results (`[Message] timestamp=…` trailer) are stripped from the prose. | +| `agy_tool_action` | Synthetic **completed** tool pair (tool-call + tool-result, same id — prefer `data.toolUseId`, falling back to the message id). `name === 'SYSTEM_MESSAGE'` ⇒ `AgyAsyncTask` background-task card; `name === 'ERROR_MESSAGE'` ⇒ `AgyError` card (`is_error: true`); otherwise map agy tool names to canonical ones (`run_command`→Bash, `view_file`→Read, `write_to_file`→Write, `replace_file_content`→Edit, `grep_search`→Grep, `list_dir`→LS) and translate arg keys (`CommandLine`→`command`, `TargetFile`→`file_path`, …), stripping agy's result preambles/trailers. See `normalizeAgent.ts` for the exact strip rules. | + +### Unknown `'output'` types + +A visible `data.type` not matched above ⇒ stringified agent text fallback (unlike the codex family, which drops). + +--- + +## `role: 'agent'` — family `'event'` + +`payload.data` is one `AgentEvent` (`web/src/chat/types.ts` lines 17–36). The union is **open-ended** — the last member is `{type: string} & Record`; tolerate unknown types (render generically or ignore, never crash). + +| `type` | Fields | +|---|---| +| `switch` | `mode: 'local' \| 'remote'` | +| `message` | `message: string` | +| `error` | `message: string` | +| `title-changed` | `title: string` | +| `limit-reached` | `endsAt: number`, `limitType: string` | +| `limit-warning` | `utilization: number` (0–1), `endsAt`, `limitType` | +| `ready` | — | +| `api-error` | `retryAttempt`, `maxRetries`, `error: unknown` | +| `turn-duration` | `durationMs`, `targetMessageId?` | +| `microcompact` | `trigger`, `preTokens`, `tokensSaved` | +| `compact` | `trigger`, `preTokens` | +| `compact-summary` | `summary`, `tokensBefore?`, `estimatedTokensAfter?` | +| `recap` | `text` | +| `thread-goal-updated` | `goal: ThreadGoal`, `threadId?`, `turnId?` | +| `thread-goal-cleared` | `threadId?` | +| `abort-restore` | `text` | +| *(catch-all)* | `{type: string, …}` | + +Several event rows are also synthesized by the other two families (system subtypes, `context_compacted`, `token-count`, `agent-run-*`) — the renderer should treat them uniformly. Note: `event`-family `message` rows whose text is a `Goal …` status line are filtered by the **hub** at ingest and from REST pages (`isRedundantGoalStatusEventContent`, `shared/src/messages.ts`); clients need no special handling. + +--- + +## Fallback rules + +| Situation | Behavior | +|---|---| +| No unwrappable envelope | stringify whole `content` as agent text | +| `role` not `user`/`agent` | stringify `record.content` as agent text | +| user payload unrecognized | stringify as user text | +| `'codex'` family, unknown `data.type` | **drop** (return nothing) | +| `'output'` family, hidden by skip filters | **drop** | +| `'output'` family, visible but unknown `data.type` | stringify as agent text | +| `'event'` family, `data` lacks a string `type` | stringify as agent text | + +"Stringify" = a stable JSON serialization (web: `safeStringify`) rendered as plain text. These are the only two legitimate drop paths; everything else must render something. + +--- + +## Truncation marker + +At ingest the hub head+tail-truncates any **string longer than 64 KiB found anywhere inside agent-role content** (`hub/src/store/contentCodec.ts`): the stored value becomes first 48 KiB + `\n…[hapi: truncated N chars]…\n` + last 12 KiB. User-role content is never truncated (it is delivered verbatim to the CLI). The operation is idempotent and applied deep (arrays/objects). + +Clients must render truncated strings as-is (recognizing the `…[hapi: truncated N chars]…` marker is optional polish), must not assume tool results are complete, and must never choke on the marker. + +--- + +## Permission requests are NOT messages + +Pending tool approvals never appear in the message stream. They live on the session object (`shared/src/schemas.ts:167-203`): + +```ts +session.agentState = { + requests?: Record + completedRequests?: Record // flat (AskUserQuestion) + | Record // nested (request_user_input) + }> +} +``` + +`agentState` updates arrive as a versioned SSE patch — apply it under the version gate described in [sse.md](./sse.md#versioned-patch-algorithm). Render pending `requests` as approval cards interleaved with the chat (the web reducer keys them to the matching `tool_use` when one exists); on resolution the entry moves to `completedRequests`, whose `status`/`answers` back-fill the tool card's permission state. Decide via `POST /api/sessions/:id/permissions/:requestId/approve` (`{mode?, allowTools?, decision?, answers?}`) or `…/deny` (`{decision?}`) — see [rest.md](./rest.md). Session-list badges come precomputed on `SessionSummary.pendingRequestsCount` / `pendingRequests` (≤ 5 entries). + +--- + +## Golden fixtures + +The golden fixtures in `shared/fixtures/chat/` are the executable form of this section: input `DecryptedMessage` samples paired with the canonical decoded projection, generated from the web pipeline. A native decode implementation is correct when it reproduces them exactly — when the fixtures and this page disagree, the fixtures win. diff --git a/docs/api/client-contract/pagination.md b/docs/api/client-contract/pagination.md new file mode 100644 index 0000000000..52f7bae236 --- /dev/null +++ b/docs/api/client-contract/pagination.md @@ -0,0 +1,191 @@ +# Message pagination, windowing, and optimistic sends + +**Audience:** Implementers of native HAPI clients (iOS / Android). This page specifies the message paging protocol (`GET /api/sessions/:id/messages`), the epoch reset contract, the tail-sync loop, recommended client windowing, and the optimistic-send / cancel lifecycle. Companion pages: [sse](./sse.md), [messages](./messages.md), [rest](./rest.md). + +Source of truth: `shared/src/apiTypes.ts` (`MessagesQuerySchema`, `MessagesResponse`, `SendMessageRequestSchema`), `hub/src/web/routes/messages.ts`, `hub/src/sync/messageService.ts`, `hub/src/store/messages.ts`, reference client `web/src/lib/message-window-store.ts` + `web/src/lib/messages.ts`. + +--- + +## Position key + +Messages are ordered by a **compound position**, not by `seq` alone: + +``` +position = (at, seq) where at = invokedAt ?? createdAt +``` + +Ascending by `at`, ties broken by `seq`. Rationale: a queued user message sits at its `createdAt` until the agent consumes it, at which point `invokedAt` is stamped and the row **moves forward** to its invocation position. `seq` (per-session insert counter) alone would freeze queued rows at enqueue order. Every cursor in this protocol is therefore a `(seq, at)` **pair** — both halves are always required together. + +--- + +## `GET /api/sessions/:id/messages` + +Query parameters (`MessagesQuerySchema`; all numbers coerced from strings): + +| Param | Type | Constraint | +|---|---|---| +| `limit` | int | 1–200. **Default 50** when omitted (the web reference always sends 200). | +| `beforeSeq` + `beforeAt` | int + int | Page strictly older than this position. Pairwise required. | +| `afterSeq` + `afterAt` | int + int | Page strictly newer than this position. Pairwise required. | +| `untilSeq` + `untilAt` | int + int | Inclusive snapshot head for a catch-up loop. Pairwise required; **requires an `after` cursor**. | +| `epoch` | int ≥ 0 | Client's cached epoch. **Requires an `after` cursor.** | + +Validation rules (violations are `400 {"error":"Invalid query","issues":…}`): + +- `beforeAt`⇄`beforeSeq`, `afterAt`⇄`afterSeq`, `untilAt`⇄`untilSeq` must each be provided together. +- `before` and `after` are **mutually exclusive**. +- `until` and `epoch` are only valid alongside `after`. + +Session errors: `404` not found, `403` foreign namespace (see [errors](./errors.md)). + +### Response shape + +```ts +type MessagesResponse = { + messages: DecryptedMessage[] // ascending display order + page: { + direction: 'latest' | 'before' | 'after' + limit: number + epoch: number // server's current epoch for this session + reset: boolean // true ⇒ discard your window, this page replaces it + nextBeforeSeq: number | null // cursor for the next OLDER page + nextBeforeAt: number | null + nextAfterSeq: number | null // cursor for the next NEWER page + nextAfterAt: number | null + snapshotHeadSeq: number | null // newest position at snapshot time + snapshotHeadAt: number | null + hasMore: boolean // more rows exist in the requested direction + } +} +``` + +### `latest` (no cursor) + +Newest `limit` rows by position, **plus** — out of band — every uninvoked local user message (queued rows, including future-scheduled ones), so a fresh client still sees the queued bar even when those rows fall outside the page. The out-of-band rows are pinned to every latest response and do **not** affect the cursor: `nextBefore*` anchors to the oldest row of the position-ordered page proper. `hasMore` = at least one row exists before that. If a page contains only server-side-filtered rows (see [messages](./messages.md)), the hub auto-advances to older pages until it can return something or history is exhausted. + +### `before` + +Rows strictly older than the cursor. `nextBefore*` = oldest row of this page; `hasMore` = at least one row older than that. The response also carries the current `epoch` — compare it to your cached one (see below). + +### `after` + +Rows strictly newer than the cursor, bounded by an **inclusive snapshot head** = `min(until, currentHead)` (or whichever exists). This keeps a catch-up loop from chasing messages appended while it runs. Responses: + +- Client `epoch` ≠ server epoch ⇒ the server ignores the cursor and returns the **latest page with `reset: true`** (`direction: 'latest'`). +- Snapshot head ≤ cursor ⇒ empty page, `hasMore: false`, `nextAfter*` echoes the cursor. +- Otherwise: `nextAfter*` = last row's position, `hasMore` = `nextAfter < snapshotHead`. + +--- + +## Epoch + +`epoch` is a per-session monotonic counter (`message_epochs` table, starts at 0) that is bumped whenever history changes in a way that invalidates composite cursors (`hub/src/store/messages.ts`): + +- a new row lands **before** the current head position (out-of-order insert, e.g. transcript import with an earlier timestamp); +- a queued message is deleted (cancel); +- rewind / history replace (`replaceSessionMessagesFrom`); +- messages are copied/merged between sessions (both sides), fork hydration. + +Client contract: + +- **`after` request** — always send your cached `epoch`. On mismatch the server answers with the latest page and `reset: true`; **discard the entire local window** and replace it with that page. +- **`before` request** — the response's `page.epoch` may differ from your cached one; if it does, your cursors are meaningless: drop cursor state, flag the window for a latest reset, and run a fresh tail sync (web: `fetchOlderMessages` → `epoch-reset` outcome). +- A structural change is also announced live via the `messages-invalidated` SSE event — on the open session, clear the window and tail-sync from scratch. + +--- + +## Tail-sync loop + +Run after connect, after an SSE `resume: 'gap'` handshake, on session open, and when told to (`messages-invalidated`). Reference: `runTailSync` in `web/src/lib/message-window-store.ts`. + +1. **No usable state** (no newest cursor, no cached epoch, or a reset is pending): `GET …/messages?limit=200` (latest), replace/merge into the window, store `page.epoch`, `nextBefore*` (older-page cursor) and `snapshotHead*` (newest cursor). Done. +2. **Have cursor + epoch**: loop + - `GET …/messages?afterSeq&afterAt&epoch[&untilSeq&untilAt]&limit=200`, where `after` starts at your newest cursor and `until` is the `snapshotHead*` captured from the **first** response of the loop (fixes the target so the loop terminates). + - `page.reset` or `direction: 'latest'` ⇒ replace the window with this page; stop. + - Otherwise merge the rows, advance `after = nextAfter*`, update the newest cursor to `max(current, nextAfter)`; stop when `hasMore` is false. + - Guard: if `nextAfter` did not advance past the previous cursor, abort with an error (protocol violation, do not spin). + +New live rows keep arriving via the SSE `message-received` event; ingest them and advance the newest cursor to `max(current, incoming position)`. Only run one tail sync at a time per session; if events force another (e.g. a reset was flagged mid-loop), queue a trailing run. + +--- + +## Client windowing (normative recommendation) + +Constants from the web reference (`web/src/lib/message-window-store.ts`): + +| Constant | Value | Meaning | +|---|---|---| +| `PAGE_SIZE` | 200 | Request size for every page fetch. | +| `VISIBLE_WINDOW_SIZE` | 400 | Max regular rows kept in **tail** mode (following live bottom). | +| `HISTORY_WINDOW_SIZE` | 600 | Max regular rows kept in **history** mode (user scrolled back). | +| `OLDER_LOAD_WINDOW_SIZE` | 800 | Temporary cap while an older page is being merged (prepend). | +| `AGENT_RUN_WINDOW_SIZE` | 800 | Separate trim bucket for codex `agent-run-*` rows so background-agent traces don't evict chat. | + +Rules: + +- **Tail mode** trims from the top (oldest dropped). Dropping rows ⇒ set `hasMore: true` and recompute the older-page cursor from the oldest kept row. +- **History mode** trims from the bottom (newest dropped). Dropping newest rows means your window no longer reaches the tail ⇒ flag "latest reset required": on returning to tail mode, discard cursors and fetch a fresh latest page rather than trusting stale ones. +- **Queued rows are never trimmed** (user messages with `invokedAt === null`, see below) — they are re-merged after every trim. +- Persist the window (messages + cursors + epoch) per session for instant cold-start rendering; on re-activation with a persisted cursor, still fetch a fresh latest page first (another client may have advanced the session by many pages) and reconcile. + +--- + +## Optimistic sends + +Send: `POST /api/sessions/:id/messages` (see constraints below). Reference: `web/src/hooks/mutations/useSendMessage.ts`, `mergeMessages` in `web/src/lib/messages.ts`. + +Lifecycle: + +1. Generate a client-side `localId` and append an **optimistic row**: `{id: localId, seq: null, localId, invokedAt: null, scheduledAt, createdAt: now, status:'sending', content: {role:'user', content:{type:'text', text, attachments?}, meta:{deliveryMode}}}`. A row is *optimistic* iff `id === localId`. +2. On POST success: status → `queued` if the session is currently thinking, else `sent`. On failure: drop the row and restore the composer (or keep it as `failed` with a retry affordance when attachments are involved). +3. **Echo**: the hub emits `message-received` carrying the stored row (server `id`, real `seq`, same `localId`). Merging a stored row whose `localId` matches an optimistic row **replaces** the optimistic one, preserving the client-side `status` and any already-known `invokedAt` the server row lacks. Fallback when no `localId` echo matches: drop an optimistic `sent` row when a server user message lands within **10 s** of the same position. +4. **`messages-consumed {localIds, invokedAt}`** (SSE): stamp `invokedAt` and flip status to `sent` on matching rows (skip `failed` ones). This is what moves a message out of the queued bar and into the thread at its invocation position. +5. **`message-cancelled {messageId, localId?}`** (SSE): remove the row (match either id). + +**Queued semantics**: a user message is "queued" iff `invokedAt === null` **strictly** and `status !== 'failed'`. `undefined` means already-invoked (rows from pre-V8 hubs omit the field) — only rows explicitly carrying `null` belong in the queued bar. Server-side, rows sent without a `localId` are stamped invoked at insert and can never be queued. + +### Queued-state recovery + +After a reconnect whose handshake said `resume: 'gap'` (an `ok` resume replayed the consume/cancel events already), the consumed/cancelled events for your queued rows may have been lost. Reference: `web/src/lib/queued-state-reconciliation.ts`. + +1. Finish a tail sync. +2. Collect candidate `localId`s: user rows with `invokedAt === null`, excluding optimistic rows still `sending`/`failed`. +3. `POST /api/sessions/:id/messages/queued-state` with `{"localIds": […]}` (max 1000 per call; batch above that) → `{queuedLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]}`. +4. Apply `invokedLocalMessages` exactly like `messages-consumed`; drop candidates that are in **neither** list (deleted server-side). + +--- + +## Send constraints + +`POST /api/sessions/:id/messages` body (`SendMessageRequestSchema`): + +| Field | Type | Rules | +|---|---|---| +| `text` | string | Required (route also accepts empty text when `attachments` is non-empty). | +| `localId` | string | Optional but **required for `scheduledAt`**, and required in practice: without it the row is stamped invoked at insert (no queue/ack/cancel path). | +| `attachments` | `AttachmentMetadata[]` | Optional. Not allowed with `scheduledAt`. | +| `scheduledAt` | epoch ms | Optional. Must be ≤ now + **7 days**; requires `localId`; no attachments; never `steer`. | +| `deliveryMode` | `'queue'` \| `'steer'` | Optional, default `queue`. `steer` is honored only for **Pi-flavor** sessions and never for scheduled sends — the hub silently normalizes everything else to `queue`, and deferred/replayed delivery (reconnect backfill, retries, scheduled release) always degrades `steer` to `queue`. | + +Response `{"ok": true}`. Sending to an inactive session returns `409 {"error":"Session is inactive","code":"session_inactive"}` — resume/reopen first, and note the resumed session **may have a different id** (migrate drafts and re-target, see [rest](./rest.md)). + +--- + +## Cancel and steer + +**Cancel**: `DELETE /api/sessions/:id/messages/:messageId` — `:messageId` may be the server id **or** the `localId`. Response union (`CancelMessageResponseSchema`): + +| Response | Meaning | Client action | +|---|---|---| +| `{"status":"cancelled","localId":string\|null}` | Row deleted (or already gone). Bumps the epoch. | Remove the row. | +| `{"status":"invoked","message":DecryptedMessage}` | Too late — the agent consumed it before the cancel landed. | **Ingest the returned message** as the authoritative row (correct `invokedAt`, status `sent`); do not resurrect the queued snapshot. | + +Other subscribers learn the same outcome via `message-cancelled` / `messages-consumed` SSE events. + +**Steer a queued message into the current turn**: `POST /api/sessions/:id/messages/:messageId/steer` (Pi sessions) → `SteerQueuedMessageResponseSchema`: + +| Response | Client action | +|---|---| +| `{"status":"steered","localId"}` | Keep the row queued-side; it is being injected into the live turn. | +| `{"status":"invoked","message"}` | Already consumed — ingest the message. | +| `{"status":"failed","error","localId":string\|null}` | Surface the error; the row remains queued. | diff --git a/docs/api/client-contract/sse.md b/docs/api/client-contract/sse.md new file mode 100644 index 0000000000..e6ae787263 --- /dev/null +++ b/docs/api/client-contract/sse.md @@ -0,0 +1,202 @@ +# SSE stream (`GET /api/events`) + +**Audience:** Implementers of native HAPI clients (iOS / Android). This page specifies the hub's server-sent-events stream: subscription model, framing, resume protocol, the `SyncEvent` union, and the versioned session-patch algorithm. Companion pages: [auth](./auth.md), [REST](./rest.md), [pagination](./pagination.md), [messages](./messages.md). + +Source of truth: `hub/src/web/routes/events.ts`, `hub/src/sse/sseManager.ts`, `hub/src/web/sseCompression.ts`, `shared/src/schemas.ts` (`SyncEventSchema`), reference client `web/src/hooks/useSSE.ts`. + +--- + +## Endpoint + +`GET /api/events` — long-lived `text/event-stream` response. + +| Query param | Values | Notes | +|---|---|---| +| `token` | JWT | Browser `EventSource` cannot set headers, so the auth middleware accepts `?token=` **on this path only** (`hub/src/web/middleware/auth.ts`). Clients that can set headers may use `Authorization: Bearer` instead. | +| `all` | `true` \| `1` | Global subscription: every event in the token's namespace. | +| `sessionId` | session id | Session-scoped subscription. | +| `machineId` | machine id | Machine-scoped subscription (web does not use this). | +| `visibility` | `visible` \| `hidden` | Initial visibility state. Anything other than the literal `visible` is treated as `hidden` (the default). See [Visibility](#visibility). | +| `lastEventId` | event id | Resume cursor for manually rebuilt connections. The standard `Last-Event-ID` **request header wins** over this param when both are present (auto-reconnecting EventSource implementations send the header). | + +Up-front checks, before any bytes stream: + +| Condition | Response | +|---|---| +| Hub sync engine not ready | `503 {"error":"Not connected"}` | +| `sessionId` unknown | `404 {"error":"Session not found"}` | +| `sessionId` in another namespace | `403 {"error":"Session access denied"}` | +| `machineId` unknown / foreign namespace | `404` / `403` (same pattern) | + +A `sessionId` may resolve to a canonical id (superseded/merged sessions); the subscription binds to the **resolved** id. + +--- + +## Framing + +Every frame is a standard SSE message whose `data:` line is one JSON-encoded `SyncEvent`: + +``` +id: 018f3c2a:412:9b1f00aa +data: {"type":"session-updated","sessionId":"...","data":{...}} +``` + +- **Broadcast events** (replayed and live) carry an `id:` field. +- **`connection-changed`, `heartbeat`, and `toast` frames carry NO `id`.** SSE cursors are sticky: a frame without `id` must keep the previously seen id (native `EventSource` does this automatically; hand-rolled parsers must replicate it). A heartbeat must never reset or blank your cursor. +- The server never sends `retry:`; reconnect policy is entirely client-owned (see [Reconnect policy](#reconnect-policy-normative-recommendation)). + +### Event id format + +`{epoch}:{seq}:{nsTag}` — treat as opaque; store and echo it back, never interpret it. + +| Part | Meaning | +|---|---| +| `epoch` | 8-char random string, fixed per hub **process**. A cursor from before a hub restart can never match. | +| `seq` | Integer, monotonically increasing per hub process (shared across all namespaces/sessions). | +| `nsTag` | First 8 hex chars of `sha256("{epoch}|{namespace}")` — binds the cursor to the namespace it was issued under. | + +### Replay ring + +The hub keeps the last **256** broadcast events, capped at **2 MiB** of JSON (oldest evicted first; the byte cap always keeps at least one entry). `toast` frames are not recorded (they are visibility-targeted, not broadcast). + +--- + +## Handshake and resume + +On subscribe the hub emits, **in this guaranteed order**: + +1. `connection-changed` — `{"type":"connection-changed","data":{"status":"connected","subscriptionId":"","resume":"ok"|"gap"}}` (no `id`). +2. Replayed events (each with its `id`), when `resume` is `ok`. +3. Live traffic. + +Live broadcasts that occur while the replay is being written are queued server-side and flushed after it, so ordering is preserved. + +| `resume` verdict | Meaning | Client action | +|---|---|---| +| `ok` | The replay that follows contains **every** event missed since the cursor. | Skip the REST resync entirely. | +| `gap` (or field absent — older hubs) | The hub cannot prove continuity. | Full refetch: session list, session detail(s), message tail sync, and queued-state reconcile for the open session (see [pagination](./pagination.md#queued-state-recovery)). | + +`gap` is returned whenever: no cursor was sent, the cursor is malformed, `epoch` differs (hub restarted), `nsTag` differs (cursor issued under a different namespace — e.g. after a token swap on the same hub), `seq` is out of range, or events between the cursor and the ring's oldest entry were evicted. + +### Cursor rules (normative) + +- Keep one cursor **per subscription filter set** (the `all` / `sessionId` / `machineId` tuple, plus hub + namespace). Never replay a cursor recorded under a different filter set — the hub would replay against the wrong filter and the `ok` verdict would be wrong for what you actually missed. +- Update the cursor **after** the event is durably handled. If handling throws, leave the cursor behind the event so the hub redelivers it (at-least-once delivery; handlers must be idempotent). +- Send the cursor on reconnect via `Last-Event-ID` header or `?lastEventId`. + +--- + +## Reconnect policy (normative recommendation) + +These constants come from the web reference client (`web/src/hooks/useSSE.ts`) and the hub (`hub/src/sse/sseManager.ts`); native clients should adopt them. + +| Constant | Value | Notes | +|---|---|---| +| Server heartbeat interval | 30 s | `{"type":"heartbeat","namespace":"…","data":{"timestamp":}}` | +| Staleness threshold | 90 s | No frames (of any kind) for 90 s ⇒ tear down and reconnect. | +| Watchdog tick | 10 s | Staleness check interval; **skip checks while backgrounded**. | +| Foreground-resume staleness check | 45 s | On app-foreground, if the last frame is older than 45 s, reconnect immediately (an OS suspend can kill the socket without any error ever surfacing; one missed heartbeat interval is already enough to distrust it). | +| Connect timeout | 10 s | An attempt that has not reached OPEN in 10 s is likely hung on a dead pooled socket — abandon it and retry on a fresh connection. | +| Backoff | 1 s base, ×2, cap 30 s | Delay for attempt *n* (n ≥ 1) = `min(cap, 1000 · 2^(n-1))`. **First retry is immediate** (jitter only). | +| Jitter | +0–500 ms | Uniform, added to every delay. | +| Slow ceiling | 300 s after 8 attempts | A hub that stays unreachable is usually down for hours; each retry through a relay costs a TLS handshake. | +| Backgrounded | defer retries | Do not schedule retries while backgrounded; reconnect immediately on foreground. Reset the attempt counter to 0 on every successful open. | + +Any received frame — heartbeat included — counts as activity for the staleness clock. Do not rely on a platform SSE library's built-in auto-reconnect: it will not honor the backoff, the background deferral, or the connect timeout. + +--- + +## Dual-subscription model + +The reference client holds **two** concurrent connections (`web/src/App.tsx`, `web/src/lib/appSseSubscriptions.ts`): + +1. **Global** — `all=true`, alive for the whole app session. Drives the session list, machine list, badges, toasts. +2. **Session** — `sessionId=`, recreated on every session switch. Drives the open chat. + +Hub-side delivery (`SSEManager.shouldSend`): + +| Event type | Delivered to | +|---|---| +| `connection-changed` | the connection itself | +| `heartbeat` | every connection | +| `toast` | every **visible** connection in the namespace, regardless of filter (no `id`, never replayed) | +| `message-received`, `scheduled-matured` | `all=true` connections + matching `sessionId` connections | +| `session-added` / `session-updated` / `session-removed` / `session-ended` / `messages-invalidated` / `messages-consumed` / `message-cancelled` | `all=true` connections + matching `sessionId` connections | +| `machine-updated` | `all=true` connections + matching `machineId` connections | + +**The global connection must also handle the message-stream events** (`message-received`, `messages-consumed`, `message-cancelled`, `scheduled-matured`): while a session connection is down (reconnect gap) or the session isn't open, the global pipe is the only one alive, and it must still keep queued/optimistic bookkeeping correct — mark local messages consumed, remove cancelled rows, and refresh session-list scheduled counts. The session-scoped connection additionally ingests `message-received` into the message window. + +The two connections have **no ordering relationship with each other** — the same `session-updated` patch can arrive on both, in either order. That is why the versioned-patch gate below exists. + +--- + +## SyncEvent union (13 types) + +Schema: `SyncEventSchema` in `shared/src/schemas.ts` (discriminated on `type`). All events except `connection-changed` carry `namespace?: string`. Ignore unknown event types. + +| `type` | Payload (beyond `type`, `namespace?`) | Client handling | +|---|---|---| +| `session-added` | `sessionId`, `data?: unknown` | Handle exactly like `session-updated` (the reference client shares the branch): a full `Session` upserts; anything else falls back to refetching the session list. | +| `session-updated` | `sessionId`, `data?: Session \| SessionPatch` | See [Versioned patch algorithm](#versioned-patch-algorithm). | +| `session-removed` | `sessionId` | Drop the session from the list, drop its detail cache, clear its message window. | +| `message-received` | `sessionId`, `message: DecryptedMessage` | Ingest into the message window; advance the tail cursor (see [pagination](./pagination.md)). Also fired for the caller's own send (the localId echo). | +| `messages-invalidated` | `sessionId` | Message history changed **structurally** (rewind, fork, import, clear). Session scope: discard the whole window and run a fresh tail sync. Global scope: refetch the session list. | +| `scheduled-matured` | `sessionId` | A scheduled message became due and was handed to the agent. Refetch list/queue indicators. | +| `session-ended` | `sessionId`, `reason?: 'completed'\|'terminated'\|'error'\|'handoff'\|'cleared'` | Session lifecycle signal (the `session-updated` flow still carries the state change). | +| `machine-updated` | `machineId`, `data?: Machine \| MachinePatch \| null` | Full `Machine`: upsert (remove when `active:false`). `null`: machine removed. Patch `{active?, activeAt?, updatedAt?}`: `active:false` ⇒ remove, otherwise refetch machines. `data` absent ⇒ refetch. | +| `toast` | `data: {title, body, sessionId, url}` | Show as in-app toast/banner. Only delivered to visible connections (see [Visibility](#visibility)). | +| `messages-consumed` | `sessionId`, `localIds: string[]`, `invokedAt: number` | The agent consumed queued user messages: stamp `invokedAt`, flip status to `sent`, remove from the queued bar. | +| `message-cancelled` | `sessionId`, `messageId`, `localId?` | A queued message was cancelled: remove the row (match by `messageId` **or** `localId`). | +| `heartbeat` | `data?: {timestamp}` | Feed the staleness watchdog. No other action. **Carries no `id`.** | +| `connection-changed` | `data?: {status, subscriptionId?, resume?: 'ok'\|'gap'}` | Handshake; see [Handshake and resume](#handshake-and-resume). Store `subscriptionId` for visibility reporting. **Carries no `id`.** | + +--- + +## Versioned patch algorithm + +The most bug-prone part of the protocol. `session-updated.data` is either a **full `Session`** or a **`SessionPatch`** (`shared/src/schemas.ts`); reference implementation `applySessionDetailPatch` in `web/src/hooks/useSSE.ts`. + +1. **Full session** (validates against `SessionSchema` and `data.id === event.sessionId`): replace the cached session wholesale. +2. **Patch** (validates against the strict `SessionPatchSchema` — unknown keys make it fail — and is non-empty): apply field-by-field as below. +3. **Absent or unparseable `data`**: fall back to refetching the session detail and list over REST. + +Patch application, field by field: + +- **Flat fields** — `active`, `thinking`, `activeTurnStartedAt`, `activeAt`, `model`, `modelReasoningEffort`, `effort`, `serviceTier`, `permissionMode`, `collaborationMode`, `copilotAgentMode`, `backgroundTaskCount`: last-write-wins assignment when present. +- **`updatedAt`** — max-monotonic: `updatedAt = max(cached.updatedAt, patch.updatedAt)`. A stale replay must never move the clock backward. +- **Versioned sub-patches** — `metadata`, `agentState`, `todos`, `teamState` each arrive as a wrapper `{version: number, value: …}`. Apply `value` and store `version` **only when `version` is strictly greater than the cached watermark**: + + | Wrapper | Cached watermark on `Session` | `value` type | + |---|---|---| + | `metadata` | `metadataVersion` | `Metadata \| null` | + | `agentState` | `agentStateVersion` | `AgentState \| null` | + | `todos` | `todosUpdatedAt` (treat absent as 0) | `TodoItem[]` | + | `teamState` | `teamStateUpdatedAt` (treat absent as 0) | `TeamState \| null` — `null` means "team deleted": clear it | + + Strictly greater, because the two SSE connections have **no shared ordering** — the same version can arrive twice and an older version can arrive after a newer one. Applying a stale `agentState` would resurrect resolved permission requests; a stale `metadata` would regress the resume/session-id state. (The web session-*list* path tolerates `>=` because re-deriving its summary from an equal version is idempotent; for a single-cache native client, strict `>` is the rule.) +- **Never wholesale-spread the wrapper.** `session.metadata` must become `wrapper.value` — assigning `{version, value}` itself into the session is a classic porting bug. +- **`scratchlistUpdatedAt`** — a bare refetch trigger: the patch carries no entries; its presence means "refetch `GET /api/sessions/:id/scratchlist`". Nothing else to apply. + +### Keep-alive noise + +The CLI keep-alive makes the hub re-broadcast a patch roughly **every 10 s per active session**, in which typically only `activeAt` moves. Recommendation (web: `isRenderIrrelevantSessionPatch`): treat a patch as render-irrelevant when the only effective change is an `activeAt` delta **< 60 s** (relative-time labels only change at minute boundaries); the session-list path ignores `activeAt` entirely. Apply the data if you like, but do not re-render or re-sort six times a minute for it. + +Reference list sort (web): `globalPinned` > `pinned` > `active` > `pendingRequestsCount` (among active) > `updatedAt` desc. + +--- + +## Visibility + +`POST /api/visibility` with body `{"subscriptionId": "", "visibility": "visible" | "hidden"}` → `{"ok": true}`. Errors: `400` invalid body, `404` unknown `subscriptionId` (or namespace mismatch), `503` hub not ready. Each new connection has a **new** `subscriptionId` — re-report after every reconnect (the web reference reports both of its connections on every foreground/background transition and retries a failed report after 2 s). + +Semantics (`hub/src/visibility/visibilityTracker.ts`, `hub/src/push/pushNotificationChannel.ts`): when **any** connection in the namespace is visible, the hub delivers notification events (ready / permission request / task result) as in-app **`toast` SSE frames to the visible connections** and suppresses Web Push for the namespace; Web Push fires only when no visible connection exists (or toast delivery reached zero connections). Native FCM devices (`POST /api/devices/register`) are independent of visibility and fire unconditionally — see [native-companion-contract](../native-companion-contract.md). + +Native rule: report `visible` on foreground and `hidden` on background, every time. A native client that stays `visible` while backgrounded suppresses its own (and every PWA's) hub-side push for the namespace, and receives its notifications only as toast frames nobody is looking at. + +--- + +## Gzip + +SSE responses are gzip-compressed when `Accept-Encoding` allows it (`hub/src/web/sseCompression.ts`): the hub drives zlib directly and issues a **sync flush after every chunk**, so events arrive immediately despite compression (~75 % ratio on real traffic). Negotiation is q-value-aware (`gzip;q=0` refuses, `*` honored); the response carries `Content-Encoding: gzip` with no `Content-Length`. + +Native clients must verify that their HTTP stack **decompresses the stream incrementally** (frames visible per flush, not buffered until EOF). If it does not — or if it only auto-decompresses when it injected `Accept-Encoding` itself — send `Accept-Encoding: identity` and take the uncompressed stream. From e0e7be39c384b723f83d5f5cd7c1754befcc48e6 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 11:18:50 +0800 Subject: [PATCH 004/168] feat(ios): scaffold SwiftUI app + HapiKit package + CI (A-M0) --- .github/workflows/ios.yml | 40 +++ .gitignore | 7 + ios/Hapi.xcodeproj/project.pbxproj | 316 ++++++++++++++++++ .../xcshareddata/xcschemes/Hapi.xcscheme | 78 +++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + ios/Hapi/Assets.xcassets/Contents.json | 6 + ios/Hapi/HapiApp.swift | 19 ++ ios/Hapi/Info.plist | 19 ++ ios/Hapi/RootView.swift | 26 ++ ios/Packages/HapiKit/Package.swift | 26 ++ .../HapiClient/HapiClientVersion.swift | 15 + .../Sources/HapiProtocol/JSONValue.swift | 106 ++++++ .../HapiProtocol/Models/Placeholder.swift | 11 + .../Tests/HapiClientTests/SmokeTests.swift | 10 + .../HapiProtocolTests/JSONValueTests.swift | 122 +++++++ ios/README.md | 81 +++++ 17 files changed, 906 insertions(+) create mode 100644 .github/workflows/ios.yml create mode 100644 ios/Hapi.xcodeproj/project.pbxproj create mode 100644 ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme create mode 100644 ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Hapi/Assets.xcassets/Contents.json create mode 100644 ios/Hapi/HapiApp.swift create mode 100644 ios/Hapi/Info.plist create mode 100644 ios/Hapi/RootView.swift create mode 100644 ios/Packages/HapiKit/Package.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift create mode 100644 ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift create mode 100644 ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift create mode 100644 ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift create mode 100644 ios/README.md diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000000..9e26415ae3 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,40 @@ +name: ios + +on: + push: + branches: + - main + paths: + - 'ios/**' + - 'shared/fixtures/**' + - '.github/workflows/ios.yml' + pull_request: + paths: + - 'ios/**' + - 'shared/fixtures/**' + - '.github/workflows/ios.yml' + +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + package-tests: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain versions + run: | + xcodebuild -version + swift --version + - name: Run HapiKit package tests + run: swift test --package-path ios/Packages/HapiKit + + app-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Show toolchain versions + run: xcodebuild -version + - name: Build Hapi app for iOS Simulator + run: xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO diff --git a/.gitignore b/.gitignore index 7c12eb7360..b9c798c88e 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,10 @@ e2e-output/ .xyz-harness .agents/ .pi/ + +# Xcode / iOS build artifacts +ios/**/xcuserdata/ +ios/**/.build/ +ios/**/.swiftpm/ +**/*.xcresult +**/DerivedData/ diff --git a/ios/Hapi.xcodeproj/project.pbxproj b/ios/Hapi.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..3b8655ec6c --- /dev/null +++ b/ios/Hapi.xcodeproj/project.pbxproj @@ -0,0 +1,316 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + A100000000000000000000C4 /* HapiProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = A100000000000000000000C2 /* HapiProtocol */; }; + A100000000000000000000C5 /* HapiClient in Frameworks */ = {isa = PBXBuildFile; productRef = A100000000000000000000C3 /* HapiClient */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A100000000000000000000A4 /* Hapi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hapi.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + A100000000000000000000C6 /* Exceptions for "Hapi" folder in "Hapi" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = A100000000000000000000A5 /* Hapi */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A100000000000000000000A2 /* Hapi */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + A100000000000000000000C6 /* Exceptions for "Hapi" folder in "Hapi" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = Hapi; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + A100000000000000000000A8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A100000000000000000000C4 /* HapiProtocol in Frameworks */, + A100000000000000000000C5 /* HapiClient in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A100000000000000000000A1 = { + isa = PBXGroup; + children = ( + A100000000000000000000A2 /* Hapi */, + A100000000000000000000A3 /* Products */, + ); + sourceTree = ""; + }; + A100000000000000000000A3 /* Products */ = { + isa = PBXGroup; + children = ( + A100000000000000000000A4 /* Hapi.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A100000000000000000000A5 /* Hapi */ = { + isa = PBXNativeTarget; + buildConfigurationList = A100000000000000000000B4 /* Build configuration list for PBXNativeTarget "Hapi" */; + buildPhases = ( + A100000000000000000000A7 /* Sources */, + A100000000000000000000A8 /* Frameworks */, + A100000000000000000000A9 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + A100000000000000000000A2 /* Hapi */, + ); + name = Hapi; + packageProductDependencies = ( + A100000000000000000000C2 /* HapiProtocol */, + A100000000000000000000C3 /* HapiClient */, + ); + productName = Hapi; + productReference = A100000000000000000000A4 /* Hapi.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A100000000000000000000A6 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + A100000000000000000000A5 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = A100000000000000000000B1 /* Build configuration list for PBXProject "Hapi" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A100000000000000000000A1; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + A100000000000000000000C1 /* XCLocalSwiftPackageReference "Packages/HapiKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = A100000000000000000000A3 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A100000000000000000000A5 /* Hapi */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A100000000000000000000A9 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A100000000000000000000A7 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A100000000000000000000B2 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A100000000000000000000B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A100000000000000000000B5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Hapi/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HAPI; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = run.hapi.companion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A100000000000000000000B6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Hapi/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HAPI; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = run.hapi.companion; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A100000000000000000000B1 /* Build configuration list for PBXProject "Hapi" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A100000000000000000000B2 /* Debug */, + A100000000000000000000B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A100000000000000000000B4 /* Build configuration list for PBXNativeTarget "Hapi" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A100000000000000000000B5 /* Debug */, + A100000000000000000000B6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + A100000000000000000000C1 /* XCLocalSwiftPackageReference "Packages/HapiKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/HapiKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A100000000000000000000C2 /* HapiProtocol */ = { + isa = XCSwiftPackageProductDependency; + productName = HapiProtocol; + }; + A100000000000000000000C3 /* HapiClient */ = { + isa = XCSwiftPackageProductDependency; + productName = HapiClient; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = A100000000000000000000A6 /* Project object */; +} diff --git a/ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme b/ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme new file mode 100644 index 0000000000..265f76fbc9 --- /dev/null +++ b/ios/Hapi.xcodeproj/xcshareddata/xcschemes/Hapi.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..eb87897008 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..13613e3ee1 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Hapi/Assets.xcassets/Contents.json b/ios/Hapi/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/ios/Hapi/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Hapi/HapiApp.swift b/ios/Hapi/HapiApp.swift new file mode 100644 index 0000000000..d65b705b52 --- /dev/null +++ b/ios/Hapi/HapiApp.swift @@ -0,0 +1,19 @@ +import OSLog +import SwiftUI + +@main +struct HapiApp: App { + private let log = Logger(subsystem: "run.hapi.companion", category: "app") + + var body: some Scene { + WindowGroup { + RootView() + .onOpenURL { url in + // M1d wires this to the pairing flow + // (hapicompanion://bind?hub=&code=). + // For M0 the scheme is registered but intentionally ignored. + log.info("Ignoring URL until pairing lands: \(url.absoluteString, privacy: .public)") + } + } + } +} diff --git a/ios/Hapi/Info.plist b/ios/Hapi/Info.plist new file mode 100644 index 0000000000..259f304f8f --- /dev/null +++ b/ios/Hapi/Info.plist @@ -0,0 +1,19 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + run.hapi.companion.pairing + CFBundleURLSchemes + + hapicompanion + + + + + diff --git a/ios/Hapi/RootView.swift b/ios/Hapi/RootView.swift new file mode 100644 index 0000000000..9836503379 --- /dev/null +++ b/ios/Hapi/RootView.swift @@ -0,0 +1,26 @@ +import HapiClient +import HapiProtocol +import SwiftUI + +struct RootView: View { + var body: some View { + NavigationStack { + ContentUnavailableView { + Label("HAPI", systemImage: "antenna.radiowaves.left.and.right") + } description: { + Text("Native companion scaffold (M0). Pairing and sessions arrive in M1.") + } + .navigationTitle("HAPI") + .safeAreaInset(edge: .bottom) { + Text("HapiKit \(HapiClientVersion.current) · protocol v\(ProtocolVersion.supported)") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.bottom, 8) + } + } + } +} + +#Preview { + RootView() +} diff --git a/ios/Packages/HapiKit/Package.swift b/ios/Packages/HapiKit/Package.swift new file mode 100644 index 0000000000..781247fc75 --- /dev/null +++ b/ios/Packages/HapiKit/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "HapiKit", + platforms: [ + // The app targets iOS 17+. macOS is declared so `swift test` can run + // the package on macOS CI runners (the code is pure Foundation). + .iOS(.v17), + .macOS(.v14), + ], + products: [ + .library(name: "HapiProtocol", targets: ["HapiProtocol"]), + .library(name: "HapiClient", targets: ["HapiClient"]), + ], + targets: [ + // Pure protocol layer: wire models, chat pipeline, window logic. + // Must stay Foundation-only so it can be verified against + // shared/fixtures/** without any UI or transport concerns. + .target(name: "HapiProtocol"), + // Transport layer: APIClient, auth, SSE, stores (lands in M1). + .target(name: "HapiClient", dependencies: ["HapiProtocol"]), + .testTarget(name: "HapiProtocolTests", dependencies: ["HapiProtocol"]), + .testTarget(name: "HapiClientTests", dependencies: ["HapiClient"]), + ] +) diff --git a/ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift b/ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift new file mode 100644 index 0000000000..6368927cb7 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiClient/HapiClientVersion.swift @@ -0,0 +1,15 @@ +import Foundation +import HapiProtocol + +/// Scaffold marker for the transport layer. +/// +/// APIClient, AuthManager (single-flight refresh), and SSEClient arrive in +/// M1b/M1c. Until then this constant proves the target graph and the +/// HapiClient -> HapiProtocol dependency compile and link. +public enum HapiClientVersion { + /// Version of the HapiKit client scaffold. + public static let current = "0.1.0" + + /// Highest hub protocol generation this client speaks. + public static let protocolVersion = ProtocolVersion.supported +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift new file mode 100644 index 0000000000..e07a260aa0 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/JSONValue.swift @@ -0,0 +1,106 @@ +import Foundation + +/// A JSON value exactly as it appears on the wire. +/// +/// The hub protocol carries several free-form JSON payloads (tool inputs, +/// agent event data, metadata blobs). `JSONValue` preserves them losslessly +/// without committing to a schema. Numbers are stored as `Double`, matching +/// JavaScript semantics on the hub side. +public enum JSONValue: Equatable, Hashable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) +} + +// MARK: - Codable + +extension JSONValue: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + // Foundation's JSON decoder does not coerce 0/1 to Bool (nor + // true/false to Double), so the ordering here is not lossy. + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Value is not a representable JSON type" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } +} + +// MARK: - Literal conveniences (used heavily by tests and fixtures) + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift new file mode 100644 index 0000000000..1a1a17ed44 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Models/Placeholder.swift @@ -0,0 +1,11 @@ +import Foundation + +/// The hub protocol generation this client understands. +/// +/// Real wire models (sessions, messages, patches, catalogs) land in M1a. +/// This type exists from M0 so the target, its tests, and the app's package +/// linkage have a stable anchor. +public struct ProtocolVersion: Sendable { + /// Highest protocol generation supported by this build. + public static let supported = 1 +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift new file mode 100644 index 0000000000..3842fcb231 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/SmokeTests.swift @@ -0,0 +1,10 @@ +import HapiClient +import Testing + +@Suite("HapiClient scaffold") +struct SmokeTests { + @Test func exposesVersionMetadata() { + #expect(!HapiClientVersion.current.isEmpty) + #expect(HapiClientVersion.protocolVersion == 1) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift b/ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift new file mode 100644 index 0000000000..0856edb7bf --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiProtocolTests/JSONValueTests.swift @@ -0,0 +1,122 @@ +import Foundation +import HapiProtocol +import Testing + +@Suite("JSONValue wire coding") +struct JSONValueTests { + private func decode(_ json: String) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: Data(json.utf8)) + } + + private func encode(_ value: JSONValue) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return String(decoding: try encoder.encode(value), as: UTF8.self) + } + + @Test func decodesEveryScalarKind() throws { + #expect(try decode("null") == .null) + #expect(try decode("true") == .bool(true)) + #expect(try decode("false") == .bool(false)) + #expect(try decode("42") == .number(42)) + #expect(try decode("-7.5") == .number(-7.5)) + #expect(try decode("\"hapi\"") == .string("hapi")) + } + + @Test func decodesEmptyContainers() throws { + #expect(try decode("{}") == .object([:])) + #expect(try decode("[]") == .array([])) + } + + @Test func distinguishesBoolsNumbersAndStrings() throws { + #expect(try decode("1") == .number(1)) + #expect(try decode("1") != .bool(true)) + #expect(try decode("true") == .bool(true)) + #expect(try decode("true") != .number(1)) + #expect(try decode("\"true\"") == .string("true")) + #expect(try decode("\"null\"") == .string("null")) + } + + @Test func decodesNestedDocument() throws { + let json = """ + { + "id": "sess_1", + "seq": 7, + "ratio": 0.5, + "active": true, + "archived": false, + "parent": null, + "tags": ["ios", "m0"], + "meta": {"nested": {"deep": [1, {"leaf": null}]}} + } + """ + let expected: JSONValue = [ + "id": "sess_1", + "seq": 7, + "ratio": 0.5, + "active": true, + "archived": false, + "parent": nil, + "tags": ["ios", "m0"], + "meta": ["nested": ["deep": [1, ["leaf": nil]]]], + ] + #expect(try decode(json) == expected) + } + + @Test func roundTripsNestedComposite() throws { + let original: JSONValue = [ + "role": "agent", + "content": [ + "type": "output", + "data": [ + "blocks": [ + ["text": "hello", "tokens": 12.5, "done": false], + ["text": "world", "tokens": 3, "done": true], + ], + "meta": nil, + "tags": ["a", "b", "c"], + ], + ], + "seq": 42, + ] + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == original) + } + + @Test func roundTripsScalarsAtTopLevel() throws { + for value in [JSONValue.null, .bool(false), .number(0.25), .string("x")] { + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(decoded == value) + } + } + + @Test func encodesDeterministicTextForNonNumericValues() throws { + // Numbers are excluded here on purpose: their textual form is an + // implementation detail of JSONEncoder; they are covered by the + // round-trip tests above. + let value: JSONValue = [ + "b": true, + "a": [nil, "x"], + ] + #expect(try encode(value) == #"{"a":[null,"x"],"b":true}"#) + } + + @Test func literalsBuildTheExpectedCases() { + let object: JSONValue = ["k": 1] + #expect(object == .object(["k": .number(1)])) + + let null: JSONValue = nil + #expect(null == .null) + + let array: JSONValue = [true, 2.5, "s"] + #expect(array == .array([.bool(true), .number(2.5), .string("s")])) + } + + @Test func rejectsMalformedJSON() { + #expect(throws: (any Error).self) { + try JSONDecoder().decode(JSONValue.self, from: Data("{".utf8)) + } + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000000..e3370a0a89 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,81 @@ +# HAPI iOS (native companion) + +Native SwiftUI client for HAPI. Fully independent of `web/` at the code level; +it shares only the protocol contract (`docs/api/`) and the golden fixtures +(`shared/fixtures/`, produced by the consistency track). + +## Requirements + +- Xcode 16 or newer (iOS 17 SDK). No third-party dependencies in M0. +- Deployment target: iOS 17.0. + +## Build + +Open `ios/Hapi.xcodeproj` in Xcode and run the shared `Hapi` scheme, or from +the command line: + +```sh +# App (simulator, no signing) +xcodebuild build -project ios/Hapi.xcodeproj -scheme Hapi \ + -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO + +# Package tests (also runs on macOS, the package is pure Foundation) +swift test --package-path ios/Packages/HapiKit +``` + +CI runs both on `macos-15` via `.github/workflows/ios.yml` (triggered by +changes under `ios/**` and `shared/fixtures/**`). + +## Layout + +``` +ios/ + Hapi.xcodeproj/ Hand-rolled minimal project (objectVersion 77). + Hapi/ App target sources. This is an Xcode 16 "synchronized + folder": add files here and they join the target + without touching project.pbxproj. + Packages/HapiKit/ Local SPM package with the real logic: + HapiProtocol Pure-Foundation protocol layer: wire models, JSONValue, + chat pipeline + message window logic (ported from + web/src/chat/**). Validated against shared/fixtures/**. + HapiClient Transport layer: APIClient, AuthManager (single-flight + refresh), SSEClient, @Observable stores, snapshots. +``` + +The app target stays thin; features live in `HapiKit` so they are testable +with `swift test` and free of UI concerns. + +### Fixtures + +`HapiProtocolTests` will run the golden fixtures from the repo-root +`shared/fixtures/` directory, resolved relative to the package directory +(`ios/Packages/HapiKit` -> `../../../shared/fixtures`). The wiring lands in +M2 once the fixture generator (track K, K5) has produced them; until then the +tests are self-contained. + +## Milestones (track A of the native-clients plan) + +- **M0** — this scaffold: project, HapiKit package, CI, one passing test. +- **M1** — foundations: HapiProtocol wire models + catalogs; APIClient + auth + (Keychain, single-flight 401 refresh); SSEClient + reconnect state machine + + versioned patch application (incl. gzip streaming check); pairing flow + (VisionKit scan + `hapicompanion://bind` deep link + multi-hub). +- **M2** — read-only chat: session list; chat pipeline port + (normalize/reducer/toolGroups, fixtures green is the gate); message window + store; Markdown/code/diff renderers; read-only ChatView with paging. +- **M3** — interaction: composer (optimistic send, queue/steer, drafts, + reopen migration, slash commands); permission UX; new session; attachments. +- **M4** — secondary features: files/git; Scratchlist; dictation; + usage/storage (Swift Charts); settings. +- **M5** — polish: zh-CN localization, Dynamic Type/VoiceOver, long-session + memory profiling, App Store material. + +## Notes + +- The `hapicompanion://` URL scheme is registered via `Hapi/Info.plist` + (only `CFBundleURLTypes` lives there; everything else is generated through + `GENERATE_INFOPLIST_FILE` + `INFOPLIST_KEY_*` build settings). +- `run.hapi.companion` is the bundle id; signing is `Automatic` and CI builds + with `CODE_SIGNING_ALLOWED=NO`. +- CI uses the runner's default Xcode; each job prints `xcodebuild -version` + first so failures are attributable to a toolchain bump. From 83bd25aa3bd29935cf2a1087ceaac3590767498b Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 11:22:35 +0800 Subject: [PATCH 005/168] feat(android): scaffold Compose app + core modules + CI (B-M0) --- .github/workflows/android.yml | 42 +++ .gitignore | 9 + android/README.md | 77 ++++++ android/app/build.gradle.kts | 63 +++++ android/app/proguard-rules.pro | 3 + android/app/src/main/AndroidManifest.xml | 30 +++ .../kotlin/app/hapi/companion/AppGraph.kt | 15 ++ .../kotlin/app/hapi/companion/MainActivity.kt | 69 +++++ .../app/hapi/companion/ui/theme/Theme.kt | 51 ++++ android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 6 + android/build.gradle.kts | 9 + android/core/data/build.gradle.kts | 38 +++ .../main/kotlin/app/hapi/data/DataModule.kt | 27 ++ android/core/protocol/build.gradle.kts | 35 +++ .../app/hapi/protocol/pairing/BindLink.kt | 93 +++++++ .../app/hapi/protocol/wire/JsonExtensions.kt | 53 ++++ .../app/hapi/protocol/wire/ProtocolVersion.kt | 11 + .../app/hapi/protocol/pairing/BindLinkTest.kt | 102 +++++++ .../hapi/protocol/wire/JsonExtensionsTest.kt | 113 ++++++++ android/gradle.properties | 12 + android/gradle/libs.versions.toml | 42 +++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 8 + android/gradlew | 251 ++++++++++++++++++ android/gradlew.bat | 94 +++++++ android/settings.gradle.kts | 27 ++ 27 files changed, 1284 insertions(+) create mode 100644 .github/workflows/android.yml create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt create mode 100644 android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt create mode 100644 android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/core/data/build.gradle.kts create mode 100644 android/core/data/src/main/kotlin/app/hapi/data/DataModule.kt create mode 100644 android/core/protocol/build.gradle.kts create mode 100644 android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/BindLink.kt create mode 100644 android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/JsonExtensions.kt create mode 100644 android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ProtocolVersion.kt create mode 100644 android/core/protocol/src/test/kotlin/app/hapi/protocol/pairing/BindLinkTest.kt create mode 100644 android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/JsonExtensionsTest.kt create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000000..6b92d7b5e5 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,42 @@ +name: android + +on: + push: + branches: + - main + paths: + - 'android/**' + - 'shared/fixtures/**' + - '.github/workflows/android.yml' + pull_request: + paths: + - 'android/**' + - 'shared/fixtures/**' + - '.github/workflows/android.yml' + +permissions: + contents: read + +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: android + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + # Android SDK is preinstalled on GitHub ubuntu runners (licenses + # accepted); AGP auto-installs any missing platform/build-tools. + - uses: gradle/actions/setup-gradle@v4 + - name: Protocol tests (pure JVM, fixtures-driven) + run: ./gradlew :core:protocol:test + - name: Assemble debug APK + run: ./gradlew :app:assembleDebug diff --git a/.gitignore b/.gitignore index 7c12eb7360..af4dd748f0 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,12 @@ e2e-output/ .xyz-harness .agents/ .pi/ + +# Android (android/) +android/**/build/ +android/.gradle/ +android/.kotlin/ +android/local.properties +android/captures/ +*.keystore +*.jks diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000000..74b6f442d7 --- /dev/null +++ b/android/README.md @@ -0,0 +1,77 @@ +# HAPI Android Companion + +Native Android client (Kotlin + Jetpack Compose) for the HAPI hub. Fully +independent from the web app; shares only the protocol contract +(`docs/api/`) and the golden fixtures (`shared/fixtures/`). + +- **applicationId**: `run.hapi.companion` · **minSdk** 26 · **target/compileSdk** 36 +- **Toolchain**: Gradle 8.14.2 (wrapper) · AGP 8.11.1 · Kotlin 2.1.21 · Compose BOM 2025.05.00 · JDK 17+ (CI uses 21) + +## Modules + +| Module | Type | Responsibility | +|---|---|---| +| `:core:protocol` | **pure Kotlin/JVM** (no Android) | Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, `BindLink` pairing-link parsing. | +| `:core:data` | Android library | Transport + persistence: `HapiApi` (OkHttp REST, single-flight 401 re-auth), `SseEngine` (okhttp-sse, resume/backoff state machine), StateFlow stores + AtomicFile JSON snapshots, credential store, FCM registration, WorkManager workers. Placeholder in M0 — see `DataModule.kt`. | +| `:app` | Android application | Compose UI, navigation, deep links (`hapicompanion://bind`), FCM service (M4), hand-rolled DI (`AppGraph`, no Hilt). | + +Dependency direction: `:app` → `:core:data` → `:core:protocol`. + +## Protocol conformance fixtures + +`:core:protocol` is the porting target for `web/src/chat/` and is verified +against golden fixtures generated from the web implementation (track K). +The test task already passes the fixtures location as a system property: + +```kotlin +// core/protocol/build.gradle.kts +tasks.test { + systemProperty("hapi.fixtures.dir", rootDir.parentFile.resolve("shared/fixtures").absolutePath) +} +``` + +Fixture-driven tests (M2) read `System.getProperty("hapi.fixtures.dir")` — +no further build changes are needed when `shared/fixtures/**` lands. CI +re-runs this suite whenever `android/**` or `shared/fixtures/**` change. + +## Building + +Requires an Android SDK for `:app`/`:core:data` (set `ANDROID_HOME` or +`android/local.properties` with `sdk.dir=...`). `:core:protocol` alone needs +only a JDK. + +```sh +cd android +./gradlew :core:protocol:test # pure JVM protocol tests (fast) +./gradlew :app:assembleDebug # debug APK +./gradlew :app:installDebug # install on a connected device +``` + +Without an Android SDK you can still run the protocol suite by configuring +only the needed projects: + +```sh +./gradlew --no-configuration-cache --configure-on-demand :core:protocol:test +``` + +CI (`.github/workflows/android.yml`) runs the protocol tests and +`:app:assembleDebug` on every PR touching `android/**` or `shared/fixtures/**`. + +## Milestones (track B of the native-clients plan) + +- **M0** — this scaffold: modules, version catalog, CI, placeholder screen. +- **M1** — foundations: wire types + modes catalog; auth + `HapiApi` (MockWebServer-tested); `SseEngine` reconnect state machine + versioned patches (gzip streaming verified); pairing UI + `hapicompanion://bind` deep link. +- **M2** — read-only chat: chat pipeline port gated on fixtures all-green; session list; `MessageWindowStore` port; Markdown renderer; read-only chat screen (`LazyColumn(reverseLayout = true)`). +- **M3** — interaction: composer (optimistic send/queue/steer/drafts), permission approvals UX, session controls (mode/model/abort/resume/rename/archive), new session, dictation. +- **M4** — FCM push (register → notification actions via expedited WorkManager) + files/git viewer, Scratchlist, usage/storage stats. +- **M5** — polish: zh-CN i18n, OLED/Material You theming, predictive back, LeakCanary pass, Play listing + self-build docs. + +## Firebase / push (self-build note) + +M0 deliberately does **not** apply the `com.google.gms.google-services` +plugin and has no Firebase dependency, so the project builds without any +`google-services.json`. In M4a the plugin lands together with the FCM +service: official builds inject the default Firebase project config in CI, +while self-builders drop in their own `app/google-services.json` (docs will +accompany M4a; a `PushBinding` seam for hub-provided `FirebaseOptions` is +planned for v1.x). diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000000..99a21d70cf --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,63 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + // NOTE: com.google.gms.google-services is deliberately NOT applied in M0. + // It is added in M4a together with google-services.json + the FCM service, + // so the scaffold builds green without any Firebase project configured. +} + +android { + namespace = "app.hapi.companion" + compileSdk = 36 + + defaultConfig { + applicationId = "run.hapi.companion" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation(project(":core:protocol")) + implementation(project(":core:data")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000000..dcccef4df1 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# App-specific R8/ProGuard rules. +# kotlinx.serialization ships its own consumer rules; add rules here only when a +# release build actually needs them (verified via :app:assembleRelease + smoke test). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..359d0d7f19 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt new file mode 100644 index 0000000000..cc445280ea --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt @@ -0,0 +1,15 @@ +package app.hapi.companion + +/** + * Hand-rolled dependency graph seed (no Hilt/Dagger by design -- the app wires + * roughly 15 long-lived types, which does not justify a DI framework). + * + * From M1 on this becomes a class instantiated in `HapiApplication.onCreate()` + * that owns the singletons in construction order: + * CredentialStore -> AuthManager -> HapiApi -> SseEngine -> stores + * (session list / session detail / message window) -> push registration. + * Compose reads it via a CompositionLocal; workers/services reach it through + * the Application instance. Everything behind it stays constructor-injected + * and unit-testable without this object. + */ +object AppGraph diff --git a/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt new file mode 100644 index 0000000000..3633f30667 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt @@ -0,0 +1,69 @@ +package app.hapi.companion + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import app.hapi.companion.ui.theme.HapiTheme +import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION + +/** + * Single-activity entry point. M1d replaces the placeholder with the pairing + * flow (QR scan + `hapicompanion://bind` deep link handling -- the intent + * filter is already declared in the manifest) and Navigation Compose. + */ +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + HapiTheme { + PlaceholderScreen() + } + } + } +} + +@Composable +private fun PlaceholderScreen() { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "HAPI", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Companion scaffold · protocol v$SUPPORTED_PROTOCOL_VERSION", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PlaceholderScreenPreview() { + HapiTheme { + PlaceholderScreen() + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt new file mode 100644 index 0000000000..2c11f19251 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/ui/theme/Theme.kt @@ -0,0 +1,51 @@ +package app.hapi.companion.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF3D6837), + secondary = Color(0xFF54634D), + tertiary = Color(0xFF386569), +) + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFFA3D397), + secondary = Color(0xFFBCCBB2), + tertiary = Color(0xFFA0CFD2), +) + +/** + * Material3 theme for the HAPI companion app. + * + * Dynamic color (Material You) on Android 12+, static fallback schemes below. + * M5 revisits this for OLED-black surfaces and full token coverage. + */ +@Composable +fun HapiTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + content = content, + ) +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..972ceafb34 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + HAPI + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..2da5ac2acf --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + + + +
+ ← HAPI +

Privacy Policy

+

Effective date: August 19, 2026 · Applies to the HAPI mobile companion apps (Android and iOS) and the self-hosted HAPI hub.

+ +
+

The short version: HAPI is self-hosted software. The apps talk only to the + hub server you run. We — the HAPI developers — operate no servers that receive your + conversations, your code, or your personal data. The apps contain no analytics, no advertising, + and no tracking SDKs, and there is no account with us to create.

+
+ +

What the app stores on your device

+
    +
  • The address of your hub and the access credentials you pair with (kept in the app's private storage).
  • +
  • App preferences (theme, language, notification choices).
  • +
  • Cached session content fetched from your hub, so the app works offline.
  • +
+

Uninstalling the app removes all of this from the device. Your sessions themselves live on your + own hub server, under your control.

+ +

Where your data travels

+

Session content, files, and commands move directly between the app and your hub over the + connection you configure. No copy is sent to us or to any third party by the app itself.

+ +

Push notifications

+

Android: notifications are delivered through Google's Firebase Cloud + Messaging (FCM). Your hub sends the notification (session title, status text) to FCM, which routes + it to your device; Google processes this traffic per its own privacy policy. The FCM device token + is stored only by your hub. Builds compiled without a Firebase configuration send nothing to + Google and simply have no push.

+

iOS: notifications are end-to-end encrypted. Your hub encrypts the content + with a key that exists only on your device and your hub; Apple's push service — and the optional + HAPI relay, if you use it instead of your own APNs credentials — carry ciphertext and routing + metadata only, and cannot read the notification. Self-hosters can bypass the relay entirely with + their own Apple developer credentials.

+ +

Camera

+

Used only to scan the pairing QR code, processed on the device. No images are stored or + transmitted, and pairing works without the camera via manual entry.

+ +

Microphone

+

Used only for voice dictation in the message composer, and only while you hold the dictation + button. Speech recognition is performed by your device's system speech service (for example, the + platform speech recognizer), which may process audio according to its provider's policy. HAPI + does not record, store, or transmit the audio itself; only the resulting text is placed in the + composer.

+ +

What we collect

+

Nothing. The apps have no telemetry, crash reporting, analytics, or advertising SDKs. If you + install from an app store, the store operator (Google or Apple) may collect install and crash + statistics under its own policies, independently of us.

+ +

Data deletion

+

Unpair a hub or uninstall the app to remove everything held on the device. Data on your hub is + yours to delete at any time — it is your server.

+ +

Children

+

HAPI is a developer tool and is not directed at children under 13.

+ +

Open source

+

The complete source code of the apps and the hub is available at + github.com/tiann/hapi — the claims above can be + verified in the code.

+ +

Changes & contact

+

If this policy changes, the updated version will be published at this address with a new + effective date. Questions and concerns: open an issue on + GitHub or email + twsxtd@gmail.com.

+
+ + From c62cea9be80de4a95b57fe584291cb0a1a0003d8 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 19 Aug 2026 20:50:10 +0800 Subject: [PATCH 078/168] docs: privacy policy as a VitePress page, replacing the static HTML Markdown under docs/ renders through the existing docs pipeline (deployed at /docs/privacy.html) and stays easy to maintain; the hand-written website/public/privacy.html is gone. Footer gains a Privacy Policy link. Content unchanged: self-hosted architecture, zero developer-side collection, FCM transit on Android vs E2E envelopes on iOS, camera/microphone usage, deletion, contact. --- docs/.vitepress/config.ts | 2 +- docs/privacy.md | 58 ++++++++++++++++ website/public/privacy.html | 129 ------------------------------------ 3 files changed, 59 insertions(+), 130 deletions(-) create mode 100644 docs/privacy.md delete mode 100644 website/public/privacy.html diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 0a740df879..c0afdb7f0b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -74,7 +74,7 @@ export default defineConfig({ ], footer: { - message: 'Released under the AGPL-3.0 License.', + message: 'Released under the AGPL-3.0 License. · Privacy Policy', copyright: 'Copyright © 2025-present' }, diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000000..d2c6448574 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,58 @@ +--- +title: Privacy Policy +aside: false +--- + +# Privacy Policy + +**Effective date: August 19, 2026** · Applies to the HAPI mobile companion apps (Android and iOS) and the self-hosted HAPI hub. + +::: tip The short version +HAPI is self-hosted software. The apps talk only to the hub server **you** run. We — the HAPI developers — operate no servers that receive your conversations, your code, or your personal data. The apps contain no analytics, no advertising, and no tracking SDKs, and there is no account with us to create. +::: + +## What the app stores on your device + +- The address of your hub and the access credentials you pair with (kept in the app's private storage). +- App preferences (theme, language, notification choices). +- Cached session content fetched from your hub, so the app works offline. + +Uninstalling the app removes all of this from the device. Your sessions themselves live on your own hub server, under your control. + +## Where your data travels + +Session content, files, and commands move directly between the app and your hub over the connection you configure. No copy is sent to us or to any third party by the app itself. + +## Push notifications + +**Android:** notifications are delivered through Google's Firebase Cloud Messaging (FCM). Your hub sends the notification (session title, status text) to FCM, which routes it to your device; Google processes this traffic per its own privacy policy. The FCM device token is stored only by your hub. Builds compiled without a Firebase configuration send nothing to Google and simply have no push. + +**iOS:** notifications are end-to-end encrypted. Your hub encrypts the content with a key that exists only on your device and your hub; Apple's push service — and the optional HAPI relay, if you use it instead of your own APNs credentials — carry ciphertext and routing metadata only, and cannot read the notification. Self-hosters can bypass the relay entirely with their own Apple developer credentials. + +## Camera + +Used only to scan the pairing QR code, processed on the device. No images are stored or transmitted, and pairing works without the camera via manual entry. + +## Microphone + +Used only for voice dictation in the message composer, and only while you hold the dictation button. Speech recognition is performed by your device's system speech service (for example, the platform speech recognizer), which may process audio according to its provider's policy. HAPI does not record, store, or transmit the audio itself; only the resulting text is placed in the composer. + +## What we collect + +Nothing. The apps have no telemetry, crash reporting, analytics, or advertising SDKs. If you install from an app store, the store operator (Google or Apple) may collect install and crash statistics under its own policies, independently of us. + +## Data deletion + +Unpair a hub or uninstall the app to remove everything held on the device. Data on your hub is yours to delete at any time — it is your server. + +## Children + +HAPI is a developer tool and is not directed at children under 13. + +## Open source + +The complete source code of the apps and the hub is available at [github.com/tiann/hapi](https://github.com/tiann/hapi) — the claims above can be verified in the code. + +## Changes & contact + +If this policy changes, the updated version will be published at this address with a new effective date. Questions and concerns: open an issue on [GitHub](https://github.com/tiann/hapi/issues) or email [twsxtd@gmail.com](mailto:twsxtd@gmail.com). diff --git a/website/public/privacy.html b/website/public/privacy.html deleted file mode 100644 index 57b68879a8..0000000000 --- a/website/public/privacy.html +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - Privacy Policy — HAPI - - - - - -
- ← HAPI -

Privacy Policy

-

Effective date: August 19, 2026 · Applies to the HAPI mobile companion apps (Android and iOS) and the self-hosted HAPI hub.

- -
-

The short version: HAPI is self-hosted software. The apps talk only to the - hub server you run. We — the HAPI developers — operate no servers that receive your - conversations, your code, or your personal data. The apps contain no analytics, no advertising, - and no tracking SDKs, and there is no account with us to create.

-
- -

What the app stores on your device

-
    -
  • The address of your hub and the access credentials you pair with (kept in the app's private storage).
  • -
  • App preferences (theme, language, notification choices).
  • -
  • Cached session content fetched from your hub, so the app works offline.
  • -
-

Uninstalling the app removes all of this from the device. Your sessions themselves live on your - own hub server, under your control.

- -

Where your data travels

-

Session content, files, and commands move directly between the app and your hub over the - connection you configure. No copy is sent to us or to any third party by the app itself.

- -

Push notifications

-

Android: notifications are delivered through Google's Firebase Cloud - Messaging (FCM). Your hub sends the notification (session title, status text) to FCM, which routes - it to your device; Google processes this traffic per its own privacy policy. The FCM device token - is stored only by your hub. Builds compiled without a Firebase configuration send nothing to - Google and simply have no push.

-

iOS: notifications are end-to-end encrypted. Your hub encrypts the content - with a key that exists only on your device and your hub; Apple's push service — and the optional - HAPI relay, if you use it instead of your own APNs credentials — carry ciphertext and routing - metadata only, and cannot read the notification. Self-hosters can bypass the relay entirely with - their own Apple developer credentials.

- -

Camera

-

Used only to scan the pairing QR code, processed on the device. No images are stored or - transmitted, and pairing works without the camera via manual entry.

- -

Microphone

-

Used only for voice dictation in the message composer, and only while you hold the dictation - button. Speech recognition is performed by your device's system speech service (for example, the - platform speech recognizer), which may process audio according to its provider's policy. HAPI - does not record, store, or transmit the audio itself; only the resulting text is placed in the - composer.

- -

What we collect

-

Nothing. The apps have no telemetry, crash reporting, analytics, or advertising SDKs. If you - install from an app store, the store operator (Google or Apple) may collect install and crash - statistics under its own policies, independently of us.

- -

Data deletion

-

Unpair a hub or uninstall the app to remove everything held on the device. Data on your hub is - yours to delete at any time — it is your server.

- -

Children

-

HAPI is a developer tool and is not directed at children under 13.

- -

Open source

-

The complete source code of the apps and the hub is available at - github.com/tiann/hapi — the claims above can be - verified in the code.

- -

Changes & contact

-

If this policy changes, the updated version will be published at this address with a new - effective date. Questions and concerns: open an issue on - GitHub or email - twsxtd@gmail.com.

-
- - From 1f5602ad6ae64d3083647696f955a3c3933604bc Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 19 Aug 2026 21:31:29 +0800 Subject: [PATCH 079/168] Release version 0.29.0 --- .gitignore | 1 + bun.lock | 22 ++++++++++------------ cli/package.json | 12 ++++++------ shared/src/buildInfo.ts | 2 +- 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 87d5744778..1114b5e282 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ coverage/ # Claude local settings .claude/settings.local.json +.claude/worktrees/ localdocs/ execplan/ diff --git a/bun.lock b/bun.lock index c07cc564e8..7be5aa3307 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "cli": { "name": "@twsxtd/hapi", - "version": "0.28.0", + "version": "0.29.0", "bin": { "hapi": "bin/hapi.cjs", }, @@ -47,11 +47,11 @@ "vitest": "^4.0.16", }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.28.0", - "@twsxtd/hapi-darwin-x64": "0.28.0", - "@twsxtd/hapi-linux-arm64": "0.28.0", - "@twsxtd/hapi-linux-x64": "0.28.0", - "@twsxtd/hapi-win32-x64": "0.28.0", + "@twsxtd/hapi-darwin-arm64": "0.29.0", + "@twsxtd/hapi-darwin-x64": "0.29.0", + "@twsxtd/hapi-linux-arm64": "0.29.0", + "@twsxtd/hapi-linux-x64": "0.29.0", + "@twsxtd/hapi-win32-x64": "0.29.0", }, }, "docs": { @@ -1110,15 +1110,13 @@ "@twsxtd/hapi": ["@twsxtd/hapi@workspace:cli"], - "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-t1Yl+O23t2YrhPRE46B14ZNA109emOg0cKCDyWWM4CIBEYWWnsEA66tIR6fTxjmcbn9duhyn0asgv6BDftC08w=="], + "@twsxtd/hapi-darwin-arm64": ["@twsxtd/hapi-darwin-arm64@0.29.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-y9hxLvkZHko0JO6QDKsPSb9ITA8JHNkEIZAwjO5lpNQSS8PAXADQBCbqDqz3N7MHUGbnh0qGwFXClfaPxnfqfA=="], - "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-+Smx6GUTOk+/dsUJAhjN7xX6oFDqM0AErc16zPskQBY3KIMrn907+ZZEtuFJI/JYS4fR3yI+11M7x5+Qh98SZQ=="], + "@twsxtd/hapi-darwin-x64": ["@twsxtd/hapi-darwin-x64@0.29.0", "", { "os": "darwin", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-ORZh1fR2G4+eTIVg3nmq7t7isKxKer8XaP4xsdLXpZTFKH0pjkFELLb90ajSw+8cJBk6HrKE+jxfz+g2/r2R+Q=="], - "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-ARxkR2UPOsAWgmZItmzlqtJz5eoxlRVzgIdDxd3eV3l2qx++6QB12tjHHnWroJsW2h+Q6JUH//mFtgr/O7OJ5Q=="], + "@twsxtd/hapi-linux-arm64": ["@twsxtd/hapi-linux-arm64@0.29.0", "", { "os": "linux", "cpu": "arm64", "bin": { "hapi": "bin/hapi" } }, "sha512-kHQ0i8ZNFG6blbmKoUAt+gk6f5xADgHRvc9erw12+Le+0t00KD/czQf8tFXetyqHavUDWkT8sOG1FYYyNcBCwA=="], - "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-AekOE5SyYnVBFUDsoLEQ8tQwELQZ6n3/f3HUYMQXcdD2aHd/Xij3vF0vocUt1Zg1dPxhOzJD0gIuJvi/tPuKEQ=="], - - "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-fvfpT9MkIjWd++PQX/TQEIioWKYOvCSe2prhNCVfTGjnI+oaoK0h1g0lmwvT0+EYM81X5HoxKXfe/OyJjtqE8A=="], + "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.29.0", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-KwCJhDN9H+QI0Xafj63CFqqPiv/dHv6WhSjWKFOIcT6sPwY8JcLUjDkp5a4NLG9DSzqOR9DB2ptA2l9GPFa1/A=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], diff --git a/cli/package.json b/cli/package.json index fc2ed7e777..51f512bc91 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@twsxtd/hapi", - "version": "0.28.0", + "version": "0.29.0", "description": "App for agentic coding - access coding agent anywhere", "author": "Kirill Dubovitskiy & weishu", "license": "AGPL-3.0-only", @@ -26,11 +26,11 @@ } }, "optionalDependencies": { - "@twsxtd/hapi-darwin-arm64": "0.28.0", - "@twsxtd/hapi-darwin-x64": "0.28.0", - "@twsxtd/hapi-linux-arm64": "0.28.0", - "@twsxtd/hapi-linux-x64": "0.28.0", - "@twsxtd/hapi-win32-x64": "0.28.0" + "@twsxtd/hapi-darwin-arm64": "0.29.0", + "@twsxtd/hapi-darwin-x64": "0.29.0", + "@twsxtd/hapi-linux-arm64": "0.29.0", + "@twsxtd/hapi-linux-x64": "0.29.0", + "@twsxtd/hapi-win32-x64": "0.29.0" }, "scripts": { "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"", diff --git a/shared/src/buildInfo.ts b/shared/src/buildInfo.ts index b3a31edb05..49a39fda42 100644 --- a/shared/src/buildInfo.ts +++ b/shared/src/buildInfo.ts @@ -1 +1 @@ -export const APP_VERSION = '0.28.0' +export const APP_VERSION = '0.29.0' From 0f7a3da68bc45ebb817135fc6fc2f1f13f75d3dd Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 20 Aug 2026 08:53:41 +0800 Subject: [PATCH 080/168] feat(cursor): mid-turn Steer via concurrent ACP session/prompt (#888) (#1609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(shared): steer capability gates and live steered signal schemas - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession gate which agents can deliver queued messages into the active turn (pi, codex, cursor ACP; legacy stream-json cursor excluded) - AgentState.steeringActive, DecryptedMessage.steered and messages-consumed live signal (never persisted by the hub) * feat(cli): queue reservations and steered messages-consumed option - MessageQueue2 gains takeByLocalId/restoreReservation/ beginReservationDispatch/commitReservation so an async steer can reserve a queued row without racing the main loop's turn/start drain - emitMessagesConsumed accepts steered: true to mark mid-turn delivery * feat(codex): mid-turn steer via app-server turn/steer (#888) - CodexAppServerClient.steerTurn + TurnSteerParams/Response types - CodexRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, validates it against the active turn (no control commands, matching mode hash), injects via turn/steer with an epoch guard that invalidates in-flight steers on abort/cleanup - steeringActive agent state tracks the active-turn window - hub syncEngine gate opens to codex; messages-consumed relays steered * feat(web): Steered badge and steer gating for codex sessions - HappyUserMessage shows a ↳ Steered badge fed by the live messages-consumed steered signal, preserved across server echoes and refetches (mergeMessages carries the optimistic marker) - SessionChat gates canSteer via isSteeringSupportedForSession instead of the pi-only check - clearStaleQueuedStatus normalizes a queued status on an invoked message - fix(web): drop duplicate showSessionSummaryInChat in markdown test (upstream typecheck breakage) * feat(acp): split request dispatch from completion and add soft steer - AcpStdioTransport.sendRequestWithDispatch separates stdin-accepted dispatch from the JSON-RPC response, keeping sendRequest behavior unchanged - AcpSdkBackend tracks concurrent session/prompt requests with an activePromptRequests counter (main prompt + soft steers); response completion stays pending until every concurrent prompt settles - beginSoftSteerPrompt kicks off a concurrent session/prompt (Cursor GUI Send semantics — no cancel, no handler swap) returning {dispatched, completed}; softSteerPrompt awaits the full response for direct callers * feat(cursor): mid-turn soft steer via concurrent session/prompt (#888) - CursorAcpRemoteLauncher registers the steer-queued-message RPC handler: reserves the queued row, rejects control commands and mode mismatches, then soft-injects via beginSoftSteerPrompt without canceling the in-flight turn - Acks the hub once stdin accepts the inject (not on turn completion) to stay inside the 30s RPC window; the launcher stays busy until the concurrent prompt settles so handlers are not swapped mid-inject - steeringActive agent state mirrors the active-turn window; abort and cleanup reset it and invalidate pending steers - Legacy stream-json Cursor sessions register a steer handler that reports unsupported * fix(codex,shared): address bot findings on steer gate and ambiguous turn/steer - STEERING_SUPPORTED_FLAVORS / isSteeringSupportedForSession advertise codex and pi only; cursor joins when its soft-steer handler lands (#1609) - turn/steer now splits dispatch (stdin accepted) from completion (turn finished): the hub RPC acks once dispatch succeeds — never on the concurrent turn's completion, which can exceed the 30s RPC window - queue row commits only after the turn settles; a rejected/aborted steer restores the row so the message still delivers via turn/start, and a dispatched steer is never restored (no duplicate delivery) - steer carries clientUserMessageId (echoed as userMessage.clientId) so ambiguous transport failures can reconcile the thread later - client tests cover dispatch/complete split and stdin-write failure * feat(shared): advertise cursor in the steer gate now that its handler lands Cursor ACP sessions pass the web and hub steer gates; legacy stream-json cursor sessions stay excluded. * fix(codex): reconcile dispatched steers before restoring; align error copy - A dispatched turn/steer whose completion fails (disconnect / protocol error) is now reconciled via thread/read by clientUserMessageId before the queued row is restored — the instruction is only re-delivered by turn/start when the thread never received it - Reconcile targets the pinned steer thread, not whichever turn is current when completion fails - syncEngine unsupported-flavor error now matches the capability gate (Pi and Codex only until the cursor handler lands) - launcher tests cover steer success (ack on dispatch), reconcile-accepted and reconcile-rejected outcomes * fix(codex): consume the row at dispatch; drop background reconcile - The hub RPC acks and the queue row is consumed as soon as stdin accepts turn/steer; completion is background-only logging. A dispatched steer is never restored, so the same localId cannot be re-delivered via turn/start after the caller was told the steer succeeded - Dispatch failure (stdin write error) still restores the row and reports failure - steer.completed rejection is always handled (no unhandled rejection on the dispatch-failure path) - tests updated: completion failure after dispatch keeps the row consumed; dispatch failure restores it * fix(cursor): consume the row at dispatch; keep waiters for prompt gating - The hub RPC acks and the queue row is consumed as soon as stdin accepts the concurrent session/prompt; completion is background-only. A dispatched steer is never restored (no duplicate via the next prompt) - softSteerWaiters are registered before awaiting dispatch so the main loop's finally cannot start the next prompt mid-inject; they still gate prompt handover on completion - tests updated: post-dispatch ACP rejection keeps the row consumed * fix(cursor,hub): never hang teardown on unresolved soft steer; align diagnostics - Prompt-finally waits for soft-steer completion only when not exiting; the outer finally no longer waits at all — cleanup() disconnects the ACP transport, which rejects pending requests and settles the waiters - syncEngine gate diagnostics and JSDoc name all supported flavors (Pi, Codex, Cursor ACP) - regression test: Switch with an unresolved soft-steer completion still reaches teardown * fix(codex): distinguish definite rejection from indeterminate completion - Transport-level failures (timeout, abort, disconnect, spawn, protocol) carry an indeterminate marker; explicit JSON-RPC error responses do not - After a dispatched steer, turn completion resolves → commit + consumed; a definite app-server rejection restores the row (instruction was never accepted, so turn/start cannot duplicate it); an indeterminate outcome leaves the row reserved so it can never be delivered twice - Completion handling registers before awaiting dispatch so the dispatch-failure path cannot leak an unhandled rejection - client/launcher tests cover explicit rejection (restore), indeterminate outcome (row stays reserved) and dispatch failure * fix(codex): reconcile indeterminate steers instead of a permanent reservation - After an indeterminate completion (disconnect/protocol), reconcile the thread by clientUserMessageId immediately: accepted → commit + consumed, provably rejected → restore, still unreadable → keep the reservation and retry from the main-loop top on later passes (post-reconnect) - A row never sits in dispatching forever: the hub cannot stamp it invoked while the instruction may never have been accepted - tests: indeterminate keeps reserved while thread unreadable; accepted reconciliation consumes; rejected path restores * fix(cursor): abort drops soft-steer waiters so the next prompt never blocks - Ordinary Abort (shouldExit false) now clears softSteerWaiters: the prompt finally cannot wait forever on a soft steer whose completion is unbounded; the ACP cancel rejects in-flight requests, and cleanup() settles leftovers on session end - regression test: unresolved soft-steer completion after Abort no longer blocks the next prompt * fix(codex): accept all thread item shapes; retry reconcile; ack through abort - Reconcile matcher accepts userMessage/user_message with clientId/ client_id, matching the shapes the thread parser supports — an accepted steer can no longer be misclassified as rejected - A pending reconciliation schedules a wakeLoop retry, so a temporary app-server outage cannot strand the reservation behind waitForTurnOrRecovery - The success-path ACK no longer checks the steer epoch: the hub already reported steered on dispatch, so commit + messages-consumed must reach it even when an abort resets the queue in between * fix(cursor,acp): abort force-settles soft-steer bookkeeping - AcpSdkBackend.abortSoftSteers() drops the concurrent-prompt counter and notifies response-complete so the next turn's waitForResponseComplete() cannot block on a soft steer that will never settle after abort - handleAbort calls it before clearing the waiters; the main prompt's own finishPromptRequest stays guarded by Math.max(0, ...) - unit tests cover counter release and no-op when idle * fix(codex): reinit reconnected app-server; keep reconcile retries alive - thread/read after a disconnect auto-connects a fresh app-server, which must be initialized before any request — reconcile now ensures connect + initialize (isConnected getter added to the client) - every still-unknown loop-top reconciliation schedules the next retry, so recovery without external traffic is eventually observed - launcher mock gains isConnected * test(acp): match finishPromptRequest epoch signature in whitebox test * fix(codex): timer-driven reconciliation; init tracking; abort-safe ACK - Reconciliation runs on a self-rescheduling 1s timer independent of the main loop (wakes it too), so idle loops and waitForTurnOrRecovery still observe app-server recovery; abort clears nothing implicitly — the ACK path commits and consumes even when the reservation was cancelled - Absence of a durable client id is ambiguous: unmatched reads stay 'unknown' and keep retrying instead of restoring the row - CodexAppServerClient tracks initialized state (reset on disconnect/exit) so ensureAppServerInitialized re-initializes a fresh process before thread/read; initialize failures leave the flag false for the next retry - tests: accepted reconciliation via scheduled timer, indeterminate keeps reserved, explicit rejection restores * fix(codex): bind reconciliation to the launcher lifecycle - runSteerReconciliation clears any armed retry timer on entry and never installs a second one, so loop-top and timer-driven passes cannot multiply - shuttingDown is set when the main loop ends: timers are cleared and the pending map is dropped, so an unresolved steer can never respawn an app-server after cleanup (remote-to-local switch included) * fix(cursor): abort releases an in-progress soft-steer wait - The prompt-finally wait races Promise.allSettled against the abort signal: an Abort that clears the waiters now also releases a wait that already started, so the launcher always reaches the next queued prompt * fix(codex): report steered only after app-server acceptance - The handler now awaits steer.completed (the inject-acceptance response): an explicit JSON-RPC rejection surfaces as failed and restores the row for the normal turn/start path instead of a false steered - Transport failure after dispatch reports 'Steer outcome is being reconciled' and keeps the row reserved while the timer-driven thread reconciliation runs - dispatch-failure path also swallows the paired completion rejection * fix(cursor,acp): commit on ACP acceptance; distinguish transport failures - AcpStdioTransport marks transport-level failures (timeout, closed, stdin write) as indeterminate; explicit JSON-RPC error responses are not - The steer handler commits + consumes on completion (ACP acceptance) and restores the row on an explicit rejection; an indeterminate transport failure keeps the row reserved so a delivered instruction is never re-sent, and the ACK reaches the hub even when abort reset the queue - launcher/transport tests updated for the three outcomes * fix(steer): tri-state cancel, clear-safe reservations, bounded acceptance wait - MessageQueue2.cancelByLocalId returns 'in-flight' for a dispatching steer reservation: the hub neither deletes the row nor stamps invoked_at (new CancelMessageResponse 'busy' status; web restores the optimistic row); pushIsolateAndClear and reset/close share cancelReservations so /clear-style commands cannot have a rejected steer resurrect a discarded prompt - turn/steer acceptance wait bounded at 25s (< hub 30s RPC timeout): a lost response is indeterminate and funnels into thread reconciliation instead of stranding the reservation - tests updated for the tri-state cancel contract * test(cursor): match tri-state cancel contract for dispatching steers * fix(cursor): drop duplicate promptInFlight declaration after upstream merge * fix(codex,web): busy-aware edit flow; bound reconciliation reads - QueuedMessagesBar edit flow treats a 'busy' cancel as unsuccessful: it never prefills the composer when the row is inside an async steer, so a second client cannot send a duplicate - reconcileSteerByClientId bounds thread/read with a 5s timeout so a connected-but-silent app-server cannot hold the reservation in-flight indefinitely * fix(steer): inFlight-dominated cancel acks; bounded reconciliation - hub cancel-queued-message acks check inFlight before removed: a stale duplicate socket reporting removed can no longer delete the durable row while another socket is dispatching the steer - reconciliation entries expire after 60s and mark delivered: after the rejection window, a dispatched steer that the app-server never proved (client ids dropped on restart) is committed instead of polling thread/read forever - pre-dispatch failures (abort before write included) never enter reconciliation — they restore the row and report failure * fix(cursor,acp,web): indeterminate close marks, steer gating precision - AcpStdioTransport.rejectAllPending marks close/protocol failures indeterminate, so an accepted-but-close-interrupted soft steer restores nothing (no duplicate delivery) - the abort race in the soft-steer wait removes its listener in finally (no accumulation across repeated waits) - SessionChat gates the Steer button on agentState.steeringActive for codex/cursor instead of the queued-grace thinking flag, so Steer is not exposed before the launcher can accept it - codex pre-dispatch abort never enters reconciliation (merged from #1606) * fix(steer): persist indeterminate outcomes without replay * fix(cursor): hold ambiguous steers for explicit resolution * fix(steer): make ambiguous delivery restart-safe * fix(cursor): make ambiguous delivery restart-safe * fix(steer): recover crash-held rows and preserve retry dedup * fix(steer): ack retries and bound stdin dispatch * fix(cursor): reject steers when prompt generation changes * fix(steer): reconcile indeterminate dispatches and serialize retries * fix(cursor): preserve soft-steer reservations across abort * fix(codex): classify stdin callback failures as indeterminate * fix(steer): recheck indeterminate cancels after ACK * fix(steer): close retry and abort races * fix(cursor): hold ambiguous dispatch failures * fix(steer): serialize live retries and abort admission * fix(steer): distinguish live dispatching from unknown * fix(cursor): bound ACP dispatch acknowledgements * fix(steer): keep ACK failures held and reconcile busy cancel * fix(cursor): preserve state when dispatch ACK is uncertain * fix(steer): distinguish held cancel from removal * fix(store): combine schema v24 migrations * fix(cursor): distinguish held cancel from removal * fix(store): reserve schema v25 for steer delivery state * fix(cursor): suppress late ACP updates after abort * fix(steer): keep held cancel state and notify requeue * fix(cursor): isolate late updates after abort * fix(steer): release explicitly cancelled unknown reservations * test(cursor): cover explicit held cancellation * fix(codex): reject cancelled reservations before native steer * fix(cursor): reject cancelled reservations before ACP steer * fix(codex): make reservation restore atomic with state * fix(cursor): make reservation restore atomic with state * fix(codex): terminate abandoned transport writes * fix(cursor): hard-stop abandoned writes and update native queue state * fix(steer): own abandoned app-server lifecycle and consume races * fix(cursor): isolate aborts and add native retry resolution * fix(codex): confirm dispatch and recover abandoned turns * test(codex): mock abandoned transport callback * fix(native): reconcile retry responses * fix(codex): clear visible turn state on transport loss * fix(steer): claim retries and cover native delivery state * fix(native): resync busy cancel outcomes * fix(native): preserve indeterminate state on Android hydration * fix(steer): make retry claims single-winner * fix(cursor): hold restore failures for explicit resolution * fix(steer): serialize concurrent retry claims * fix(socket): tolerate missing steer-state ACK callbacks * fix(native): serialize retry operations * docs(web): document unknown steer delivery and retry controls * fix(steer): handle retry failures and abort-before-connect * fix(cursor): drain foreground prompt after soft-steer abort * fix(steer): reinitialize after transport loss and finish iOS retry errors * fix(steer): preserve indeterminate rows across reconnect gaps * test(web): mock indeterminate queued recovery state * fix(steer): recover consumed ACK tombstones * fix(steer): expose consumed cancel tombstones * fix(cursor): drain soft steers before handler replacement * fix(cursor): preserve buffered output on abort --- .../agent/backends/acp/AcpMessageHandler.ts | 16 +- .../agent/backends/acp/AcpSdkBackend.test.ts | 243 ++++++++++++++ cli/src/agent/backends/acp/AcpSdkBackend.ts | 145 ++++++++- .../backends/acp/AcpStdioTransport.test.ts | 25 +- .../agent/backends/acp/AcpStdioTransport.ts | 159 +++++++-- .../cursor/cursorAcpRemoteLauncher.test.ts | 305 +++++++++++++++++- cli/src/cursor/cursorAcpRemoteLauncher.ts | 241 +++++++++++++- cli/src/cursor/cursorLegacyRemoteLauncher.ts | 9 + cli/src/cursor/runCursor.test.ts | 1 + cli/src/cursor/runCursor.ts | 1 + hub/src/sync/steerQueuedMessage.test.ts | 2 +- hub/src/sync/syncEngine.ts | 10 +- shared/src/modes.test.ts | 20 +- shared/src/modes.ts | 16 +- web/src/components/SessionChat.tsx | 4 +- 15 files changed, 1139 insertions(+), 58 deletions(-) diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index a08fdb7051..69aaec32c3 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -398,6 +398,7 @@ function getSuffixPrefixOverlap(base: string, next: string): number { export class AcpMessageHandler { private readonly toolCalls = new Map(); + private acceptingUpdates = true; private bufferedText = ''; // Array buffer avoids the O(N²) string concatenation that per-token // ACP streams (OpenCode/Zen emits one chunk per generated token) would @@ -410,13 +411,26 @@ export class AcpMessageHandler { private reasoningSnapshotEmitted = false; private readonly textChunkMode: AcpTextChunkMode; + private readonly onMessage: (message: AgentMessage) => void; + constructor( - private readonly onMessage: (message: AgentMessage) => void, + onMessage: (message: AgentMessage) => void, private readonly options: { textChunkMode?: AcpTextChunkMode; flavor?: string } = {} ) { + this.onMessage = (message) => { + if (this.acceptingUpdates) onMessage(message); + }; this.textChunkMode = this.options.textChunkMode ?? 'dedupe'; } + /** Drop late updates from a cancelled prompt before the next handler exists. */ + deactivate(): void { + this.acceptingUpdates = false; + this.bufferedText = ''; + this.bufferedReasoning = []; + this.resetReasoningState(); + } + /** * Emits any buffered assistant text as a single message and clears the * buffer. Callers must treat this as a text-segment boundary: it is diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index 4d17fa7b4a..4d9036018b 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { AgentMessage } from '@/agent/types'; import { AcpSdkBackend } from './AcpSdkBackend'; +import { AcpMessageHandler } from './AcpMessageHandler'; import { buildAcpStdioSpawnOptions } from './AcpStdioTransport'; import { ACP_SESSION_UPDATE_TYPES } from './constants'; @@ -1142,6 +1143,194 @@ describe('AcpSdkBackend', () => { expect(registered.get('cursor/ask_question')).toBe(handler); }); + it('beginSoftSteerPrompt sends concurrent session/prompt without cancel', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const calls: Array<{ method: string; params: unknown }> = []; + const backendInternal = backend as unknown as { + transport: { + sendRequestWithDispatch: (method: string, params: unknown, options?: { timeoutMs?: number }) => { dispatched: Promise; completed: Promise }; + sendNotification: (method: string, params: unknown) => void; + close: () => Promise; + } | null; + isProcessingMessage: boolean; + }; + backendInternal.isProcessingMessage = true; + backendInternal.transport = { + sendRequestWithDispatch: (method, params) => { + calls.push({ method, params }); + return { dispatched: Promise.resolve(), completed: Promise.resolve({ stopReason: 'end_turn' }) }; + }, + sendNotification: () => {}, + close: async () => {} + }; + + backend.beginSoftSteerPrompt('session-1', [{ type: 'text', text: 'pivot now' }]); + + expect(calls).toEqual([{ + method: 'session/prompt', + params: { + sessionId: 'session-1', + prompt: [{ type: 'text', text: 'pivot now' }] + } + }]); + }); + + it('beginSoftSteerPrompt drains buffered output after completion', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + transport: { + sendRequestWithDispatch: (method: string, params: unknown, options?: { timeoutMs?: number }) => { dispatched: Promise; completed: Promise }; + close: () => Promise; + } | null; + isProcessingMessage: boolean; + waitForSessionUpdateQuiet: (quietMs: number, timeoutMs: number) => Promise; + drainLateBuffers: () => Promise; + messageHandler: { drainBuffers: () => void } | null; + }; + const events: string[] = []; + backendInternal.isProcessingMessage = true; + backendInternal.transport = { + sendRequestWithDispatch: () => { + events.push('request'); + return { dispatched: Promise.resolve(), completed: Promise.resolve({ stopReason: 'end_turn' }) }; + }, + close: async () => {} + }; + backendInternal.waitForSessionUpdateQuiet = async () => { + events.push('quiet'); + }; + backendInternal.messageHandler = { + drainBuffers: () => events.push('drain') + }; + backendInternal.drainLateBuffers = async () => { + events.push('late'); + }; + + await backend.beginSoftSteerPrompt('session-1', [{ type: 'text', text: 'pivot now' }]).completed; + + expect(events).toEqual(['request', 'quiet', 'drain', 'late', 'drain']); + }); + + it('beginSoftSteerPrompt returns a pending promise without blocking the caller', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + let resolvePrompt: ((value: unknown) => void) | null = null; + const backendInternal = backend as unknown as { + transport: { + sendRequestWithDispatch: (method: string, params: unknown, options?: { timeoutMs?: number }) => { dispatched: Promise; completed: Promise }; + sendNotification: (method: string, params: unknown) => void; + close: () => Promise; + } | null; + isProcessingMessage: boolean; + }; + backendInternal.isProcessingMessage = true; + backendInternal.transport = { + sendRequestWithDispatch: () => ({ + dispatched: Promise.resolve(), + completed: new Promise((resolve) => { resolvePrompt = resolve; }) + }), + sendNotification: () => {}, + close: async () => {} + }; + + // Must not hang waiting for the ACP prompt response (hub RPC is 30s). + let settled = false; + const pending = backend.beginSoftSteerPrompt('session-1', [{ type: 'text', text: 'pivot now' }]).completed.then(() => { + settled = true; + }); + expect(resolvePrompt).not.toBeNull(); + expect(settled).toBe(false); + resolvePrompt!({ stopReason: 'end_turn' }); + await pending; + expect(settled).toBe(true); + }); + + it('keeps response completion pending until a concurrent soft steer settles', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + let resolvePrompt: ((value: unknown) => void) | null = null; + const backendInternal = backend as unknown as { + transport: { sendRequestWithDispatch: () => { dispatched: Promise; completed: Promise }; close: () => Promise } | null; + isProcessingMessage: boolean; + activePromptRequests: number; + finishPromptRequest: (epoch: number) => void; + waitForSessionUpdateQuiet: () => Promise; + drainLateBuffers: () => Promise; + }; + backendInternal.isProcessingMessage = true; + backendInternal.activePromptRequests = 1; + backendInternal.transport = { + sendRequestWithDispatch: () => ({ + dispatched: Promise.resolve(), + completed: new Promise((resolve) => { resolvePrompt = resolve; }) + }), + close: async () => {} + }; + backendInternal.waitForSessionUpdateQuiet = async () => {}; + backendInternal.drainLateBuffers = async () => {}; + + const pendingSteer = backend.beginSoftSteerPrompt('session-1', [{ type: 'text', text: 'pivot now' }]); + let responseComplete = false; + const responseWait = backend.waitForResponseComplete().then(() => { responseComplete = true; }); + + backendInternal.finishPromptRequest(0); + await Promise.resolve(); + expect(backend.processingMessage).toBe(true); + expect(responseComplete).toBe(false); + + resolvePrompt!({ stopReason: 'end_turn' }); + await pendingSteer.completed; + await responseWait; + expect(backend.processingMessage).toBe(false); + expect(responseComplete).toBe(true); + }); + + it('softSteerPrompt awaits session/prompt completion', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + let resolvePrompt: ((value: unknown) => void) | null = null; + const backendInternal = backend as unknown as { + transport: { + sendRequest: (method: string, params: unknown, options?: { timeoutMs?: number }) => Promise; + sendNotification: (method: string, params: unknown) => void; + close: () => Promise; + } | null; + isProcessingMessage: boolean; + }; + backendInternal.isProcessingMessage = true; + backendInternal.transport = { + sendRequest: () => new Promise((resolve) => { + resolvePrompt = resolve; + }), + sendNotification: () => {}, + close: async () => {} + }; + + let done = false; + const pending = backend.softSteerPrompt('session-1', [{ type: 'text', text: 'pivot now' }]).then(() => { + done = true; + }); + await Promise.resolve(); + expect(done).toBe(false); + resolvePrompt!({ stopReason: 'end_turn' }); + await pending; + expect(done).toBe(true); + }); + + it('beginSoftSteerPrompt rejects when no prompt is in flight', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + transport: { sendRequest: () => Promise; close: () => Promise } | null; + isProcessingMessage: boolean; + }; + backendInternal.isProcessingMessage = false; + backendInternal.transport = { + sendRequest: async () => null, + close: async () => {} + }; + + expect(() => backend.beginSoftSteerPrompt('session-1', [{ type: 'text', text: 'x' }])) + .toThrow(/No active ACP prompt/); + }); + + it('suppressUpdatesDuring drops session/update notifications that would otherwise leak into the previous turn\'s onUpdate, then restores normal forwarding', async () => { // Reproduces the real /compact duplicate-summary bug: OpenCode keeps // streaming session/update notifications (over the same ACP @@ -1540,3 +1729,57 @@ describe('AcpSdkBackend', () => { await pending; }); }); + +describe('AcpSdkBackend abortSoftSteers', () => { + it('drains buffered foreground output before suppressing late updates', async () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const updates: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => updates.push(message)); + const backendInternal = backend as unknown as { + messageHandler: AcpMessageHandler | null; + }; + + await handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentMessageChunk, + content: { type: 'text', text: 'partial answer' } + }); + await handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.agentThoughtChunk, + content: { type: 'text', text: 'partial thought' } + }); + backendInternal.messageHandler = handler; + + backend.abortSoftSteers(); + + expect(updates).toEqual([ + { type: 'reasoning', text: 'partial thought' }, + { type: 'text', text: 'partial answer' } + ]); + }); + + it('releases processingMessage without waiting for the concurrent prompt', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + isProcessingMessage: boolean; + activePromptRequests: number; + }; + backendInternal.isProcessingMessage = true; + backendInternal.activePromptRequests = 2; + + backend.abortSoftSteers(); + + expect(backend.processingMessage).toBe(false); + expect(backendInternal.activePromptRequests).toBe(0); + }); + + it('is a no-op when nothing is in flight', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const backendInternal = backend as unknown as { + activePromptRequests: number; + }; + backendInternal.activePromptRequests = 0; + + expect(() => backend.abortSoftSteers()).not.toThrow(); + expect(backend.processingMessage).toBe(false); + }); +}); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index f686a9ddff..dca431371f 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -78,6 +78,14 @@ export class AcpSdkBackend implements AgentBackend { private setModeSupported: boolean | undefined = undefined; private isProcessingMessage = false; private promptRequestInFlight = false; + /** Concurrent session/prompt requests (main prompt + soft steers). */ + private activePromptRequests = 0; + /** Foreground prompt only; soft steers are excluded after Abort. */ + private foregroundPromptRequests = 0; + /** Bumped by abortSoftSteers; stale finishes from cancelled requests are dropped. */ + private promptRequestEpoch = 0; + /** Incremented for each foreground prompt turn, including retry-wrapped turns. */ + private promptGeneration = 0; private responseCompleteResolvers: Array<() => void> = []; private lastSessionUpdateAt = 0; private latestUsageUpdate: AcpUsageUpdate | null = null; @@ -557,7 +565,9 @@ export class AcpSdkBackend implements AgentBackend { textChunkMode: this.options.textChunkMode, flavor: this.options.flavor, }); - this.isProcessingMessage = true; + this.promptGeneration++; + this.foregroundPromptRequests++; + const promptRequestEpoch = this.beginPromptRequest(); this.lastSessionUpdateAt = Date.now(); this.latestUsageUpdate = null; this.lastForwardedUsageUpdate = null; @@ -635,8 +645,14 @@ export class AcpSdkBackend implements AgentBackend { } } finally { this.promptUsageCallback = null; - this.isProcessingMessage = false; - this.notifyResponseComplete(); + this.foregroundPromptRequests = Math.max(0, this.foregroundPromptRequests - 1); + if (promptRequestEpoch !== this.promptRequestEpoch) { + this.activePromptRequests = Math.max(0, this.activePromptRequests - 1); + this.isProcessingMessage = this.activePromptRequests > 0; + if (!this.isProcessingMessage) this.notifyResponseComplete(); + } else { + this.finishPromptRequest(promptRequestEpoch); + } } } } @@ -649,6 +665,83 @@ export class AcpSdkBackend implements AgentBackend { this.transport.sendNotification('session/cancel', { sessionId }); } + /** + * Soft-inject a follow-up `session/prompt` while another prompt is in flight. + * + * Used for Cursor mid-turn steer (GUI "Send" / next-opportune soft send). + * Does **not** cancel the active prompt and does **not** swap message handlers — + * `session/update` notifications keep flowing to the in-flight turn's handler. + * + * Awaits the full concurrent `session/prompt` JSON-RPC response (turn completion + * for that inject). Do **not** call this from the hub `SteerQueuedMessage` handler — + * that RPC uses a 30s Socket.IO timeout. Use {@link beginSoftSteerPrompt} there. + */ + async softSteerPrompt(sessionId: string, content: PromptContent[]): Promise { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + if (!this.isProcessingMessage) { + throw new Error('No active ACP prompt to soft-steer into'); + } + + const promptRequestEpoch = this.beginPromptRequest(); + try { + await this.transport.sendRequest('session/prompt', { + sessionId, + prompt: content + }, { timeoutMs: Infinity }); + } finally { + this.finishPromptRequest(promptRequestEpoch); + } + } + + /** + * Kick off a soft steer without blocking the hub RPC on turn completion. + * Separates transport dispatch from prompt completion so callers can commit + * queue state only after stdin accepted the request without waiting for the turn. + */ + beginSoftSteerPrompt(sessionId: string, content: PromptContent[]): { + dispatched: Promise; + completed: Promise; + } { + if (!this.transport) { + throw new Error('ACP transport not initialized'); + } + if (!this.isProcessingMessage) { + throw new Error('No active ACP prompt to soft-steer into'); + } + + const transport = this.transport; + const promptRequestEpoch = this.beginPromptRequest(); + const request = transport.sendRequestWithDispatch('session/prompt', { + sessionId, + prompt: content + }, { timeoutMs: Infinity, dispatchTimeoutMs: 20_000 }); + const completed = (async () => { + try { + await request.completed; + } finally { + try { + await this.waitForSessionUpdateQuiet( + AcpSdkBackend.UPDATE_QUIET_PERIOD_MS, + AcpSdkBackend.UPDATE_DRAIN_TIMEOUT_MS + ); + this.messageHandler?.drainBuffers(); + await this.drainLateBuffers(); + this.messageHandler?.drainBuffers(); + } finally { + this.finishPromptRequest(promptRequestEpoch); + } + } + })(); + + void completed.catch((error) => { + logger.warn('[ACP] soft-steer session/prompt failed', error); + }); + + return { dispatched: request.dispatched, completed }; + } + async respondToPermission( _sessionId: string, request: PermissionRequest, @@ -750,13 +843,17 @@ export class AcpSdkBackend implements AgentBackend { * Useful for checking if it's safe to perform session operations. */ get processingMessage(): boolean { - return this.isProcessingMessage; + return this.activePromptRequests > 0; } isPromptRequestInFlight(): boolean { return this.promptRequestInFlight; } + getPromptGeneration(): number { + return this.promptGeneration; + } + getLastSessionUpdateAt(): number { return this.lastSessionUpdateAt; } @@ -768,7 +865,7 @@ export class AcpSdkBackend implements AgentBackend { * like session swap or sending task_complete. */ async waitForResponseComplete(): Promise { - if (!this.isProcessingMessage) { + if (this.activePromptRequests === 0) { return; } return new Promise((resolve) => { @@ -787,6 +884,8 @@ export class AcpSdkBackend implements AgentBackend { this.messageHandler?.drainBuffers(); this.messageHandler = null; this.activeSessionId = null; + this.activePromptRequests = 0; + this.foregroundPromptRequests = 0; this.isProcessingMessage = false; this.sessionModelsMetadata.clear(); this.initialAvailableCommands.clear(); @@ -1067,6 +1166,42 @@ export class AcpSdkBackend implements AgentBackend { return await responsePromise; } + private beginPromptRequest(): number { + this.activePromptRequests++; + this.isProcessingMessage = true; + return this.promptRequestEpoch; + } + + /** + * Force-settle soft-steer bookkeeping without waiting for the concurrent + * `session/prompt` to finish. Called on abort: the in-flight turn is + * cancelled anyway, so a pending soft steer may never complete; dropping + * its counter keeps {@link waitForResponseComplete} from blocking the next + * turn. Bumps the epoch so a stale finish from a cancelled request cannot + * decrement a newer prompt's counter. + */ + abortSoftSteers(): void { + this.messageHandler?.drainBuffers(); + this.messageHandler?.deactivate?.(); + this.promptRequestEpoch++; + this.activePromptRequests = this.foregroundPromptRequests; + this.isProcessingMessage = this.activePromptRequests > 0; + if (!this.isProcessingMessage) { + this.notifyResponseComplete(); + } + } + + private finishPromptRequest(epoch: number): void { + if (epoch !== this.promptRequestEpoch) { + return; + } + this.activePromptRequests = Math.max(0, this.activePromptRequests - 1); + this.isProcessingMessage = this.activePromptRequests > 0; + if (!this.isProcessingMessage) { + this.notifyResponseComplete(); + } + } + private notifyResponseComplete(): void { const resolvers = this.responseCompleteResolvers; this.responseCompleteResolvers = []; diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts index 33a94d5486..5013618ad7 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.test.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.test.ts @@ -91,7 +91,7 @@ vi.mock('node:child_process', () => ({ }) })); -import { AcpStdioTransport } from './AcpStdioTransport'; +import { AcpStdioTransport, isAcpIndeterminateError } from './AcpStdioTransport'; import { killProcessByChildProcess } from '@/utils/process'; function emitStdout(chunk: string): void { @@ -594,6 +594,29 @@ describe('AcpStdioTransport closed stdin writes', () => { expect(buffer.length).toBeLessThanOrEqual(8_000); }); + test('bounds a stalled stdin dispatch and rejects both promises', async () => { + vi.useFakeTimers(); + try { + const transport = await AcpStdioTransport.create({ command: 'gemini' }); + const request = transport.sendRequestWithDispatch('session/prompt', {}, { + timeoutMs: Infinity, + dispatchTimeoutMs: 10 + }); + let dispatchError: unknown; + let completedError: unknown; + const dispatch = request.dispatched.catch((error) => { dispatchError = error; }); + const completed = request.completed.catch((error) => { completedError = error; }); + await vi.advanceTimersByTimeAsync(10); + await dispatch; + await completed; + expect(isAcpIndeterminateError(dispatchError)).toBe(true); + expect(isAcpIndeterminateError(completedError)).toBe(true); + await transport.close(); + } finally { + vi.useRealTimers(); + } + }); + test('rejects pending requests when stdin.write throws', async () => { spawnState.stdinWrite.mockImplementation(() => { throw new Error('WritableIterable is closed'); diff --git a/cli/src/agent/backends/acp/AcpStdioTransport.ts b/cli/src/agent/backends/acp/AcpStdioTransport.ts index d5bee14cdf..e94cf84069 100644 --- a/cli/src/agent/backends/acp/AcpStdioTransport.ts +++ b/cli/src/agent/backends/acp/AcpStdioTransport.ts @@ -16,6 +16,20 @@ import { } from './agentCliGuard'; import { matchesAcpHttp2Cancel, matchesAcpRetryBackoff } from './acpStderrErrors'; +/** Marks transport-level failures whose request outcome is unknown (unlike an + * explicit JSON-RPC error response). */ +export const ACP_INDETERMINATE_SYMBOL = Symbol('acp-indeterminate'); + +function markAcpIndeterminate(error: Error): Error { + Object.defineProperty(error, ACP_INDETERMINATE_SYMBOL, { value: true }); + return error; +} + +export function isAcpIndeterminateError(error: unknown): boolean { + return typeof error === 'object' && error !== null + && (error as Record)[ACP_INDETERMINATE_SYMBOL] === true; +} + interface JsonRpcRequest { jsonrpc: '2.0'; id: string | number | null; @@ -68,6 +82,7 @@ export class AcpStdioTransport { private readonly pending = new Map void; reject: (error: Error) => void; + rejectDispatched: (error: Error) => void; }>(); private readonly requestHandlers = new Map(); private notificationHandler: ((method: string, params: unknown) => void) | null = null; @@ -243,11 +258,25 @@ export class AcpStdioTransport { /** Default timeout for requests in milliseconds (2 minutes) */ static readonly DEFAULT_TIMEOUT_MS = 120_000; - async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number }): Promise { + async sendRequest(method: string, params?: unknown, options?: { timeoutMs?: number; dispatchTimeoutMs?: number }): Promise { + const request = this.sendRequestWithDispatch(method, params, options); + void request.dispatched.catch(() => {}); + return request.completed; + } + + /** + * Split a request into transport dispatch (stdin accepted) and completion + * (JSON-RPC response). Lets callers commit state once stdin accepted the + * request without waiting for the (possibly long-running) response. + */ + sendRequestWithDispatch( + method: string, + params?: unknown, + options?: { timeoutMs?: number; dispatchTimeoutMs?: number } + ): { dispatched: Promise; completed: Promise } { if (this.closed || this.exited) { - return Promise.reject( - this.closeError ?? this.exitError ?? new Error('ACP transport is closed') - ); + const error = markAcpIndeterminate(this.closeError ?? this.exitError ?? new Error('ACP transport is closed')); + return { dispatched: Promise.reject(error), completed: Promise.reject(error) }; } const id = this.nextId++; @@ -259,37 +288,107 @@ export class AcpStdioTransport { }; const timeoutMs = options?.timeoutMs ?? AcpStdioTransport.DEFAULT_TIMEOUT_MS; + const dispatchTimeoutMs = options?.dispatchTimeoutMs ?? timeoutMs; + + let timer: ReturnType | null = null; + let dispatchTimer: ReturnType | null = null; + let resolveDispatched!: () => void; + let rejectDispatched!: (error: Error) => void; + let resolveCompleted!: (value: unknown) => void; + let rejectCompleted!: (error: Error) => void; + const dispatched = new Promise((resolve, reject) => { + resolveDispatched = resolve; + rejectDispatched = reject; + }); + const completed = new Promise((resolve, reject) => { + resolveCompleted = resolve; + rejectCompleted = reject; + }); + let dispatchSettled = false; - // Skip timeout for infinite/no-timeout requests (e.g., long-running prompts) - if (!Number.isFinite(timeoutMs)) { - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); - this.writePayload(payload); - }); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const clearTimers = () => { + if (timer) clearTimeout(timer); + if (dispatchTimer) clearTimeout(dispatchTimer); + }; + const failRequest = (error: Error) => { + this.pending.delete(id); + clearTimers(); + if (!dispatchSettled) { + dispatchSettled = true; + rejectDispatched(error); + } + rejectCompleted(error); + }; + if (Number.isFinite(timeoutMs)) { + timer = setTimeout(() => { if (this.pending.has(id)) { - this.pending.delete(id); - reject(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`)); + failRequest(markAcpIndeterminate(new Error(`ACP request '${method}' timed out after ${timeoutMs}ms`))); } }, timeoutMs); - // Don't let timer keep Node alive if process wants to exit timer.unref(); + } + if (Number.isFinite(dispatchTimeoutMs)) { + dispatchTimer = setTimeout(() => { + if (this.pending.has(id) && !dispatchSettled) { + const error = markAcpIndeterminate(new Error(`ACP request '${method}' dispatch timed out after ${dispatchTimeoutMs}ms`)); + try { + this.process.stdin.destroy(); + } catch (destroyError) { + logger.debug('[ACP] Error destroying stalled stdin', destroyError); + } + this.markClosed(error); + } + }, dispatchTimeoutMs); + dispatchTimer.unref(); + } - this.pending.set(id, { - resolve: (value) => { - clearTimeout(timer); - resolve(value); - }, - reject: (error) => { - clearTimeout(timer); - reject(error); + this.pending.set(id, { + resolve: (value) => { + clearTimers(); + if (!dispatchSettled) { + dispatchSettled = true; + resolveDispatched(); } - }); - this.writePayload(payload); + resolveCompleted(value); + }, + reject: (error) => { + clearTimers(); + if (!dispatchSettled) { + dispatchSettled = true; + resolveDispatched(); + } + rejectCompleted(error); + }, + rejectDispatched: (error) => { + if (!dispatchSettled) { + dispatchSettled = true; + rejectDispatched(error); + } + } }); + + try { + const serialized = JSON.stringify(payload); + this.process.stdin.write(`${serialized}\n`, (error) => { + if (error) { + const writeError = markAcpIndeterminate(error instanceof Error ? error : new Error(String(error))); + this.markClosed(writeError); + failRequest(writeError); + return; + } + if (!dispatchSettled) { + dispatchSettled = true; + if (dispatchTimer) clearTimeout(dispatchTimer); + resolveDispatched(); + } + }); + } catch (error) { + const writeError = error instanceof Error ? error : new Error(String(error)); + this.markClosed(writeError); + failRequest(writeError); + } + + return { dispatched, completed }; } sendNotification(method: string, params?: unknown): void { @@ -475,8 +574,10 @@ export class AcpStdioTransport { } private rejectAllPending(error: Error): void { - for (const { reject } of this.pending.values()) { - reject(error); + const indeterminate = markAcpIndeterminate(error); + for (const { reject, rejectDispatched } of this.pending.values()) { + rejectDispatched(indeterminate); + reject(indeterminate); } this.pending.clear(); } diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index ba86989691..010b4bd04a 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import type { EnhancedMode } from './loop'; import type { AgentMessage } from '@/agent/types'; +import { ACP_INDETERMINATE_SYMBOL } from '@/agent/backends/acp/AcpStdioTransport'; const harness = vi.hoisted(() => ({ initializeError: null as Error | null, @@ -15,11 +16,14 @@ const harness = vi.hoisted(() => ({ newSessionAttempts: 0, promptCalls: 0, prompts: [] as unknown[][], + deferPrompt: null as Promise | null, + deferSoftSteer: null as Promise | null, + softSteerDispatchError: null as Error | null, + deferSoftSteerDispatch: null as Promise | null, promptErrors: [] as Error[], promptMessages: [] as AgentMessage[], promptMessageBatches: [] as AgentMessage[][], promptStderrErrors: [] as Array<{ type: string; message: string; raw: string }>, - deferPrompt: null as Promise | null, releasePrompt: null as (() => void) | null, backendArgs: null as { command: string; args?: string[] } | null, setConfigOptionCalls: [] as Array<{ sessionId: string; configId: string; value: string }>, @@ -145,6 +149,16 @@ vi.mock('./utils/cursorAcpBackend', () => ({ if (error) throw error; }), cancelPrompt: vi.fn(async () => {}), + getPromptGeneration: vi.fn(() => 1), + beginSoftSteerPrompt: vi.fn(() => ({ + dispatched: harness.softSteerDispatchError + ? Promise.reject(harness.softSteerDispatchError) + : (harness.deferSoftSteerDispatch ?? Promise.resolve()), + completed: harness.deferSoftSteer ?? Promise.resolve() + })), + softSteerPrompt: vi.fn(async () => {}), + abortSoftSteers: vi.fn(), + waitForResponseComplete: vi.fn(async () => {}), respondToPermission: vi.fn(async () => {}), onStderrError: vi.fn((handler) => { harness.stderrErrorHandler = handler ?? null; @@ -206,6 +220,7 @@ vi.mock('@/ui/logger', () => ({ import { classifyCursorAcpLoadError, cursorAcpRemoteLauncher } from './cursorAcpRemoteLauncher'; import { createCursorAcpBackend } from './utils/cursorAcpBackend'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { CursorSession } from './session'; import { ApiSessionClient } from '@/api/apiSession'; import { @@ -213,7 +228,7 @@ import { writeSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; -function makeSession(sessionId: string | null): CursorSession { +function makeSession(sessionId: string | null, closeQueue = true): CursorSession { const queue = new MessageQueue2(() => 'mode'); const client = makeClient(); @@ -232,22 +247,31 @@ function makeSession(sessionId: string | null): CursorSession { }); session.onSessionFoundWithProtocol = vi.fn(); - queue.close(); + if (closeQueue) { + queue.close(); + } return session; } function makeClient() { + const handlers = new Map Promise>(); return { sessionId: 'test-session-id', rpcHandlerManager: { - registerHandler: vi.fn(), + handlers, + registerHandler: vi.fn((method: string, handler: (payload?: unknown) => Promise) => { + handlers.set(method, handler); + }), unregisterHandler: vi.fn() }, updateMetadata: vi.fn(), flushMetadata: vi.fn(async () => true), sendSessionEvent: vi.fn(), sendAgentMessage: vi.fn(), + emitMessagesConsumed: vi.fn(), + emitSteerIndeterminate: vi.fn(), + setSteerDeliveryState: vi.fn(async () => true), sendClaudeSessionMessage: vi.fn(), keepAlive: vi.fn(), emitSessionReady: vi.fn() @@ -267,11 +291,14 @@ describe('cursorAcpRemoteLauncher', () => { harness.newSessionAttempts = 0; harness.promptCalls = 0; harness.prompts = []; + harness.deferPrompt = null; + harness.deferSoftSteer = null; + harness.softSteerDispatchError = null; + harness.deferSoftSteerDispatch = null; harness.promptErrors = []; harness.promptMessages = []; harness.promptMessageBatches = []; harness.promptStderrErrors = []; - harness.deferPrompt = null; harness.releasePrompt = null; harness.setConfigOptionCalls = []; harness.deferSetConfigOption = null; @@ -292,6 +319,274 @@ describe('cursorAcpRemoteLauncher', () => { _resetSharedCursorModelsCacheForTests(); }); + it('ends the launcher when a soft steer outlives an abort', async () => { + let releasePrompt!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + // Soft-steer completion never settles — simulates Cursor keeping the + // concurrent request open after an ordinary Abort. + harness.deferSoftSteer = new Promise(() => {}); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + await handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }); + harness.deferPrompt = null; + releasePrompt(); + await handlers.get(RPC_METHODS.Abort)!(); + + // The old soft steer never settled, so the launcher must not install + // another prompt handler over it; the bounded drain ends the session. + expect(harness.promptCalls).toBe(1); + session.queue.close(); + await runPromise; + }, 10_000); + + it('restores a queued steer when ACP dispatch fails', async () => { + let releasePrompt!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + harness.softSteerDispatchError = new Error('stdin closed'); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' })) + .resolves.toEqual({ steered: false, error: 'Failed to soft-steer into active turn' }); + expect(session.client.emitMessagesConsumed).not.toHaveBeenCalledWith(['steer'], { steered: true }); + + harness.softSteerDispatchError = null; + harness.deferPrompt = null; + releasePrompt(); + session.queue.close(); + await runPromise; + }); + + it('restores the row when ACP explicitly rejects after dispatch', async () => { + let releasePrompt!: () => void; + let rejectSoftSteer!: (error: Error) => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; }); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' })) + .resolves.toEqual({ steered: true }); + // An explicit JSON-RPC rejection means ACP never accepted the + // instruction — the row is restored for the next prompt (no data loss). + rejectSoftSteer(new Error('request rejected')); + await vi.waitFor(() => expect(session.queue.cancelByLocalId('steer')).toBe(true)); + expect(session.client.emitMessagesConsumed).not.toHaveBeenCalledWith(['steer'], { steered: true }); + + harness.deferPrompt = null; + releasePrompt(); + session.queue.close(); + await runPromise; + }); + + it('holds a transport-ambiguous steer for explicit retry or cancel', async () => { + let releasePrompt!: () => void; + let rejectSoftSteer!: (error: Error) => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + harness.deferSoftSteer = new Promise((_, reject) => { rejectSoftSteer = reject; }); + const indeterminate = new Error('ACP transport closed'); + Object.defineProperty(indeterminate, ACP_INDETERMINATE_SYMBOL, { value: true }); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' })) + .resolves.toEqual({ steered: true }); + rejectSoftSteer(indeterminate); + await vi.waitFor(() => expect(session.queue.cancelByLocalId('steer')).toBe(true)); + expect(session.client.emitSteerIndeterminate).toHaveBeenCalledWith(['steer']); + expect(session.client.emitMessagesConsumed).not.toHaveBeenCalledWith(['steer'], { steered: true }); + + harness.deferPrompt = null; + releasePrompt(); + session.queue.close(); + await runPromise; + }); + + it('holds an indeterminate dispatch failure instead of restoring it', async () => { + let releasePrompt!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + const indeterminate = new Error('ACP write callback failed'); + Object.defineProperty(indeterminate, ACP_INDETERMINATE_SYMBOL, { value: true }); + harness.softSteerDispatchError = indeterminate; + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' })) + .resolves.toEqual({ steered: false, error: 'Steer outcome is being reconciled' }); + expect(session.client.emitSteerIndeterminate).toHaveBeenCalledWith(['steer']); + expect(session.queue.cancelByLocalId('steer')).toBe(true); + + harness.deferPrompt = null; + releasePrompt(); + session.queue.close(); + await runPromise; + }); + + it('prevents cancellation once ACP steer dispatch starts', async () => { + let releasePrompt!: () => void; + let releaseDispatch!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; }); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + const steerResult = handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }); + await Promise.resolve(); + expect(session.queue.cancelByLocalId('steer')).toBe('in-flight'); + releaseDispatch(); + await expect(steerResult).resolves.toEqual({ steered: true }); + expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['steer'], { steered: true }); + + harness.deferPrompt = null; + releasePrompt(); + session.queue.close(); + await runPromise; + }); + + it('blocks the next prompt while soft-steer dispatch is pending', async () => { + let releasePrompt!: () => void; + let releaseDispatch!: () => void; + let releaseSoftSteer!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; }); + harness.deferSoftSteer = new Promise((resolve) => { releaseSoftSteer = resolve; }); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + const steerResult = handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }); + await Promise.resolve(); + session.queue.push('next', mode, 'next'); + harness.deferPrompt = null; + releasePrompt(); + await Promise.resolve(); + expect(harness.promptCalls).toBe(1); + + releaseDispatch(); + await expect(steerResult).resolves.toEqual({ steered: true }); + expect(harness.promptCalls).toBe(1); + releaseSoftSteer(); + await vi.waitFor(() => expect(harness.promptCalls).toBe(2)); + session.queue.close(); + await runPromise; + }); + + it('acknowledges a steer dispatched before an overlapping abort', async () => { + let releasePrompt!: () => void; + let releaseDispatch!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + harness.deferSoftSteerDispatch = new Promise((resolve) => { releaseDispatch = resolve; }); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + const steerResult = handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' }); + await Promise.resolve(); + releaseDispatch(); + await expect(steerResult).resolves.toEqual({ steered: true }); + await handlers.get(RPC_METHODS.Abort)!(); + + await vi.waitFor(() => expect(session.client.emitMessagesConsumed).toHaveBeenCalledWith(['steer'], { steered: true })); + expect(vi.mocked(session.client.emitMessagesConsumed).mock.calls + .filter(([ids]) => ids.includes('steer'))).toHaveLength(1); + + harness.deferPrompt = null; + releasePrompt(); + session.queue.close(); + await runPromise; + }); + + it('does not hang teardown while a soft steer completion is unresolved', async () => { + let releasePrompt!: () => void; + harness.deferPrompt = new Promise((resolve) => { releasePrompt = resolve; }); + // Completion never resolves — simulates Cursor keeping the concurrent + // request open past Exit/Switch. + harness.deferSoftSteer = new Promise(() => {}); + const session = makeSession(null, false); + const mode = { permissionMode: 'default' } as EnhancedMode; + session.queue.push('first', mode, 'first'); + + const runPromise = cursorAcpRemoteLauncher(session); + await vi.waitFor(() => expect(harness.promptCalls).toBe(1)); + const handlers = (session.client as unknown as { + rpcHandlerManager: { handlers: Map Promise> }; + }).rpcHandlerManager.handlers; + + session.queue.push('soft steer', mode, 'steer'); + await expect(handlers.get(RPC_METHODS.SteerQueuedMessage)!({ localId: 'steer' })) + .resolves.toEqual({ steered: true }); + + // Exit/Switch must reach cleanup (which disconnects the transport and + // rejects pending ACP requests) without waiting on the soft steer. + harness.deferPrompt = null; + releasePrompt(); + await handlers.get(RPC_METHODS.Switch)!(); + await vi.waitFor(() => expect(runPromise).resolves.toBeDefined()); + }); + it('spawns agent acp backend, not stream-json', async () => { const session = makeSession(null); await cursorAcpRemoteLauncher(session); diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index cfa3539d14..5130a17c6c 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -34,7 +34,9 @@ import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/c import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; import type { AcpStderrError } from '@/agent/backends/acp/AcpStdioTransport'; +import { isAcpIndeterminateError } from '@/agent/backends/acp/AcpStdioTransport'; import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { cursorHapiMcpServerId, installCursorMcpOverlay, @@ -50,6 +52,8 @@ import { stripRetryableCursorError } from './cursorAutoRetry'; +const CURSOR_ABORT_DRAIN_TIMEOUT_MS = 5_000; + class CursorAcpRemoteLauncher extends RemoteLauncherBase { private readonly session: CursorSession; private backend: ReturnType | null = null; @@ -63,6 +67,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private defaultBackendModel: string | null = null; private unregisterModelApplyHandler: (() => void) | null = null; private modelApplySeq = 0; + private activePromptModeHash: string | null = null; + /** True while a backend.prompt turn is in flight. */ + private promptInFlight = false; + /** Concurrent soft-steer session/prompt RPCs still running after kickoff. */ + private softSteerWaiters: Promise[] = []; /** True when ACP process was spawned with `--auto-review`. */ private spawnedWithAutoReview = false; /** Avoid re-queueing `/auto-review` on every mid-session mode sync. */ @@ -72,7 +81,6 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private pendingRetryableFromStderr = false; private pendingInlineRetryableError = false; private attemptProducedToolActivity = false; - private promptInFlight = false; private userAbortRequested = false; constructor(session: CursorSession) { @@ -94,6 +102,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { protected async runMainLoop(): Promise { const session = this.session; const messageBuffer = this.messageBuffer; + session.client.updateAgentState?.((state) => ({ ...state, steeringActive: false })); const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { enableChangeTitle: false, @@ -414,13 +423,165 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { onSwitch: () => this.handleSwitchRequest() }); + // Soft steer = Cursor GUI "Send" (next-opportune / soft inject): fire a + // concurrent session/prompt without canceling the in-flight turn. Abort + // remains the hard stop path (GUI "Stop & send"). + session.client.rpcHandlerManager.registerHandler( + RPC_METHODS.SteerQueuedMessage, + async (payload: unknown) => { + const localId = typeof (payload as { localId?: unknown } | null)?.localId === 'string' + ? (payload as { localId: string }).localId + : ''; + if (!localId) { + return { steered: false, error: 'Missing localId' }; + } + const backend = this.backend; + const acpSessionId = this.acpSessionId; + if (!this.promptInFlight || !acpSessionId || !backend) { + return { steered: false, error: 'No active steerable turn' }; + } + const targetPromptGeneration = backend.getPromptGeneration(); + const taken = session.queue.takeByLocalId(localId); + if (!taken) { + return { steered: false, error: 'Message not in queue' }; + } + const isControlCommand = Boolean(taken.item.isolate) + || parseCursorSpecialCommand(taken.item.message).type !== null; + if (isControlCommand) { + session.queue.restoreReservation(taken); + return { steered: false, error: 'Control commands cannot be steered' }; + } + if (this.activePromptModeHash !== taken.item.modeHash) { + session.queue.restoreReservation(taken); + return { steered: false, error: 'Queued message mode differs from the active turn' }; + } + + // Ack the hub once the soft-steer request is kicked off — not when + // the concurrent session/prompt finishes. ACP treats that response as + // turn completion, which can exceed the hub's 30s Socket.IO RPC timeout + // and report a false failure after the inject already started. + // Keep the launcher busy until that background prompt settles so we + // do not emit ready / start the next backend.prompt() while it runs. + if (!session.queue.beginReservationDispatch(taken)) { + return { steered: false, error: 'Steer cancelled' }; + } + const dispatchStatePersisted = await session.client.setSteerDeliveryState([localId], 'dispatching'); + if (!dispatchStatePersisted) { + session.queue.markReservationIndeterminate(taken); + session.client.emitSteerIndeterminate([localId]); + return { steered: false, error: 'Steer state is indeterminate' }; + } + const restoreQueuedReservation = async (): Promise => { + if (!taken.originIndeterminate) { + const persisted = await session.client.setSteerDeliveryState([localId], 'queued'); + if (!persisted) { + session.queue.markReservationIndeterminate(taken); + session.client.emitSteerIndeterminate([localId]); + return false; + } + } + if (taken.state !== 'dispatching' || !session.queue.restoreReservation(taken)) { + session.client.emitSteerIndeterminate([localId]); + return false; + } + return true; + }; + if (taken.state !== 'dispatching') { + session.client.emitSteerIndeterminate([localId]); + return { steered: false, error: 'Steer cancelled' }; + } + if (!this.promptInFlight + || this.backend !== backend + || this.acpSessionId !== acpSessionId + || backend.getPromptGeneration() !== targetPromptGeneration) { + await restoreQueuedReservation(); + return { steered: false, error: 'Active turn changed' }; + } + let steer: { dispatched: Promise; completed: Promise }; + try { + steer = backend.beginSoftSteerPrompt(acpSessionId, [{ + type: 'text', + text: taken.item.message + }]); + } catch (error) { + if (isAcpIndeterminateError(error)) { + if (session.queue.markReservationIndeterminate(taken)) { + session.client.emitSteerIndeterminate([localId]); + } + logger.debug('[cursor-acp] soft-steer dispatch outcome unknown', error); + return { steered: false, error: 'Steer outcome is being reconciled' }; + } + logger.debug('[cursor-acp] soft-steer failed to start', error); + await restoreQueuedReservation(); + return { steered: false, error: 'Failed to soft-steer into active turn' }; + } + // Completion still gates the next prompt (handler swap safety); + // register the waiter before awaiting dispatch so the main loop's + // finally cannot slip a prompt in between. + const steerDone = Promise.all([steer.dispatched, steer.completed]).then(() => {}, (error) => { + logger.debug('[cursor-acp] soft-steer completion failed after dispatch', error); + }); + this.softSteerWaiters.push(steerDone); + const removeWaiter = () => { + this.softSteerWaiters = this.softSteerWaiters.filter((p) => p !== steerDone); + }; + void steerDone.then(removeWaiter); + try { + await steer.dispatched; + } catch (error) { + if (isAcpIndeterminateError(error)) { + if (session.queue.markReservationIndeterminate(taken)) { + session.client.emitSteerIndeterminate([localId]); + } + logger.debug('[cursor-acp] soft-steer dispatch outcome unknown', error); + return { steered: false, error: 'Steer outcome is being reconciled' }; + } + await restoreQueuedReservation(); + logger.debug('[cursor-acp] soft-steer failed to start', error); + return { steered: false, error: 'Failed to soft-steer into active turn' }; + } + // The RPC acks once stdin accepted the inject. The queue row is + // committed only when the concurrent prompt settles: an explicit + // JSON-RPC rejection means ACP never accepted the instruction + // (restore it for the next prompt), while a transport failure + // (abort/disconnect) keeps the row reserved — never re-delivered. + void steer.completed.then(() => { + // Completion means ACP accepted the inject: the ACK must + // reach the hub even when an abort reset the queue and + // cancelled the reservation in between. + session.queue.commitReservation(taken); + messageBuffer.addMessage(taken.item.message, 'user'); + session.client.emitMessagesConsumed([localId], { steered: true }); + }, (error) => { + if (isAcpIndeterminateError(error)) { + // Do not leave the reservation dispatching forever. Hold + // it outside automatic replay and persist the ambiguous + // outcome; a later explicit Steer retries this same row. + if (session.queue.markReservationIndeterminate(taken)) { + session.client.emitSteerIndeterminate([localId]); + } + logger.debug('[cursor-acp] soft-steer outcome unknown after dispatch; row held for explicit resolution', error); + return; + } + void restoreQueuedReservation().then((restored) => { + if (restored) { + logger.debug('[cursor-acp] soft-steer rejected by ACP; row restored', error); + } + }); + }); + return { steered: true }; + } + ); + const sendReady = () => { session.sendSessionEvent({ type: 'ready' }); }; + try { while (!this.shouldExit) { const waitSignal = this.abortController.signal; const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { if (waitSignal.aborted && !this.shouldExit) { continue; @@ -463,6 +624,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { }]; session.onThinkingChange(true); + this.promptInFlight = true; + session.client.updateAgentState?.((state) => ({ ...state, steeringActive: true })); + this.activePromptModeHash = batch.hash; try { this.promptInFlight = true; @@ -508,6 +672,31 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } } finally { this.promptInFlight = false; + session.client.updateAgentState?.((state) => ({ ...state, steeringActive: false })); + // Soft-steers share the ACP session; wait for them before ready / + // the next prompt so message handlers are not swapped mid-inject. + // An Abort (which clears the waiters) must release this wait too: + // race the settle against the abort signal so the launcher never + // blocks on a soft steer whose completion is unbounded. + if (this.softSteerWaiters.length > 0 && !this.shouldExit) { + const waitSignal = this.abortController.signal; + let releaseWait!: () => void; + const abortListener = () => releaseWait(); + if (!waitSignal.aborted) { + waitSignal.addEventListener('abort', abortListener, { once: true }); + } + try { + await Promise.race([ + Promise.allSettled([...this.softSteerWaiters]), + new Promise((resolve) => { releaseWait = resolve; }) + ]); + } finally { + // Repeated waits must not accumulate abort listeners. + waitSignal.removeEventListener('abort', abortListener); + } + this.softSteerWaiters = []; + } + this.activePromptModeHash = null; this.pendingRetryableError = null; this.pendingRetryableFromStderr = false; this.pendingInlineRetryableError = false; @@ -520,6 +709,11 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { } } } + } finally { + // No wait here: Exit/Switch must reach cleanup() promptly; it + // disconnects the ACP transport, rejecting pending soft-steer + // requests and settling any waiters. + } } protected async cleanup(): Promise { @@ -530,6 +724,13 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { try { this.clearAbortHandlers(this.session.client.rpcHandlerManager); + this.session.client.rpcHandlerManager.registerHandler(RPC_METHODS.SteerQueuedMessage, async () => ({ + steered: false, + error: 'Session ending' + })); + this.promptInFlight = false; + this.session.client.updateAgentState?.((state) => ({ ...state, steeringActive: false })); + this.softSteerWaiters = []; this.unregisterModelApplyHandler?.(); this.unregisterModelApplyHandler = null; @@ -887,13 +1088,47 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { private async handleAbort(): Promise { this.userAbortRequested = true; const backend = this.backend; - const sessionId = this.session.sessionId; + const sessionId = this.acpSessionId ?? this.session.sessionId; if (backend && sessionId) { + const pendingSoftSteers = [...this.softSteerWaiters]; await backend.cancelPrompt(sessionId); + // Drop soft-steer bookkeeping first; retain the foreground prompt + // count and wait for both boundaries before a new handler is used. + backend.abortSoftSteers(); + if (!this.shouldExit) { + let timeout: ReturnType | null = null; + const drained = await Promise.race([ + Promise.all([ + backend.waitForResponseComplete(), + Promise.allSettled(pendingSoftSteers) + ]).then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), CURSOR_ABORT_DRAIN_TIMEOUT_MS); + timeout.unref?.(); + }) + ]); + if (timeout) clearTimeout(timeout); + if (!drained) { + // An ACP request that ignores cancel cannot safely share a + // handler with the next prompt. End the launcher instead + // of allowing late updates to cross the turn boundary. + logger.warn('[cursor-acp] abort drain timed out; ending session to isolate late ACP updates'); + this.shouldExit = true; + } + } } await this.permissionAdapter?.cancelAll('User aborted'); await this.extensionAdapter?.cancelAll('User aborted'); - this.session.queue.reset(); + // A soft steer may settle after Abort; preserve its reservation until + // the completion callback records accepted or indeterminate. + this.session.queue.reset({ preserveDispatchingReservations: true }); + this.promptInFlight = false; + // Abort is the hard-stop path: drop soft-steer waiters so the prompt + // finally cannot block the next prompt on a soft steer whose completion + // is unbounded and may never settle. Soft counters were already reset + // above; only the foreground prompt was drained before continuing. + this.softSteerWaiters = []; + this.session.client.updateAgentState?.((state) => ({ ...state, steeringActive: false })); this.session.onThinkingChange(false); this.abortController.abort(); this.abortController = new AbortController(); diff --git a/cli/src/cursor/cursorLegacyRemoteLauncher.ts b/cli/src/cursor/cursorLegacyRemoteLauncher.ts index 3d4437f1a6..a273423f3d 100644 --- a/cli/src/cursor/cursorLegacyRemoteLauncher.ts +++ b/cli/src/cursor/cursorLegacyRemoteLauncher.ts @@ -12,6 +12,7 @@ import { } from '@/modules/common/remote/RemoteLauncherBase'; import type { CursorSession } from './session'; import type { EnhancedMode } from './loop'; +import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; // TODO(cursor-acp): remove legacy stream-json resume path after migration window. // New Cursor sessions use ACP only. This path exists because pre-ACP Cursor // session_id values are not loadable via ACP session/load. @@ -138,6 +139,14 @@ class CursorRemoteLauncher extends RemoteLauncherBase { onSwitch: () => this.handleSwitchRequest() }); + session.client.rpcHandlerManager.registerHandler( + RPC_METHODS.SteerQueuedMessage, + async () => ({ + steered: false, + error: 'Mid-turn steering requires a Cursor ACP session' + }) + ); + const sendReady = () => { session.sendSessionEvent({ type: 'ready' }); }; diff --git a/cli/src/cursor/runCursor.test.ts b/cli/src/cursor/runCursor.test.ts index d45808870a..227b7028de 100644 --- a/cli/src/cursor/runCursor.test.ts +++ b/cli/src/cursor/runCursor.test.ts @@ -16,6 +16,7 @@ const harness = vi.hoisted(() => ({ session: { onUserMessage: vi.fn(), onCancelQueuedMessage: vi.fn(), + onRetryQueuedMessage: vi.fn(), sendSessionEvent: vi.fn(), rpcHandlerManager: { registerHandler: vi.fn() diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index fec43e8091..0cf4636781 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -112,6 +112,7 @@ export async function runCursor(opts: { logger.debug(`[cursor] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); return removed; }); + session.onRetryQueuedMessage((localId) => messageQueue.releaseIndeterminateReservation(localId)); session.rpcHandlerManager.registerHandler(RPC_METHODS.SetSessionConfig, async (payload: unknown) => { if (!payload || typeof payload !== 'object') { diff --git a/hub/src/sync/steerQueuedMessage.test.ts b/hub/src/sync/steerQueuedMessage.test.ts index 8515054ad6..1704a9592a 100644 --- a/hub/src/sync/steerQueuedMessage.test.ts +++ b/hub/src/sync/steerQueuedMessage.test.ts @@ -63,7 +63,7 @@ describe('SyncEngine.steerQueuedMessage', () => { expect(result).toEqual({ status: 'failed', - error: 'Steering is only supported for Pi and Codex sessions', + error: 'Steering is only supported for Pi, Codex, and Cursor ACP sessions', localId: null }) } finally { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 28212f2b03..049398f46b 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -1035,9 +1035,11 @@ export class SyncEngine { } /** - * Ask the CLI to deliver one waiting-queue message into the active Pi turn - * (Pi native steer). Only pi sessions support this today; the CLI's - * `steer-queued-message` handler is registered by the pi runner alone. + * Ask the CLI to deliver one waiting-queue message into the active turn + * (native steer). Supported for Pi, Codex, and Cursor ACP sessions; the + * CLI's `steer-queued-message` handler is registered per flavor. Legacy + * stream-json Cursor sessions and other flavors are rejected by the + * capability gate. */ async steerQueuedMessage( sessionId: string, @@ -1048,7 +1050,7 @@ export class SyncEngine { return { status: 'failed', error: 'Session not found', localId: null } } if (!isSteeringSupportedForSession(session.metadata)) { - return { status: 'failed', error: 'Steering is only supported for Pi and Codex sessions', localId: null } + return { status: 'failed', error: 'Steering is only supported for Pi, Codex, and Cursor ACP sessions', localId: null } } if (session.agentState?.controlledByUser === true) { return { status: 'failed', error: 'Steering is only available for remote sessions', localId: null } diff --git a/shared/src/modes.test.ts b/shared/src/modes.test.ts index 0e60af3b36..69ab759ffb 100644 --- a/shared/src/modes.test.ts +++ b/shared/src/modes.test.ts @@ -133,10 +133,10 @@ describe('claude auto permission mode', () => { }) describe('isSteeringSupportedForFlavor', () => { - it('supports codex and pi only', () => { + it('supports codex, cursor and pi', () => { expect(isSteeringSupportedForFlavor('codex')).toBe(true) + expect(isSteeringSupportedForFlavor('cursor')).toBe(true) expect(isSteeringSupportedForFlavor('pi')).toBe(true) - expect(isSteeringSupportedForFlavor('cursor')).toBe(false) expect(isSteeringSupportedForFlavor('claude')).toBe(false) expect(isSteeringSupportedForFlavor('opencode')).toBe(false) expect(isSteeringSupportedForFlavor(undefined)).toBe(false) @@ -150,13 +150,25 @@ describe('isSteeringSupportedForSession', () => { expect(isSteeringSupportedForSession({ flavor: 'pi' })).toBe(true) }) - it('rejects cursor until its steer handler lands', () => { + it('supports Cursor ACP sessions', () => { expect(isSteeringSupportedForSession({ flavor: 'cursor', cursorSessionProtocol: 'acp', cursorSessionId: 'sess-1', + })).toBe(true) + expect(isSteeringSupportedForSession({ flavor: 'cursor' })).toBe(true) + }) + + it('rejects legacy Cursor stream-json sessions', () => { + expect(isSteeringSupportedForSession({ + flavor: 'cursor', + cursorSessionProtocol: 'stream-json', + cursorSessionId: 'legacy-1', + })).toBe(false) + expect(isSteeringSupportedForSession({ + flavor: 'cursor', + cursorSessionId: 'legacy-without-protocol', })).toBe(false) - expect(isSteeringSupportedForSession({ flavor: 'cursor' })).toBe(false) }) it('rejects non-steerable flavors', () => { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 8f18e63f2d..73162e49af 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -190,12 +190,11 @@ export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption * - Codex: app-server `turn/steer` (true mid-turn inject) * - Cursor ACP: concurrent `session/prompt` soft-send (no cancel). Legacy * stream-json Cursor sessions are NOT steerable — gate with - * {@link isSteeringSupportedForSession}. (Cursor joins this list once its - * soft-steer handler lands.) + * {@link isSteeringSupportedForSession}. * * Claude / others: not supported (no reachable soft-steer path) — UI hides Steer. */ -export const STEERING_SUPPORTED_FLAVORS = ['codex', 'pi'] as const +export const STEERING_SUPPORTED_FLAVORS = ['codex', 'cursor', 'pi'] as const export function isSteeringSupportedForFlavor(flavor?: string | null): boolean { return (STEERING_SUPPORTED_FLAVORS as readonly string[]).includes(flavor ?? '') @@ -217,5 +216,14 @@ export function isSteeringSupportedForSession(metadata?: { if (metadata?.flavor === 'codex' || metadata?.flavor === 'pi') { return true } - return false + if (metadata?.flavor !== 'cursor') { + return false + } + if (metadata.cursorSessionProtocol === 'stream-json') { + return false + } + if (!metadata.cursorSessionProtocol && metadata.cursorSessionId) { + return false + } + return true } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 425ab6e54b..d57f18e7ae 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1866,7 +1866,9 @@ function SessionChatInner(props: SessionChatProps) { updatePendingSchedule(restored) }} canSteer={isSteeringSupportedForSession(props.session.metadata) - && props.session.thinking + && (agentFlavor === 'pi' + ? props.session.thinking + : props.session.agentState?.steeringActive === true) && !controlledByUser} /> From 9bdccf0cff4c96b37cddfff051b1ef5629b61ff9 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 20 Aug 2026 08:54:20 +0800 Subject: [PATCH 081/168] fix(web): unify message action buttons (#1645) --- .../messages/MessageActionButton.tsx | 29 ++++++++++++++ .../messages/MessageActions.test.tsx | 40 +++++++++++++++++++ .../AssistantChat/messages/MessageActions.tsx | 29 +++++--------- .../messages/ShareTurnButton.tsx | 13 +++--- 4 files changed, 85 insertions(+), 26 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/MessageActionButton.tsx diff --git a/web/src/components/AssistantChat/messages/MessageActionButton.tsx b/web/src/components/AssistantChat/messages/MessageActionButton.tsx new file mode 100644 index 0000000000..4b2f6d80e0 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageActionButton.tsx @@ -0,0 +1,29 @@ +import type { ButtonHTMLAttributes } from 'react' +import { cn } from '@/lib/utils' + +export const MESSAGE_ACTION_BUTTON_CLASS = + 'flex h-5 w-5 items-center justify-center rounded text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)]' + +type MessageActionButtonProps = Omit, 'aria-label' | 'title'> & { + label: string +} + +export function MessageActionButton({ + label, + className, + children, + type = 'button', + ...props +}: MessageActionButtonProps) { + return ( + + ) +} diff --git a/web/src/components/AssistantChat/messages/MessageActions.test.tsx b/web/src/components/AssistantChat/messages/MessageActions.test.tsx index de9ccf64b3..87a8bca3f7 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.test.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.test.tsx @@ -6,6 +6,7 @@ import { MessageActions, selectThreadIsRunning, } from './MessageActions' +import { MESSAGE_ACTION_BUTTON_CLASS } from './MessageActionButton' const copy = vi.fn() const onShareTurn = vi.fn() @@ -361,6 +362,45 @@ describe('MessageActions', () => { ]) }) + it('uses the shared compact style and matching accessible hover labels', () => { + renderActions({ + align: 'end', + copyText: 'body', + messageElementId: 'message-1', + showFork: true, + showRewind: true, + onFork: async () => {}, + onRewind: async () => {} + }) + + const buttons = ['Share turn as image', 'Rewind', 'Fork', 'Copy'].map((name) => + screen.getByRole('button', { name }) + ) + expect(new Set(buttons.map((button) => button.className))).toEqual(new Set([MESSAGE_ACTION_BUTTON_CLASS])) + for (const button of buttons) { + expect(button).toHaveAttribute('title', button.getAttribute('aria-label')) + } + expect(buttons[0].querySelector('svg')).toHaveAttribute('class', 'h-3.5 w-3.5') + }) + + it('localizes every message action hover label in Simplified Chinese', () => { + localStorage.setItem('hapi-lang', 'zh-CN') + + renderActions({ + align: 'end', + copyText: 'body', + messageElementId: 'message-1', + showFork: true, + showRewind: true, + onFork: async () => {}, + onRewind: async () => {} + }) + + for (const name of ['将本轮对话分享为图片', '回退', '分叉', '复制']) { + expect(screen.getByRole('button', { name })).toHaveAttribute('title', name) + } + }) + it('shows Fork confirm dialog and calls onFork only after confirm', async () => { const onFork = vi.fn(async () => {}) renderActions({ align: 'start', copyText: 'body', showFork: true, onFork }) diff --git a/web/src/components/AssistantChat/messages/MessageActions.tsx b/web/src/components/AssistantChat/messages/MessageActions.tsx index 08bcaaa043..8d750f61dd 100644 --- a/web/src/components/AssistantChat/messages/MessageActions.tsx +++ b/web/src/components/AssistantChat/messages/MessageActions.tsx @@ -8,6 +8,7 @@ import { MessageMetadata, buildMessageMetadataLabels, type MessageMetadataProps import { MessageTimestamp } from './MessageTimestamp' import { cn } from '@/lib/utils' import { ShareTurnButton } from './ShareTurnButton' +import { MessageActionButton } from './MessageActionButton' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' export type MessageHistoryAction = { @@ -70,47 +71,37 @@ export function MessageActions({ ) : null const historyButtons = !actionsLocked ? ( <> {showRewind && onRewind ? ( - + ) : null} {showFork && onFork ? ( - + ) : null} ) : null const copyButton = canCopy ? ( - + ) : null return ( diff --git a/web/src/components/AssistantChat/messages/ShareTurnButton.tsx b/web/src/components/AssistantChat/messages/ShareTurnButton.tsx index 3fa4d51744..785bb56a6c 100644 --- a/web/src/components/AssistantChat/messages/ShareTurnButton.tsx +++ b/web/src/components/AssistantChat/messages/ShareTurnButton.tsx @@ -1,6 +1,7 @@ import { useAuiState } from '@assistant-ui/react' import { useOptionalHappyChatContext } from '@/components/AssistantChat/context' import { useTranslation } from '@/lib/use-translation' +import { MessageActionButton } from './MessageActionButton' function ShareIcon(props: { className?: string }) { return ( @@ -49,12 +50,10 @@ export function ShareTurnButton(props: { const fallbackText = props.fallbackText?.trim() ?? '' return ( - + + ) } From 0aebf39c7877d6372e45b3953c282d40d27e7829 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Thu, 20 Aug 2026 09:55:21 +0900 Subject: [PATCH 082/168] fix(opencode): keep one stored message per reasoning stream (#1643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(acp): carry the live reasoning marker on the wire payload ACP agents stream thoughts a token at a time, so the handler coalesces them into a buffer and re-sends the whole buffer under a stable stream id every 250ms. The converter dropped the marker that says a payload is one of those throttled snapshots, leaving the hub unable to tell a replaceable snapshot from the settled message that closes the stream. Mirrors how the text variant already forwards streamSnapshot. * fix(hub): keep one stored message per reasoning stream OpenCode reasoning arrives as a series of growing snapshots sharing one stream id, and every snapshot was persisted as its own message. A 26h session reached 48,844 rows and 63MB, and because the web budgets a fixed number of messages, its 400-message window covered barely three minutes of conversation — scrolling up walked through duplicate snapshots instead of history. Retire a stream's earlier live snapshots once their replacement is stored. Sweeping only after the insert matters: the two statements are separate transactions, so clearing first would leave a window where a crash takes the whole stream. Only rows marked live are eligible and the replacement is spared, so a stream always keeps at least one row and the settled message that closes it is never removed. Live rendering is unchanged: the web still receives every snapshot and already folds them by stream id. * fix(web): spend the message window on conversation, not repeated snapshots The window budgets raw messages, but a reasoning stream renders as a single folded block no matter how many snapshots it arrived in. On sessions recorded before the hub started retiring them, those snapshots fill the window on their own: in one 26h session the newest 400 messages covered 202 seconds, so scrolling up paged through duplicates instead of history. Collapse each stream to its newest snapshot before trimming. Rendering is unchanged — the timeline already folds them by stream id — and rows without a stream id are never touched. * fix(ios,android): port reasoning-snapshot compaction to the native windows The window logic in HapiProtocol and :core:protocol is a one-to-one port of the web store, so collapsing superseded reasoning snapshots only on the web left the native windows budgeting raw snapshot rows. The hub stores one row per stream now, but a client that already holds the older snapshots still spends its window on them. Add the same stream-id reader and compaction to both ports, in the shape each already uses for agent-run rows, and pin the behaviour with a pagination fixture. Both fixture suites enumerate shared/fixtures/pagination from disk, so the ports cannot drift from the web again without CI saying so. --- .../protocol/window/MessageWindowLogic.kt | 62 +++- cli/src/agent/messageConverter.test.ts | 26 ++ cli/src/agent/messageConverter.ts | 14 +- .../handlers/cli/sessionHandlers.test.ts | 61 ++++ .../socket/handlers/cli/sessionHandlers.ts | 15 +- hub/src/store/messageStore.ts | 5 + hub/src/store/messages.test.ts | 115 ++++++++ hub/src/store/messages.ts | 45 +++ .../Window/MessageWindowLogic.swift | 61 +++- ...easoning-snapshots-collapse-to-newest.json | 265 ++++++++++++++++++ shared/src/messages.test.ts | 42 +++ shared/src/messages.ts | 43 +++ web/scripts/fixtures/pagination/cases.ts | 66 +++++ web/src/lib/message-window-store.test.ts | 101 +++++++ web/src/lib/message-window-store.ts | 40 ++- 15 files changed, 947 insertions(+), 14 deletions(-) create mode 100644 shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt index 204081d128..ce0488010f 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/window/MessageWindowLogic.kt @@ -106,12 +106,64 @@ object MessageWindowLogic { } /** - * Trim to [regularLimit] while never dropping queued rows: the regular - * budget shrinks by the queued count, `agent-run-*` rows trim against - * their own [AGENT_RUN_WINDOW_SIZE] bucket, and queued rows are re-merged - * afterwards (web `trimPreservingQueued`). + * Web `getReasoningStreamId`: the stream a reasoning row belongs to, or + * null for anything else. Unrecognised shapes read as null, which means + * "keep it". */ - private fun trimPreservingQueued(messages: List, regularLimit: Int, mode: TrimMode): Trim { + private fun reasoningStreamId(message: WindowMessage): String? { + val outer = message.wire.content as? JsonObject ?: return null + if (outer["role"].stringOrNull != "agent") return null + val content = outer["content"] as? JsonObject ?: return null + if (content["type"].stringOrNull != "codex") return null + val data = content["data"] as? JsonObject ?: return null + if (data["type"].stringOrNull != "reasoning") return null + val id = data["id"].stringOrNull ?: return null + return id.takeIf { it.isNotBlank() } + } + + /** + * Web `dropSupersededReasoningSnapshots`: collapse a reasoning stream to + * the one snapshot that still says something. The CLI re-sends a growing + * buffer under a stable stream id every few hundred milliseconds and the + * timeline folds those rows into a single block, so spending window budget + * on the older ones is what pushes the surrounding conversation out of + * reach. Rows with no stream id are left alone. + */ + private fun dropSupersededReasoningSnapshots(messages: List): List { + val newestByStream = LinkedHashMap() + for (message in messages) { + val streamId = reasoningStreamId(message) ?: continue + val incumbent = newestByStream[streamId] + if (incumbent == null) { + newestByStream[streamId] = message + continue + } + // Fall back to arrival order when either row predates seq + // numbering: `messages` is kept in display order, so later still + // means newer. + val challengerAt = messagePosition(message) + val incumbentAt = messagePosition(incumbent) + val newer = if (challengerAt != null && incumbentAt != null) { + challengerAt >= incumbentAt + } else { + true + } + if (newer) newestByStream[streamId] = message + } + if (newestByStream.isEmpty()) return messages + val survivors = newestByStream.values.mapTo(HashSet()) { it.id } + return messages.filter { reasoningStreamId(it) == null || it.id in survivors } + } + + /** + * Trim to [regularLimit] while never dropping queued rows: superseded + * reasoning snapshots are collapsed first, the regular budget shrinks by + * the queued count, `agent-run-*` rows trim against their own + * [AGENT_RUN_WINDOW_SIZE] bucket, and queued rows are re-merged afterwards + * (web `trimPreservingQueued`). + */ + private fun trimPreservingQueued(incoming: List, regularLimit: Int, mode: TrimMode): Trim { + val messages = dropSupersededReasoningSnapshots(incoming) val queued = messages.filter { it.isQueuedForInvocation } val queuedIds = queued.mapTo(HashSet()) { it.id } val nonQueued = messages.filter { it.id !in queuedIds } diff --git a/cli/src/agent/messageConverter.test.ts b/cli/src/agent/messageConverter.test.ts index ac11db6d8f..ed77f81a1f 100644 --- a/cli/src/agent/messageConverter.test.ts +++ b/cli/src/agent/messageConverter.test.ts @@ -116,6 +116,32 @@ describe('convertAgentMessage', () => { }); }); + it('marks a live reasoning snapshot on the wire payload', () => { + const converted = convertAgentMessage({ + type: 'reasoning', + text: 'thinking', + id: 'reasoning-stream-1', + live: true + }); + + expect(converted).toEqual({ + type: 'reasoning', + message: 'thinking', + id: 'reasoning-stream-1', + live: true + }); + }); + + it('omits the live marker from a settled reasoning payload', () => { + const converted = convertAgentMessage({ + type: 'reasoning', + text: 'thinking', + id: 'reasoning-stream-1' + }); + + expect(converted !== null && 'live' in converted).toBe(false); + }); + it('converts error messages into codex error payloads', () => { const converted = convertAgentMessage({ type: 'error', diff --git a/cli/src/agent/messageConverter.ts b/cli/src/agent/messageConverter.ts index 1c7699b5ca..4b53f155be 100644 --- a/cli/src/agent/messageConverter.ts +++ b/cli/src/agent/messageConverter.ts @@ -5,7 +5,7 @@ import type { InlineMediaSource } from '@/modules/common/inlineMediaSource'; export type CodexMessage = | { type: 'message'; message: string; id?: string; streamSnapshot?: boolean } - | { type: 'reasoning'; message: string; id: string } + | { type: 'reasoning'; message: string; id: string; live?: boolean } | { type: 'token_count'; model: string | null; @@ -64,7 +64,17 @@ export function convertAgentMessage(message: AgentMessage, model?: string | null // AgentMessage uses `text` (consistent with the `text` variant); // the wire-level CodexMessage uses `message` to match the // existing reasoning format emitted by the Codex path. - return { type: 'reasoning', message: message.text, id: message.id ?? randomUUID() }; + // + // `live` marks a throttled snapshot of a still-growing buffer, so + // the hub can keep just the newest one per stream instead of + // storing every intermediate. The settled message that closes a + // stream carries no marker, which is what makes it the survivor. + return { + type: 'reasoning', + message: message.text, + id: message.id ?? randomUUID(), + ...(message.live === true ? { live: true } : {}) + }; case 'usage': return { type: 'token_count', diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 6d1788196c..29e011935e 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' import { Store, type StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import type { CliSocketWithData } from '../../socketTypes' @@ -37,6 +38,20 @@ function redundantGoalStatusContent(message: string): unknown { } } +function reasoningContent(streamId: string, text: string, live: boolean): unknown { + return { + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { type: 'reasoning', message: text, id: streamId, ...(live ? { live: true } : {}) } + } + } +} + +function reasoningTextOf(message: { content: unknown }): string { + return (message.content as { content: { data: { message: string } } }).content.data.message +} + describe('cli session handlers', () => { it('preserves immediate queued rows for cleared handoff transfer', () => { const store = new Store(':memory:') @@ -57,6 +72,52 @@ describe('cli session handlers', () => { ]) }) + it('collapses a live reasoning stream into its settled message', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('reasoning-stream-session', {}, null, 'default') + const socket = new FakeSocket() + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { + throw new Error('unexpected access error') + } + }) + + for (const text of ['th', 'thin', 'think']) { + socket.trigger('message', { sid: session.id, message: reasoningContent('stream-1', text, true) }) + } + socket.trigger('message', { sid: session.id, message: reasoningContent('stream-1', 'thinking', false) }) + // A second stream in the same turn must survive the first one's cleanup. + socket.trigger('message', { sid: session.id, message: reasoningContent('stream-2', 'more', true) }) + + expect(store.messages.getAllMessages(session.id).map(reasoningTextOf)).toEqual(['thinking', 'more']) + }) + + it('keeps every reasoning snapshot that carries no stream id', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('reasoning-idless-session', {}, null, 'default') + const socket = new FakeSocket() + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: session as StoredSession }), + emitAccessError: () => { + throw new Error('unexpected access error') + } + }) + + for (const text of ['one', 'two']) { + socket.trigger('message', { + sid: session.id, + message: { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'reasoning', message: text } } } + }) + } + + expect(store.messages.getAllMessages(session.id)).toHaveLength(2) + }) + it('drops redundant goal status events before persistence and broadcast', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('goal-status-session', {}, null, 'default') diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index aa22c34f77..3a7d29ebbc 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { randomUUID } from 'node:crypto' import type { CopilotAgentMode } from '@hapi/protocol' import type { AgentState, CodexCollaborationMode, Metadata, PermissionMode } from '@hapi/protocol/types' -import { isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' +import { getReasoningStreamId, isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' @@ -134,6 +134,19 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } const msg = store.messages.addMessage(sid, content, localId, undefined, createdAt) + + // A reasoning stream arrives as a series of growing snapshots under one + // stable id, so a stream should cost one row rather than one per + // interval. Retire the earlier snapshots only once their replacement is + // stored: these are separate transactions, and clearing first would let + // a crash in between take the whole stream. Only rows marked live are + // eligible, so the settled message that closes a stream survives and + // also sweeps up its own leftovers. + const reasoningStreamId = getReasoningStreamId(content) + if (reasoningStreamId) { + store.messages.deleteLiveReasoningSnapshots(sid, reasoningStreamId, msg.id) + } + if (shouldRecordSessionActivity(content)) { onSessionActivity?.(sid, msg.createdAt) } diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index d39b2fc3d4..77d38c9bf6 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -5,6 +5,7 @@ import { addMessage, addImportedMessage, cancelQueuedMessage, + deleteLiveReasoningSnapshots, deleteQueuedMessageById, claimIndeterminateMessage, lookupQueuedMessage, @@ -54,6 +55,10 @@ export class MessageStore { return addMessage(this.db, sessionId, content, localId, scheduledAt, createdAt) } + deleteLiveReasoningSnapshots(sessionId: string, streamId: string, keepMessageId?: string): number { + return deleteLiveReasoningSnapshots(this.db, sessionId, streamId, keepMessageId) + } + addImportedMessage(sessionId: string, content: unknown, localId: string, createdAt: number): { message: StoredMessage; inserted: boolean } { return addImportedMessage(this.db, sessionId, content, localId, createdAt) } diff --git a/hub/src/store/messages.test.ts b/hub/src/store/messages.test.ts index 4790f4c2fb..45065bb152 100644 --- a/hub/src/store/messages.test.ts +++ b/hub/src/store/messages.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { getReasoningStreamId } from '@hapi/protocol/messages' import { Store } from './index' function makeStore(): Store { @@ -596,3 +598,116 @@ describe('content codec integration', () => { expect(store.messages.getMessages(session.id)[0]).toMatchObject({ invokedAt }) }) }) + +describe('deleteLiveReasoningSnapshots', () => { + function reasoningMessage(streamId: string, text: string, live: boolean) { + return { + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { type: 'reasoning', message: text, id: streamId, ...(live ? { live: true } : {}) } + } + } + } + + function reasoningTexts(store: Store, sessionId: string): string[] { + return store.messages.getAllMessages(sessionId) + .map((message) => getReasoningStreamId(message.content) === null + ? null + : ((message.content as { content: { data: { message: string } } }).content.data.message)) + .filter((text): text is string => text !== null) + } + + it('keeps only the newest snapshot of a stream', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-collapse') + + for (const text of ['a', 'ab', 'abc']) { + const stored = store.messages.addMessage(session.id, reasoningMessage('stream-1', text, true)) + store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-1', stored.id) + } + + expect(reasoningTexts(store, session.id)).toEqual(['abc']) + }) + + it('lets a settled message supersede the snapshots of its own stream', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-settle') + + store.messages.addMessage(session.id, reasoningMessage('stream-1', 'partial', true)) + const settled = store.messages.addMessage(session.id, reasoningMessage('stream-1', 'final', false)) + store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-1', settled.id) + + expect(reasoningTexts(store, session.id)).toEqual(['final']) + }) + + it('never removes a settled message', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-settled-kept') + + store.messages.addMessage(session.id, reasoningMessage('stream-1', 'final', false)) + const removed = store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-1') + + expect(removed).toBe(0) + expect(reasoningTexts(store, session.id)).toEqual(['final']) + }) + + it('leaves other streams alone', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-other-stream') + + store.messages.addMessage(session.id, reasoningMessage('stream-1', 'first', true)) + const other = store.messages.addMessage(session.id, reasoningMessage('stream-2', 'second', true)) + store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-2', other.id) + + expect(reasoningTexts(store, session.id)).toEqual(['first', 'second']) + }) + + it('never crosses a session boundary', () => { + const store = makeStore() + const mine = makeSession(store, 'reasoning-mine') + const theirs = makeSession(store, 'reasoning-theirs') + + store.messages.addMessage(theirs.id, reasoningMessage('stream-1', 'theirs', true)) + store.messages.deleteLiveReasoningSnapshots(mine.id, 'stream-1') + + expect(reasoningTexts(store, theirs.id)).toEqual(['theirs']) + }) + + it('spares the message that replaced the snapshots', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-keep-replacement') + + const replacement = store.messages.addMessage(session.id, reasoningMessage('stream-1', 'newest', true)) + const removed = store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-1', replacement.id) + + expect(removed).toBe(0) + expect(reasoningTexts(store, session.id)).toEqual(['newest']) + }) + + it('never lets a stream pass through zero rows', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-never-empty') + + // Walk a stream the way the handler does — store, then sweep — and + // assert the stream is represented after every single step. + for (const text of ['a', 'ab', 'abc', 'abcd']) { + const stored = store.messages.addMessage(session.id, reasoningMessage('stream-1', text, true)) + expect(reasoningTexts(store, session.id).length).toBeGreaterThan(0) + store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-1', stored.id) + expect(reasoningTexts(store, session.id)).toEqual([text]) + } + }) + + it('leaves unrelated messages untouched', () => { + const store = makeStore() + const session = makeSession(store, 'reasoning-unrelated') + + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'hello' } }) + store.messages.addMessage(session.id, reasoningMessage('stream-1', 'partial', true)) + store.messages.deleteLiveReasoningSnapshots(session.id, 'stream-1') + + expect(store.messages.getAllMessages(session.id)).toHaveLength(1) + expect(reasoningTexts(store, session.id)).toEqual([]) + }) +}) diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 86fa34d6ff..5480897fb9 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -2,6 +2,8 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' import { isDeepStrictEqual } from 'node:util' +import { getLiveReasoningStreamId } from '@hapi/protocol/messages' + import type { StoredMessage } from './types' import { decodeMessageContent, encodeMessageContent, truncateOversizedMessageContent } from './contentCodec' @@ -383,6 +385,49 @@ export function getDeliverableMessagesAfter( return rows.map(toStoredMessage) } +/** How far back to look for a stream's replaceable snapshots. + * + * The CLI re-sends a growing reasoning buffer every few hundred milliseconds, + * so the previous snapshot of a stream is always among the newest rows of its + * session. Scanning a bounded tail keeps this off the hot path on sessions + * with tens of thousands of messages; anything older than the window is left + * alone, which errs toward keeping data. */ +const REASONING_SNAPSHOT_LOOKBACK = 50 + +/** Drop the replaceable snapshots of one reasoning stream. + * + * Only messages explicitly marked live are eligible, and `keepMessageId` is + * always spared. Callers run this *after* storing the message that supersedes + * them, so the stream never passes through a moment with no row at all — the + * two statements are separate transactions, and a crash between them must not + * be able to take the whole stream with it. Returns how many rows were + * removed. */ +export function deleteLiveReasoningSnapshots( + db: Database, + sessionId: string, + streamId: string, + keepMessageId?: string +): number { + const rows = db.prepare(` + SELECT id, content FROM messages + WHERE session_id = ? + ORDER BY seq DESC + LIMIT ? + `).all(sessionId, REASONING_SNAPSHOT_LOOKBACK) as Array> + + const staleIds = rows + .filter((row) => row.id !== keepMessageId + && getLiveReasoningStreamId(decodeMessageContent(row.content)) === streamId) + .map((row) => row.id) + if (staleIds.length === 0) return 0 + + const placeholders = staleIds.map(() => '?').join(', ') + const result = db.prepare( + `DELETE FROM messages WHERE session_id = ? AND id IN (${placeholders})` + ).run(sessionId, ...staleIds) + return Number(result.changes) +} + /** Paginate messages by COALESCE(invoked_at, created_at) DESC, seq DESC. * Results are returned in ascending display order. */ export function getMessagesByPosition( diff --git a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift index 3b6915c924..4cd398f6f9 100644 --- a/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift +++ b/ios/Packages/HapiKit/Sources/HapiProtocol/Window/MessageWindowLogic.swift @@ -127,15 +127,66 @@ public enum MessageWindowLogic { return type == "agent-run-start" || type == "agent-run-update" || type == "agent-run-trace" } - /// Trim to `regularLimit` while never dropping queued rows: the regular - /// budget shrinks by the queued count, `agent-run-*` rows trim against - /// their own bucket (`agentRunWindowSize`), and queued rows are re-merged - /// afterwards (web `trimPreservingQueued`). + /// Web `getReasoningStreamId`: the stream a reasoning row belongs to, or + /// `nil` for anything else. Unrecognised shapes read as `nil`, which means + /// "keep it". + private static func reasoningStreamId(_ message: WindowMessage) -> String? { + guard let outer = message.content.objectValue, + outer["role"]?.stringValue == "agent", + let payload = outer["content"]?.objectValue, + payload["type"]?.stringValue == "codex", + let data = payload["data"]?.objectValue, + data["type"]?.stringValue == "reasoning", + let id = data["id"]?.stringValue, + !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return nil } + return id + } + + /// Web `dropSupersededReasoningSnapshots`: collapse a reasoning stream to + /// the one snapshot that still says something. The CLI re-sends a growing + /// buffer under a stable stream id every few hundred milliseconds and the + /// timeline folds those rows into a single block, so spending window + /// budget on the older ones is what pushes the surrounding conversation + /// out of reach. Rows with no stream id are left alone. + private static func dropSupersededReasoningSnapshots( + _ messages: [WindowMessage] + ) -> [WindowMessage] { + var newestByStream: [String: WindowMessage] = [:] + for message in messages { + guard let streamId = reasoningStreamId(message) else { continue } + guard let incumbent = newestByStream[streamId] else { + newestByStream[streamId] = message + continue + } + // Fall back to arrival order when either row predates seq + // numbering: `messages` is kept in display order, so later still + // means newer. + let newer: Bool + if let challengerAt = messagePosition(message), + let incumbentAt = messagePosition(incumbent) { + newer = !(challengerAt < incumbentAt) + } else { + newer = true + } + if newer { newestByStream[streamId] = message } + } + if newestByStream.isEmpty { return messages } + let survivors = Set(newestByStream.values.map(\.id)) + return messages.filter { reasoningStreamId($0) == nil || survivors.contains($0.id) } + } + + /// Trim to `regularLimit` while never dropping queued rows: superseded + /// reasoning snapshots are collapsed first, the regular budget shrinks by + /// the queued count, `agent-run-*` rows trim against their own bucket + /// (`agentRunWindowSize`), and queued rows are re-merged afterwards (web + /// `trimPreservingQueued`). private static func trimPreservingQueued( - _ messages: [WindowMessage], + _ incoming: [WindowMessage], regularLimit: Int, mode: TrimMode ) -> Trim { + let messages = dropSupersededReasoningSnapshots(incoming) let queued = messages.filter(\.isQueuedForInvocation) let queuedIds = Set(queued.map(\.id)) let nonQueued = messages.filter { !queuedIds.contains($0.id) } diff --git a/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json b/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json new file mode 100644 index 0000000000..1b9b20a56b --- /dev/null +++ b/shared/fixtures/pagination/reasoning-snapshots-collapse-to-newest.json @@ -0,0 +1,265 @@ +{ + "description": "A reasoning stream arrives as repeated growing snapshots under one stream id. Only the newest snapshot of each stream survives the window (the timeline folds them into a single block anyway), streams collapse independently, the settled row that closes a stream supersedes its live snapshots, and rows without a stream id are untouched.", + "expectedState": { + "epoch": 0, + "hasMore": false, + "messages": [ + { + "createdAt": 1000, + "id": "a-1", + "invokedAt": 1000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 1 + }, + { + "createdAt": 4000, + "id": "s-3", + "invokedAt": 4000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 4 + }, + { + "createdAt": 5000, + "id": "a-2", + "invokedAt": 5000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 5 + }, + { + "createdAt": 7000, + "id": "s-5", + "invokedAt": 7000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 7 + }, + { + "createdAt": 9000, + "id": "s-7", + "invokedAt": 9000, + "localId": null, + "optimistic": false, + "queued": false, + "seq": 9 + } + ], + "newestCursor": { + "at": 9000, + "seq": 9 + }, + "olderCursor": { + "at": 1000, + "seq": 1 + }, + "viewMode": "tail" + }, + "fixtureVersion": 1, + "name": "reasoning-snapshots-collapse-to-newest", + "ops": [ + { + "expectedRequests": [ + { + "limit": 200 + } + ], + "op": "sync-tail", + "responses": [ + { + "messages": [ + { + "content": { + "content": { + "data": { + "message": "a-1", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 1000, + "id": "a-1", + "invokedAt": 1000, + "localId": null, + "seq": 1 + }, + { + "content": { + "content": { + "data": { + "id": "stream-a", + "live": true, + "message": "th", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 2000, + "id": "s-1", + "invokedAt": 2000, + "localId": null, + "seq": 2 + }, + { + "content": { + "content": { + "data": { + "id": "stream-a", + "live": true, + "message": "thin", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 3000, + "id": "s-2", + "invokedAt": 3000, + "localId": null, + "seq": 3 + }, + { + "content": { + "content": { + "data": { + "id": "stream-a", + "live": true, + "message": "thinking", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 4000, + "id": "s-3", + "invokedAt": 4000, + "localId": null, + "seq": 4 + }, + { + "content": { + "content": { + "data": { + "message": "a-2", + "type": "message" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 5000, + "id": "a-2", + "invokedAt": 5000, + "localId": null, + "seq": 5 + }, + { + "content": { + "content": { + "data": { + "id": "stream-b", + "live": true, + "message": "more", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 6000, + "id": "s-4", + "invokedAt": 6000, + "localId": null, + "seq": 6 + }, + { + "content": { + "content": { + "data": { + "id": "stream-b", + "message": "more still", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 7000, + "id": "s-5", + "invokedAt": 7000, + "localId": null, + "seq": 7 + } + ], + "page": { + "direction": "latest", + "epoch": 0, + "hasMore": false, + "limit": 200, + "nextAfterAt": null, + "nextAfterSeq": null, + "nextBeforeAt": 1000, + "nextBeforeSeq": 1, + "reset": false, + "snapshotHeadAt": 7000, + "snapshotHeadSeq": 7 + } + } + ] + }, + { + "messages": [ + { + "content": { + "content": { + "data": { + "id": "stream-c", + "live": true, + "message": "later", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 8000, + "id": "s-6", + "invokedAt": 8000, + "localId": null, + "seq": 8 + }, + { + "content": { + "content": { + "data": { + "id": "stream-c", + "live": true, + "message": "later still", + "type": "reasoning" + }, + "type": "codex" + }, + "role": "agent" + }, + "createdAt": 9000, + "id": "s-7", + "invokedAt": 9000, + "localId": null, + "seq": 9 + } + ], + "op": "sse-messages" + } + ] +} diff --git a/shared/src/messages.test.ts b/shared/src/messages.test.ts index 1c62cf8753..8fd210104a 100644 --- a/shared/src/messages.test.ts +++ b/shared/src/messages.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' import { extractAssistantPlainText, extractNotifySummary, + getLiveReasoningStreamId, + getReasoningStreamId, isRedundantGoalStatusEventContent, splitNotifySummary, stripNotifySummaryFooter, @@ -359,3 +362,42 @@ describe('isRedundantGoalStatusEventContent (regression-guard for messages.ts ed expect(isRedundantGoalStatusEventContent(value)).toBe(true) }) }) + +describe('reasoning stream identity', () => { + function reasoningContent(overrides: Record = {}) { + return { + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { type: 'reasoning', message: 'thinking', id: 'stream-1', ...overrides } + } + } + } + + test('reads the stream id from a live snapshot', () => { + expect(getReasoningStreamId(reasoningContent({ live: true }))).toBe('stream-1') + expect(getLiveReasoningStreamId(reasoningContent({ live: true }))).toBe('stream-1') + }) + + test('treats a settled reasoning message as part of the stream but not as live', () => { + expect(getReasoningStreamId(reasoningContent())).toBe('stream-1') + expect(getLiveReasoningStreamId(reasoningContent())).toBeNull() + }) + + test('does not treat a non-boolean live marker as live', () => { + expect(getLiveReasoningStreamId(reasoningContent({ live: 'yes' }))).toBeNull() + }) + + test.each([ + ['a user-role envelope', { role: 'user', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'reasoning', id: 'stream-1' } } }], + ['a different payload type', { role: 'agent', content: { type: 'event', data: { type: 'reasoning', id: 'stream-1' } } }], + ['a different data type', { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'message', id: 'stream-1' } } }], + ['a missing id', { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'reasoning' } } }], + ['a blank id', { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'reasoning', id: ' ' } } }], + ['a non-string id', { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: { type: 'reasoning', id: 7 } } }], + ['a non-object value', 'reasoning'] + ])('returns null for %s', (_label, value) => { + expect(getReasoningStreamId(value)).toBeNull() + expect(getLiveReasoningStreamId(value)).toBeNull() + }) +}) diff --git a/shared/src/messages.ts b/shared/src/messages.ts index b3f4cd18db..e3a8938887 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -1,3 +1,4 @@ +import { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' import { isObject } from './utils' type RoleWrappedRecord = { @@ -67,6 +68,48 @@ export function isRedundantGoalStatusMessageText(value: unknown): boolean { || /^Goal (active|paused|complete|blocked|limited by (?:budget|usage))(?:$|\s+·\s+)/.test(message) } +/** + * ACP agents stream thoughts one token at a time, so the CLI coalesces them + * into a buffer and re-sends the whole buffer under a stable stream id every + * few hundred milliseconds. Every snapshot but the newest is dead weight: the + * buffer only ever grows, so an older snapshot is a strict prefix of a newer + * one and the timeline collapses them back into a single block anyway. + * + * These two readers let the hub and the web keep one message per stream + * instead of one per snapshot. They are deliberately separate: + * + * - `getReasoningStreamId` answers "which stream does this belong to" and so + * also matches the settled message that closes a stream. That message is + * what triggers the final cleanup of its own leftovers. + * - `getLiveReasoningStreamId` answers "is this a replaceable snapshot", and + * so only ever matches something safe to drop. + * + * Anything unrecognised reads as `null`, which means "keep it". + */ +function readReasoningStreamId(value: unknown, liveOnly: boolean): string | null { + const record = unwrapRoleWrappedRecordEnvelope(value) + if (record?.role !== 'agent') return null + + const content = record.content + if (!isObject(content) || content.type !== AGENT_MESSAGE_PAYLOAD_TYPE) return null + + const data = isObject(content.data) ? content.data : null + if (!data || data.type !== 'reasoning') return null + if (liveOnly && data.live !== true) return null + + const id = data.id + if (typeof id !== 'string' || id.trim().length === 0) return null + return id +} + +export function getReasoningStreamId(value: unknown): string | null { + return readReasoningStreamId(value, false) +} + +export function getLiveReasoningStreamId(value: unknown): string | null { + return readReasoningStreamId(value, true) +} + export function isRedundantGoalStatusEventContent(value: unknown): boolean { const record = unwrapRoleWrappedRecordEnvelope(value) if (record?.role !== 'agent') return false diff --git a/web/scripts/fixtures/pagination/cases.ts b/web/scripts/fixtures/pagination/cases.ts index 2477d052ad..0f533d53fa 100644 --- a/web/scripts/fixtures/pagination/cases.ts +++ b/web/scripts/fixtures/pagination/cases.ts @@ -23,6 +23,39 @@ function agentMessage(init: { id: string; seq: number; at: number }): DecryptedM } } +/** A throttled live snapshot of a reasoning stream: the CLI re-sends the + * growing buffer under one stable stream id, so only the newest row of a + * stream carries information. `live: false` marks the settled message that + * closes the stream. */ +function reasoningSnapshot(init: { + id: string + seq: number + at: number + streamId: string + text: string + live?: boolean +}): DecryptedMessage { + return { + id: init.id, + seq: init.seq, + localId: null, + content: { + role: 'agent', + content: { + type: 'codex', + data: { + type: 'reasoning', + message: init.text, + id: init.streamId, + ...(init.live === false ? {} : { live: true }) + } + } + }, + createdAt: init.at, + invokedAt: init.at + } +} + /** A row the chat pipeline hides (normalizes to null): meta system output. */ function hiddenAgentMessage(init: { id: string; seq: number; at: number }): DecryptedMessage { return { @@ -409,6 +442,39 @@ export const paginationFixtureCases: PaginationFixtureCase[] = [ } ] }, + { + name: 'reasoning-snapshots-collapse-to-newest', + description: 'A reasoning stream arrives as repeated growing snapshots under one stream id. Only the newest snapshot of each stream survives the window (the timeline folds them into a single block anyway), streams collapse independently, the settled row that closes a stream supersedes its live snapshots, and rows without a stream id are untouched.', + ops: [ + { + op: 'sync-tail', + responses: [ + pageResponse([ + agentMessage({ id: 'a-1', seq: 1, at: 1_000 }), + reasoningSnapshot({ id: 's-1', seq: 2, at: 2_000, streamId: 'stream-a', text: 'th' }), + reasoningSnapshot({ id: 's-2', seq: 3, at: 3_000, streamId: 'stream-a', text: 'thin' }), + reasoningSnapshot({ id: 's-3', seq: 4, at: 4_000, streamId: 'stream-a', text: 'thinking' }), + agentMessage({ id: 'a-2', seq: 5, at: 5_000 }), + reasoningSnapshot({ id: 's-4', seq: 6, at: 6_000, streamId: 'stream-b', text: 'more' }), + reasoningSnapshot({ id: 's-5', seq: 7, at: 7_000, streamId: 'stream-b', text: 'more still', live: false }) + ], { + direction: 'latest', + epoch: 0, + hasMore: false, + nextBefore: { at: 1_000, seq: 1 }, + snapshotHead: { at: 7_000, seq: 7 } + }) + ] + }, + { + op: 'sse-messages', + messages: [ + reasoningSnapshot({ id: 's-6', seq: 8, at: 8_000, streamId: 'stream-c', text: 'later' }), + reasoningSnapshot({ id: 's-7', seq: 9, at: 9_000, streamId: 'stream-c', text: 'later still' }) + ] + } + ] + }, { name: 'queued-state-reconciliation-drops-deleted', description: 'Queued-state recovery after a resume gap: candidates are the user rows with invokedAt strictly null; the server verdict stamps invoked ones like messages-consumed, keeps the still-queued one, and drops candidates in neither list (deleted server-side).', diff --git a/web/src/lib/message-window-store.test.ts b/web/src/lib/message-window-store.test.ts index 03c90356d5..121ee29606 100644 --- a/web/src/lib/message-window-store.test.ts +++ b/web/src/lib/message-window-store.test.ts @@ -91,6 +91,23 @@ function makeHiddenAgentMessage(props: { id: string; seq: number; at: number }): } as DecryptedMessage } +function makeReasoningMessage(id: string, streamId: string, seq: number, at: number, live = true): DecryptedMessage { + return { + id, + seq, + localId: null, + content: { + role: 'agent', + content: { + type: 'codex', + data: { type: 'reasoning', message: id, id: streamId, ...(live ? { live: true } : {}) } + } + }, + createdAt: at, + invokedAt: at + } as DecryptedMessage +} + function makeAgentRunMessage(id: string, seq: number, at: number): DecryptedMessage { return { id, @@ -1335,3 +1352,87 @@ describe('V2 persistence boundary', () => { expect(getQueuedReconcileCandidateLocalIds(id)).toEqual(['local-1']) }) }) + + +describe('reasoning snapshot compaction', () => { + it('keeps only the newest snapshot of each stream', () => { + const id = sessionId('reasoning-compaction') + const snapshots = Array.from({ length: 5 }, (_, index) => + makeReasoningMessage(`snap-${index}`, 'stream-1', index + 1, index + 1) + ) + const others = [ + makeUserMessage({ id: 'user-1', seq: 10, invokedAt: 10, createdAt: 10 }), + makeUserMessage({ id: 'user-2', seq: 11, invokedAt: 11, createdAt: 11 }) + ] + ingestIncomingMessages(id, [...snapshots, ...others]) + + expect(getMessageWindowState(id).messages.map((message) => message.id)) + .toEqual(['snap-4', 'user-1', 'user-2']) + }) + + it('keeps the newest snapshot of every stream independently', () => { + const id = sessionId('reasoning-multi-stream') + ingestIncomingMessages(id, [ + makeReasoningMessage('a-1', 'stream-a', 1, 1), + makeReasoningMessage('a-2', 'stream-a', 2, 2), + makeReasoningMessage('b-1', 'stream-b', 3, 3), + makeReasoningMessage('b-2', 'stream-b', 4, 4) + ]) + + expect(getMessageWindowState(id).messages.map((message) => message.id)).toEqual(['a-2', 'b-2']) + }) + + it('leaves messages without a reasoning stream untouched', () => { + const id = sessionId('reasoning-unrelated') + const messages = [ + makeUserMessage({ id: 'user-1', seq: 1, invokedAt: 1, createdAt: 1 }), + makeAgentRunMessage('run-1', 2, 2), + makeAgentRunMessage('run-2', 3, 3) + ] + ingestIncomingMessages(id, messages) + + expect(getMessageWindowState(id).messages).toHaveLength(3) + }) + + it('spends the window budget on conversation rather than duplicate snapshots', () => { + const id = sessionId('reasoning-window-budget') + // A session already carrying a flood of stored snapshots: without + // compaction they consume the whole window and the conversation that + // surrounds them falls out of it. + const flood = Array.from({ length: VISIBLE_WINDOW_SIZE }, (_, index) => + makeReasoningMessage(`flood-${index}`, 'stream-1', index + 1, index + 1) + ) + const conversation = Array.from({ length: 50 }, (_, index) => + makeUserMessage({ + id: `talk-${index}`, + seq: VISIBLE_WINDOW_SIZE + index + 1, + invokedAt: VISIBLE_WINDOW_SIZE + index + 1, + createdAt: VISIBLE_WINDOW_SIZE + index + 1 + }) + ) + ingestIncomingMessages(id, [...flood, ...conversation]) + + const kept = getMessageWindowState(id).messages + for (const message of conversation) { + expect(kept.some((candidate) => candidate.id === message.id)).toBe(true) + } + expect(kept.filter((message) => message.id.startsWith('flood-'))).toHaveLength(1) + }) + + // The seq bounds are a view over what the window currently holds; the + // cursor that drives older-page requests is the server's own + // `nextBefore*`, which compaction never touches. Pinning the bounds keeps + // that distinction honest if the two are ever conflated. + it('derives its seq bounds from the surviving rows', () => { + const id = sessionId('reasoning-bounds') + ingestIncomingMessages(id, [ + makeReasoningMessage('snap-1', 'stream-1', 1, 1), + makeReasoningMessage('snap-2', 'stream-1', 2, 2), + makeUserMessage({ id: 'user-1', seq: 3, invokedAt: 3, createdAt: 3 }) + ]) + + const state = getMessageWindowState(id) + expect(state.oldestSeq).toBe(2) + expect(state.newestSeq).toBe(3) + }) +}) diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index 39092c6197..14fc630e15 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -1,3 +1,4 @@ +import { getReasoningStreamId } from '@hapi/protocol/messages' import type { ApiClient } from '@/api/client' import { normalizeDecryptedMessage } from '@/chat/normalize' import type { DecryptedMessage, MessageStatus, MessagesResponse } from '@/types/api' @@ -443,11 +444,48 @@ function isCodexAgentRunMessage(message: DecryptedMessage): boolean { return type === 'agent-run-start' || type === 'agent-run-update' || type === 'agent-run-trace' } +/** Collapse a reasoning stream down to the one snapshot that still says + * something. + * + * The CLI re-sends a growing reasoning buffer under a stable stream id every + * few hundred milliseconds, and the timeline already folds those snapshots + * into a single block by that id. Sessions recorded before the hub started + * retiring them still carry every intermediate, and spending window budget on + * rows that render as one block is what pushes the surrounding conversation + * out of reach. Messages with no stream id are left alone. */ +function dropSupersededReasoningSnapshots(messages: DecryptedMessage[]): DecryptedMessage[] { + const newestByStream = new Map() + for (const message of messages) { + const streamId = getReasoningStreamId(message.content) + if (streamId === null) continue + const incumbent = newestByStream.get(streamId) + if (!incumbent) { + newestByStream.set(streamId, message) + continue + } + // Fall back to arrival order when either row predates seq numbering: + // `messages` is kept in display order, so later still means newer. + const challengerAt = messagePosition(message) + const incumbentAt = messagePosition(incumbent) + const newer = challengerAt && incumbentAt + ? comparePosition(challengerAt, incumbentAt) >= 0 + : true + if (newer) newestByStream.set(streamId, message) + } + if (newestByStream.size === 0) return messages + + const survivors = new Set() + for (const message of newestByStream.values()) survivors.add(message.id) + return messages.filter((message) => + getReasoningStreamId(message.content) === null || survivors.has(message.id)) +} + function trimPreservingQueued( - messages: DecryptedMessage[], + incoming: DecryptedMessage[], regularLimit: number, mode: 'append' | 'prepend' ): { kept: DecryptedMessage[]; dropped: DecryptedMessage[] } { + const messages = dropSupersededReasoningSnapshots(incoming) const queued = messages.filter(isQueuedForInvocation) const queuedIds = new Set(queued.map((message) => message.id)) const nonQueued = messages.filter((message) => !queuedIds.has(message.id)) From 338e405442df2d9fbc1ecdd1240e8b5c827b0e94 Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 20 Aug 2026 10:46:02 +0800 Subject: [PATCH 083/168] fix(brand): sharpen iOS and PWA icons --- assets/brand/README.md | 13 ++++-- assets/brand/export.sh | 25 ++++++----- assets/brand/svg/hapi-maskable.svg | 28 +++++-------- assets/brand/svg/hapi-optical-mark.svg | 15 +++++++ .../AppIcon.icon/Assets/hapi-optical-mark.svg | 15 +++++++ ios/Hapi/AppIcon.icon/icon.json | 39 ++++++++++++++++++ .../AppIcon.appiconset/AppIcon.png | Bin 54031 -> 56557 bytes web/public/apple-touch-icon-180x180.png | Bin 7419 -> 7197 bytes web/public/pwa-192x192.png | Bin 7800 -> 7586 bytes web/public/pwa-512x512.png | Bin 13595 -> 13012 bytes web/public/pwa-64x64.png | Bin 2501 -> 2507 bytes web/public/pwa-maskable-192x192.png | Bin 6995 -> 6781 bytes web/public/pwa-maskable-512x512.png | Bin 13680 -> 13103 bytes website/public/apple-touch-icon-180x180.png | Bin 7419 -> 7197 bytes 14 files changed, 105 insertions(+), 30 deletions(-) create mode 100644 assets/brand/svg/hapi-optical-mark.svg create mode 100644 ios/Hapi/AppIcon.icon/Assets/hapi-optical-mark.svg create mode 100644 ios/Hapi/AppIcon.icon/icon.json diff --git a/assets/brand/README.md b/assets/brand/README.md index c399434854..7458462a9c 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -12,8 +12,13 @@ and iOS applications. - Directional negative space changes the port structure, rather than merely swapping a circle for another primitive. - Endpoint cutouts are true transparency and survive monochrome use. -- Separate PWA maskable and Android adaptive optical masters enlarge the face - independently while keeping both handoff ports in their safe areas. +- The PWA maskable master reuses the same optical geometry at a uniform smaller + scale so every handoff port remains inside the maskable safe area. +- iOS and installed PWA surfaces retain the robot mark while using its existing + small-size optical corrections: slightly stronger frame, eyes, smile, and + handoff ports, without the eye highlights that refract on iOS 26. +- The iOS 26 Icon Composer package renders the coral mark as one flat, + non-glass foreground over Warm White; it does not introduce a second logo. - 16 px uses simplified solid brackets; 32 px and above retain the negative angle-bracket ports. - Horizontal lockup canvas remains tightened; no terminal-cursor treatment. @@ -29,9 +34,11 @@ and iOS applications. - `svg/hapi-mark.svg` — transparent primary brand mark. - `svg/hapi-app-icon.svg` — full-bleed app icon master. +- `svg/hapi-optical-mark.svg` — transparent iOS launcher foreground matching + the small-size optical geometry. - `svg/hapi-tiny.svg` — simplified 16 px favicon master. - `svg/hapi-small.svg` — optically corrected 24–64 px master. -- `svg/hapi-maskable.svg` — PWA maskable optical master. +- `svg/hapi-maskable.svg` — safe-area-scaled PWA optical master. - `svg/hapi-adaptive-foreground.svg` — Android adaptive foreground master. - `svg/hapi-monochrome.svg` — one-color mark. - `svg/hapi-lockup-horizontal.svg` — tightened horizontal lockup. diff --git a/assets/brand/export.sh b/assets/brand/export.sh index 6c60edcf6c..313ec4541a 100755 --- a/assets/brand/export.sh +++ b/assets/brand/export.sh @@ -81,13 +81,14 @@ magick \ "$OUT/web/favicon-48x48.png" \ "$OUT/web/favicon.ico" -# Web/PWA and Apple touch assets. +# Web/PWA and Apple touch assets reuse the established small-size optical +# master. Opaque RGB output avoids an extra alpha-compositing pass on install. for size in 64 180 192 512; do - render "$SVG/hapi-app-icon.svg" "$size" "$OUT/web/hapi-app-${size}x${size}.png" + render_rgb "$SVG/hapi-small.svg" "$size" "$OUT/web/hapi-pwa-${size}x${size}.png" done for size in 192 512; do - render "$SVG/hapi-maskable.svg" "$size" "$OUT/web/hapi-maskable-${size}x${size}.png" + render_rgb "$SVG/hapi-maskable.svg" "$size" "$OUT/web/hapi-maskable-${size}x${size}.png" done # Android legacy launcher assets. API 26+ adaptive and Android 13+ themed @@ -105,17 +106,18 @@ xxhdpi:144 xxxhdpi:192 EOF -# iOS App Store icon: opaque RGB, no alpha channel. -render_rgb "$SVG/hapi-app-icon.svg" 1024 "$OUT/native/ios/AppIcon.png" +# Legacy iOS/App Store fallback: opaque RGB, no alpha channel. Xcode 26 uses +# the layered AppIcon.icon package copied below instead. +render_rgb "$SVG/hapi-small.svg" 1024 "$OUT/native/ios/AppIcon.png" # Web application. copy_asset "$OUT/web/favicon.ico" "$REPO_ROOT/web/public/favicon.ico" copy_asset "$SVG/hapi-small.svg" "$REPO_ROOT/web/public/icon.svg" copy_asset "$SVG/hapi-monochrome.svg" "$REPO_ROOT/web/public/mask-icon.svg" -copy_asset "$OUT/web/hapi-app-180x180.png" "$REPO_ROOT/web/public/apple-touch-icon-180x180.png" -copy_asset "$OUT/web/hapi-app-64x64.png" "$REPO_ROOT/web/public/pwa-64x64.png" -copy_asset "$OUT/web/hapi-app-192x192.png" "$REPO_ROOT/web/public/pwa-192x192.png" -copy_asset "$OUT/web/hapi-app-512x512.png" "$REPO_ROOT/web/public/pwa-512x512.png" +copy_asset "$OUT/web/hapi-pwa-180x180.png" "$REPO_ROOT/web/public/apple-touch-icon-180x180.png" +copy_asset "$OUT/web/hapi-pwa-64x64.png" "$REPO_ROOT/web/public/pwa-64x64.png" +copy_asset "$OUT/web/hapi-pwa-192x192.png" "$REPO_ROOT/web/public/pwa-192x192.png" +copy_asset "$OUT/web/hapi-pwa-512x512.png" "$REPO_ROOT/web/public/pwa-512x512.png" copy_asset "$OUT/web/hapi-maskable-192x192.png" "$REPO_ROOT/web/public/pwa-maskable-192x192.png" copy_asset "$OUT/web/hapi-maskable-512x512.png" "$REPO_ROOT/web/public/pwa-maskable-512x512.png" @@ -125,7 +127,7 @@ copy_asset "$SVG/hapi-mark.svg" "$REPO_ROOT/docs/public/logo.svg" copy_asset "$OUT/web/favicon.ico" "$REPO_ROOT/website/public/favicon.ico" copy_asset "$SVG/hapi-small.svg" "$REPO_ROOT/website/public/icon.svg" copy_asset "$SVG/hapi-mark.svg" "$REPO_ROOT/website/public/logo.svg" -copy_asset "$OUT/web/hapi-app-180x180.png" "$REPO_ROOT/website/public/apple-touch-icon-180x180.png" +copy_asset "$OUT/web/hapi-pwa-180x180.png" "$REPO_ROOT/website/public/apple-touch-icon-180x180.png" # Native application assets. while IFS=: read -r density _size; do @@ -144,5 +146,8 @@ EOF copy_asset \ "$OUT/native/ios/AppIcon.png" \ "$REPO_ROOT/ios/Hapi/Assets.xcassets/AppIcon.appiconset/AppIcon.png" +copy_asset \ + "$SVG/hapi-optical-mark.svg" \ + "$REPO_ROOT/ios/Hapi/AppIcon.icon/Assets/hapi-optical-mark.svg" echo "Synced HAPI brand assets from $ROOT" diff --git a/assets/brand/svg/hapi-maskable.svg b/assets/brand/svg/hapi-maskable.svg index d94f573599..e729806dac 100644 --- a/assets/brand/svg/hapi-maskable.svg +++ b/assets/brand/svg/hapi-maskable.svg @@ -1,22 +1,16 @@ - HAPI PWA maskable handoff icon + HAPI PWA maskable optical icon - - - - - - - - - - - - - - - - + + + + + + + + + + diff --git a/assets/brand/svg/hapi-optical-mark.svg b/assets/brand/svg/hapi-optical-mark.svg new file mode 100644 index 0000000000..3fd0e450a7 --- /dev/null +++ b/assets/brand/svg/hapi-optical-mark.svg @@ -0,0 +1,15 @@ + + HAPI optical launcher foreground + + + + + + + + + + + + + diff --git a/ios/Hapi/AppIcon.icon/Assets/hapi-optical-mark.svg b/ios/Hapi/AppIcon.icon/Assets/hapi-optical-mark.svg new file mode 100644 index 0000000000..3fd0e450a7 --- /dev/null +++ b/ios/Hapi/AppIcon.icon/Assets/hapi-optical-mark.svg @@ -0,0 +1,15 @@ + + HAPI optical launcher foreground + + + + + + + + + + + + + diff --git a/ios/Hapi/AppIcon.icon/icon.json b/ios/Hapi/AppIcon.icon/icon.json new file mode 100644 index 0000000000..ddd22a824b --- /dev/null +++ b/ios/Hapi/AppIcon.icon/icon.json @@ -0,0 +1,39 @@ +{ + "fill" : { + "solid" : "srgb:1.00000,0.97255,0.97255,1.00000" + }, + "groups" : [ + { + "layers" : [ + { + "glass" : false, + "hidden" : false, + "image-name" : "hapi-optical-mark.svg", + "name" : "HAPI optical mark", + "position" : { + "scale" : 1.92, + "translation-in-points" : [ + 0, + 0 + ] + } + } + ], + "shadow" : { + "kind" : "none", + "opacity" : 0 + }, + "specular" : false, + "translucency" : { + "enabled" : false, + "value" : 0 + } + } + ], + "supported-platforms" : { + "circles" : [ + "watchOS" + ], + "squares" : "shared" + } +} diff --git a/ios/Hapi/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/ios/Hapi/Assets.xcassets/AppIcon.appiconset/AppIcon.png index 3e415446246d047171bc04f6d886037ea7e6c3e4..149ff475828a2e2e1b819f6a9eff13ff6d0fb623 100644 GIT binary patch literal 56557 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
!i8r&WpWyge|BT5kVb}55{3-8=S6u3S&Km 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
r)^TWuff#Z4LvN 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

T@tj*qSf@c8;Ykrm#T(ko(rR(`(qG zVf0t|$*lOm6JGeOeF1t`=i8o6VpV>feEimEoaei6+k8gVxl|O zK;@rziJrx=-cF7jU<8A@%-nxxm;|-5OYe_Kre8}?o{KHTqI7asntqjnXah{Kb<|pv zb&hE;dgRoS82Yp)F!G71agm|2o-6=Ha&cc9o_thWGb3g++N9}zkHCKJ*d*_tmJoiv zh(E|^;`P1Cag01#mw(M53)G_N-K{?MjJ6_wkUI1XYonPx-L}=dkCp%eFz_D;7?Sxh zTtEMplQo4RYS*H+CIgei-{t&((eMe>7{MOvpM)k}X4Dbrk?ZjtKGtkEzl?a+QA$eZ43^RhjB8XEk~T8DHu(?`7rJqhIb`Q>{B>$%7aYn;jnPT) z1f32wZ5&$w8=0S3|w?4af) zBKO7Rof+nI@>&%g@^b+8lJWsnJ}mvCrKT{)4NhkSrI*L6`$ydZZ>5OPb}oQr`%WnW z?nif#2M51K7J?~Yt0gK+nc@1M2q)+%Uz<9Vf^ehpQiO)9NAKp^@>+RvfvQYz4CO^q zh+kal@NmTTY(9~NS-C-A1<|YD5s~p(A5*X!~7O7z>C`Kp)s>?;Eoj@98TBC z;{EiDTJ%QnrcK>ySbed#j9I>mtrlm>}sBK7(&vFsa0p99XUopBdrjN97 zqd{=VI79yj)R8Cbk+qGesS0epVOiZd{o2gdH)P>d5Gf=#U%Qt(HF_Z6@olR2Gw+O>6s9qEjq5d-a z=fscTqG)9`zF&ys4in0bbt8jP%5;gDZ+fv_Wpb=nf1l+P5x@mU>G+}HWE9P7x9r;L zFLXc+CGd7^sb96mWaeZd21Nkm9%tH=#J7B4FL-rxfRL@qL+Xaz-WB1C#~2Q(9D^Gx z9;7?ZCdZsXuFVdh(@_IWE}MpXX^zLK$rt0|-#Ff?8dG&Z>4xRr!{1;f2?&WBZ_~lN zaH@1z`1NnPXC0I-z!TPCD#h_hv>0r_`QFCLwf#z&BP(MKfrT1}J6HZ{2}vQfqiBr{l5Ociz9RTa8%}&@hqC{Ul%{)&RC!4s zouHijTPKL&C4r$AeHGH;Sze=eL8f>Bn+PP!NQe-t_|~U#%uUi^Y*fim>|mWUyRhh5 zfEpQo*C_}6d?*}ZCS}vo`y3Yr%2+L*Qz@UiNjvmS{A}TLp^wAAIQ@iSQ_U{MBZ`1M z;cNH#=qLIwbJLe2hrIbt4l>qZ+0`fVTC2qq+X6##TK7Gj6zLgCwU(J2A45Myb#74k zW-WUh0zfDA0lQOvDv44Xe%C9NxUTb44=-$>m(Eh+LbXpx0=|hCUePYB5&ZKKky2&| zzmk&rz(P=I7XSn!%FQ4ho$#v@+60)(qTcz>>SO^_QmIz~4}daLt&w)-uKULUDspeW zGZ1osCv}RsIVkK(6yPA)1@>IB`PV{lraTpy;(g>^yyGhmf$m+1ZC1NRx}hZJnOj}i zxA#9ruz_l~A#?rG6Mqps+>|=*Z$okc7F*eDE6B5m?XlQsKGUIX;66#W`tpw#z$-?ttJ_#1-*sc~IF&%3B z_VXDr>hYcET+-KLBaEX8vch;*yR)oC3jNS8kC!K(dReC z8y$o9@>x!sHP|5Y*aE6Mc9poMsCC$=^0O3GlXXIw8(}{Q>m#K$gd4Vt?)-$JgCZ>~ zXH({P9UK&1X9T~sFWJ^>QQn6uz|5VE-XUrve``g2wT%1(-Vg8B;^ z0zG^cJT35N@W&dp*t=)EY4Q0lUsEbfQ7-ErReD5>w)aNGIu=h*Y4;B7* zql@0l7G#>D-NEfayVF!hS6y+JhdC&Qn=C*rwC*ywd;j-3KPgH2#eVmj!HM&a0b!av z{I+=G{GqO^Lr@YNSD@QC?>>Z~NH;2}3Wzs7c zg42O3C7AE~H#mN1$8Y`B^4jqdIxym=xYyYg^W9mq9ZPr1iu<^d({V8Txf zBu37ZyIEzZ>Une!OL$;=ArQ}cmwF##wvhaL^Cu<1^ZuTDVS6r6f z0w6E00kv*r?)!%n=d8#`ScRj%!5K$qqTu4C4jQ!@y-Et;M0l%~m!~&gdH8hjb%h0c z))2t!qEonHdaK`iVe4qe>-g&G;WBZkP{aiwi&#B4Yl@P37+tN$S3)OF^V3T&lcz2X z+^_m${i`{Pc#f`|3(?S_KTrH}x!Q{I z8TJN@EJvu;4xVzEhZdPETQNE!XV%( z9k#m9c~}^#$~%WAGXRuL@CF@uoe<1-m$l0Od1375Kk=%z=iIRcW{C8OUz%5kTn5EHU;2CBc+yhu4!JjvvseLyj6)d9;-DVD-SzQ zgiAGe6JPPugEV7f$PJVut&hDgE#w6k$ZhAP3#xUlaMl_iWw1vVvrgdW8vKnSC6V5Nq{)H!RAo-SL&G7~j7C zkEg359&Y!m%GTnFa;@3PA;GvF%e43+3XiynK|r^#tF^u;C~%E2{UX)B9*!bkjt$k| zLj{H8x%mL>+5Ny?Jmp9?^<&d#)r|$rbPu1HSYhjIOT%5@Lr#FN5fft?uePWw0>x){ z+)wtygqWNkxcarV<6}LrmUmiTmfNUg$vtD504HQu@oggNZv7hNiFK}-AhE2d&Uq$s z@y*40z{7C{7mx)a2FHJ)jlH;~cY8JM*TY5hhn9aCv!Zs4u7PqZ(!JoiJP>)*;JNj1 z0<(3WRUN#zUl{hgD0eXy0x6uHVHlA_&1H|LnHx6(ESgC!*KZAtY9KCf(_FMT8K_-l}*^u8fZrXTiqqkd{49&bFWK@gz<2|gM12B^vgmh<;M0|Zu$ ze$myN=WNArYr1HN$CRf2>fPnrdlV>-sh$%mC2mQL{I5JbL#xN=c$D3kcZ?CZMQUyG zX#roY>tKWN9bq7Ye)6N^CVru(5BT_ETQpGIwmPdEm;gg$v6v()m5m&vB#rqmy|YUm zT1Mch0)oBx=*I`|ZH#8php`E5i@k&zTa;J&=)%A^Sa12T^CcIjE`9+($npuM+tY7A zazcCUW=par`GX0yTIB}E_RGM-Brd+`tj~_{76F>3>LO70i4_ipULYDY$Nd=A-?s9L zmTu0E4yn-tEQ4bSFPk&=Pu<5OQAV}tYr}6U&B$2?Ydegs-)1C)AUsLFP!!;@MMlxXf-jSTNyi*_TFR3e6JI^MO(9o_-}N5E&5O92W| zptQ0_(i$9`_q#M%{`7YtB{<-)^Vfz55X3)d%_Z9eL_dS)`Dpv^n>%QlcYhT@oKl+y z{J9Nfj~Y)$gs(5jk;fQ852!G@gCjN|2w)2S$DRKPtEN45utZvEq(6Jg+QpuZ557-^Pl*L5XXn;T_ZP1_GI_Qt|wS@9|OqYv&*_e$3<~6fTrRQpMRpe-DD% zJ|uot$?eToP9sr5fb0s_X+UtXa%E=JvWkw+pULGAk=1`?hvCGNLDv8eKmM(!+ge5} z-$MtI<>Al|+?7a7b_NLM-IriT%kP#pH6mZ}1b(F>Va@4c59bT-cOnfa5a&8hA*zHp z_Co|tlOV`b=c;vn_P^-)*VoBt@OhHbms!KvLplAZuEE=`fB77WJHB>{#*Z{0k(d?^ zb@wXg@IDgEr%Z&P<*z)CSYu0*(_gx!Xa~Q6X z-rlw_>Q+=*A!v7QY^($5PM3$Kw%|Gx>ChousC99ps0v$&L8cv9#YLdUVjP}7Y!A%2 zOv37>pcv2!LMYa(FPj4D)8ejr@dC&qwq23sl2u*?svIR9GT(Rh>&b4g%3pxE>Ews( z^~$(4AI$hbYP%8uEZr?j>J3vn=%$4tky+?QFEM{)>0{2rz?@8;4zI@UIQB)7JK#w$ z&*qHO2kY6~%^5%C4hf1}yZ~05$==K-g|d?p-v#Ox()gSD^}nk68*h9BHvleR-uyE? z<*##dP#?PoL0+y!f^R-2lA*nw4icP9iFs@|u+^o-q~Jcj#lp7IlGe|<4bN(x@8Nzi0K6w}?yP6^H3e=L%4X*V%IXd@srlE|H`w~6A^{*t*&_g>@0C+C^h2qy z4crSw4_4@YP;Xp*0x8`AC_qa|3rcHK%F;BNndIwh;TXS^OAza`F81`{>coM$M$QV9 z!KE4+PfAkRm4uMPS+i|J`^<<9;qLb!>-RQ@5&1ms2SH~1+~i4=26aXINee$Z(i%kl zGoIb=5FjgNgR;XD8yWL^msTfa#knY{BMj4Ia>WHdT&A|$R1{zT59rU zq0Vc8m@YZVdsBrrr1Ihhfs5uVf+KzS@gq{1JSd>!nm8XVdpbjImOPq9MZQ*9#Q^YsKpaA7LZI|l4$3q~T6dqpy#J}-d0BLWlhB=2q zk!%HQrddIf-!EQax2H-!|g{Dt{rbeLc~@ToU%;W*;W1`EN!|DZV_Q z?&dlef+_j4Zntpm?~;Bg%Od>}<23-tXYHN&UyC4Ru4P=3(+a8EqYzzr&dLF9$ho?` z)1lQcjiD8FQ*y!Xf3Dstxw#MGv1?&~W_wb5!kgSe_62B4^@{~?cyj^1wnAXGnQYrO zPt>DNTJ{MsQsw}i9vm!)&Vuej#-xeL{H~Y+&R(I!#tV_Iw`T_eTN8iBN;0jn@okFkpk}0by*c)u5Cb1 zVRYh`yoB+7^d%=VE;N_KSjaI6u;W5eFnA|tG>0&R!)MboG$sGf25jp43-{T({aLmJ>u-lvnQ{!S(|TjpB1fJ1sc z%j^S~!YvPno-Uvzq773(QLHnosLCjxO zW(`yWDbNscn@{GN6yo1a36elpm0Rb!d{iGbkTQI~DDKwPGPdmO$6d7w1eu*?qAN(j zbCGOaP3|6u4S<}_f|IgwPN z8$BQ33_6*%u0~|GKp&~wj^wlBB$T5kD%{&aYrr92 zDEk2$Ti}4;=f@y-1hlqRA5tFh+d3FztgW7keD@*;RdfR=Jn${%Uko5dA7F%=bKhPA zSvt8Wdn zE~+`!Ll0+1!Y+FWZH3%_goOlcfCL-WGxBC`=68+ssw;Y=RCm=>vE3ceyVs;w+f$1C zJzsU2m9`vzJiOj68aaQO%svLwpb%0oM;@Gh*jW0>atjMy3iyISq)sOX9~}pkksj4$ zU*&R2h$ak(ZMZlLKv&0P5?=#`rnsmb@N!KV@+DN{L#u$weVZZfk&zZsuQevOpIc8D zIcOjDH?$Z0T!*U~*(qd-?zLlF8n<^kbpKvEUnH=iZSW?b{t7?II%y;O9guCtlu7<; z{#j#Shgr{_IlMx|4{L70^)Xpynsjbied;}mz^b7X33fu>axqO{r)?q+#DX>n!FBsQOQs)<=*caM(Ncn|Mzr7^SEB|eFp&QuhRK^dR$+Og#&>-vG_i94sjQQlA)GB5dnp4aVvYzs zBo1y%26_W#|0eMfT)6Of&AoMM0NT&H9{v8*{%gU*)m!kH7EF3l>N{T6jxNN^vZVt>kSt0^>e`#Sr5(&t+RY@I< zX37>T`Tu>&r>9L^tOAJHPJw#W-<9oAWYGPdVMEKSM_DmT4R~{|%f~>XH@^#x1Z~Yw zPRS~K0g^rNEVBe4+Q$YzM!4@vWd(;5H#4c4E}WVi3@hhaz@OQKW$oDuZV^!cCN#^a;lk~df^KkF7;7x#B%#tp;JBmFNKKdVGj4xB}All(W7 zDj3Q#FEkh<1>ko)zHgb)if2fSuvaLSL=={@{qV~BV(^)W5S(a}cdmh0Eue#kg{0Y69iWFC`jh+bSgwG#64!nU_k$-+-odd0lF^uuu=oTQ;lf{CttQWh& zjex8GQ+UsNj~#W?B9_Dk8)mqFt*j3|*)n$QAO1T1k&c{i)(xN%llC@6>;8)OnzriW zf9FnOeoYtxz?Hz;47m7?hGC1qQ1*b|reSzUFh~0rTYj$p?f&BD3Z%IfQg``7|0YO^ z>*_Cmn5|0)S@Mjf*NjDop-R5CW*Q|>7wB9RE}J}8oMDiXVF2BD0zq2p@^Th}TZ&v@ z=M1OHQ>B@xrU-owrkp)CC0_=fZLH7%$p}`cc;Fe5H#B(P+ue5-uvP~}Ql)9YzI?lUb~ez7;{1f+z?E2&pVXZLs27*I z9mZUc_D1etud{vXPoN%&NVAp6t@c6Q4bJ;L&~bINHG+7wAfnhRyN9McZKCIymqmWxl0FR=rX=Hf%EWj ze;1e0mQQsB=r)l>?Nm)F4tshcxNgk=C*Z;U?SX4-*}@V{?Aicr9}bS%ar3G8FTn}( zK%M~NUvu#W5T_&Wemqg`U|bx-Ywu(M9j{Hk^|Sq_E#Q(KE3)ZicDe%U9n+7aI^yw;t68HbItGc)_5ukSafUg~c(F>z>0Z0d!%B3IUUIL3O z6r?dzUkl#9;qM}nqCh-ttJBqHprEHX#g}V|_ib*}Ro3C@+@tBQwoR%3j4kx+Fo&!E*n3VSG05+w%;1s)z;D~Psw2VD zxdD&m?Aw|T4C6n5dv`k=Omy@b6!1s6z}v$I;Mfi0QHC1hK1KXa2vvDttzt#XgZGfU z!M?xQrp>YDC10KwS1}C(ZcepzThd%xK1HQ?(P-lcoqB&XDnujPef!oX-8!jKwR6oJ zbfm3~9{BWp7^rmJd}b%TlCu&MZ1{5ro&n0e8({}PsI3`0sEhqVxt4p`fP)EwSp7;B zN4YEmb^%Z?E8ev=4lpNrUd{{av#C>efrmK&4|9u->H%!v2^*@ycKzV=n+yH@DAOjO z{|S4Z{q%^oFh;#A`>9g6a^V>K$$h&XY@#>VL|4t&ncp|n?aFyWryM^%Lr@Fy(2mD?d00HU^nTt7 zzLBIRhiF(lv)*%6i<3;Lvnil&9kSNT;%>hEHr3_{_UOCVCwCI!ZL1Vz9@T6-4HVh< z3@Y+W#AHK_XXy}-H|MTK!#4`l5W^b7igu~(;h~ZeepB{>;y%JrR}?P~jnDfIZ-got*oaolYmt7hA6Vx$)DO%7=M_(#hr1q4j}IWw0$2 zAc`-`gK#OQqn$mx3t9~A6uqTy{oPdAA3XdN)kB+M;427DL2Jgs2-FQ-fI=^`lfsG~ zeG$R(T=!Ys|28)0`)HiYd$N6!+;8=O{a~}QgbioZPU(7yw0 zKPG_$(rQu5{CnaDxQfB3yZA;&Vdl~85Ee1sk%Pl1+;uul%*^j_CU;-c=0kM-PL5m& zMiKyo4FZ}E4ae?p-?wIffeM3|7b#r>f?`IxwbKBd9-)HM+c-cK9D=U4O)mJUb9@dP z8}SP!zurT1(~brE<_j%e7umjM;(F#S`uT})G-Q*(9zAg`|8j_f)1KmolYe3#+?fiq{({ZZqwGu$x z9`}cbD|QODJ=+PFj}l29vR9`4WZ_7)8REWO;>Ze|`Sz~>(q)sZaKu;;OiTr6nonaa z&+K(nTfb=k)^dLE@OVQ+Hrnt_qDb=g?%>f#0o*2>T)n;OoJsS~q*rzoyrFG}JlitX z4C&L-*}%X+Ri$;?i!)s&8}9uXA>WEb15vu$3jKn8LDBmkK6`FG-V(U_b(WSXIOsOP z=&nBdmV*E0I5%SLtRLNz`Ri|LZ>WbN_UM7*9WXg2khWa$t|_Q`wO-KzD;d#@E(-o- zY)xz*3j()6=o08nvIT8FSA!E0Qt(GB=_;s?WyacSe@>2422_UPszOT8m8Ld~ z(Y}6-X>Yv>G-Jg)UIykA9*$EUK%zTQMw2mz%0{nTZFxJHIa$_cV~9cEG31E_y?~uJ zGbF~e4mS2VBAqSiK}&t8Z%yy39lvO|-gd4U?54-TI6i}gd2})mpUmu+ZuWLvqv zZ+i?2eAeni5Tb=~@%ZHu_}2MA|Muj#0iZ1n_rreX3;yeEiH? z*KoF7w>!Jo4`y*zONM!Q!HN4PE>5(SQmEa35pj}KIa8oh+uM)niXv>nx#-nS#i5`V zlZ|WsBkU3T=IwRT&G)E3o_C;^PP)IkeQqd-GX4a-7?4AN9|KS{nw`w$35;R=BN+J- zvFp{b_#i!A@am#MeESDRqsbA2F%Z16$Al$tcSlDYBQ<*!{9Okqv_jlTUdq93fuskq zygV@iAiyEp(`GZNhdUICKs#dD36u)^@Kf$3lf2e9y1#(tPMZZj-PoIy&MdufE7`un^@pz9X{-#76J*v8%rn24qj42nL*BvRc0 zQ52v7V<0p+>q{+3TR~nOh~2p5Fu~eR1^sWGL#7+%G3cDd>K* zUcnsA*hIv0G0+hN`r_?8%o$UQhX3r!rxaZG#Wzudh8KHQ(}_M8Et-z^Nba%i5P9d{ z_RFzeVn7EQI`6_}@7SIXT2vl|$um*G)jM{-5!q4G?LFh?*`({Ucm8*2dO_y`f`ea_ z(ZNx-;69*V<7}fIbdRrw9nKn#RpKpRj2)b@W&g5yWytUxr&j0hu1^*%%e8Cg3tRsQ-;ZMb zts;MqPU>O(+G^s9+|3NKzyu3-cX7V|2|w`1aNA4^5y;cg=d>9#L811#u3P~q5ngtj z(bv#FsX)90+?Jb2>>NnGC_G#UI}zTyHC4UUc~<}lq|MG?i_Ro6sm&>{A8J3I60tOP zhS1-=iB9EV8gJYK!=lMQ|3YcUne#$(BVafEvy<)4~5*MqEI z98uraD1Mr%R=mxc*(A{VAO36@FKKcrSF!IBUX{=Kp=0?r!@rsaXim{1zi_K?JB*NW zF=_hv@m&M;A6v1ngSYsj-7cHtVMdRw?-OGZI9?~v;FVJWizN%N|EUHkiM|2OXy3Y; zyq#r*a+@G;?wi@7QPk*7v&VpR^IZn`tp+)`DXeiMlLk~+kbKYb=gipY%*wq_ZVCOB==-0e z>|kubjBdy59c0}~KgbgL9ra7?vsldCgLW>U_{1LMy!6thl+kkqZz}c{5P~dhIJ_Dl5?Ngzx*d@a_JVZ)3aqW5~i2pL^HkueWpC#CRI@ z*vJUHKU*qC+#1+y?LmC%wv&Oc@Uor%pWN!2W|TO>_AIv=?95oI1+n8?-i3Q=BRUZJ z@;`Elbx#qAoY`{3E6F?+X8poBfCsL4mrBF$(7fFea7;)_u2a&IPcDNJ+Fj^wdS1n9QU}#_;y`FPp2BD*hJ}$qOI( zw7Nlzvaf}7-+kiiu3e>Y$z#Dy)&s1F_6+BAs`V>W!xy7w8fgsU$vA24aplq?du`Nz zrKNI|80IFevl;PInxi6p_fIyRvW+-NL+Xf%+GG`ZQk-^+L4m#F!ojw(w0w4-VAuAf z4wlVNN#g*B^mo=VNZhR{8)XBpL1j0CxiS%Rqw|dv`dBrvp$^HqyMRzorva%{xp8o2 z>4#?UQ%+MJQpjY51j~MGac&+g@aWhF?rt39P5sc~L!O@Cj4ba(Qak{a z>a!TtBH0GQ>YK204cSUb^z%z1I~R}%4xlpI3{^{Tz) zCHp(xY=zIE5rk_w$^axV&deK)8qzu72`+aBUwu-7a7xl{&D-o3)y_I4x7?r8Mcf+S z`_k2IR>>_GJu2tbnJ|j{qshujoUXc8?KWVd{k)vzxEjO)&U3h-2Jd+DYQ3bq5Ryg+ z+>o`Y*6Thpv0FTmL*}LbWZFNp%I(KHyG=`}sb?bhqo`*@n?@)u-C59r*|Wb^?@q;M z@n>%KK2+O!g%<3~6o9u5P!~4{M6DwJ3S=vO7?lBWpL$O6IZdyxi=hoyp#Z96&fJ#b&So>PX^Vmp=}0X{>c>KA}KSYdwb- zGPXn2|@kS z=ZTikyQ94Iqnmt;pWHs^9*f`FI4>9hzS|i zzm>yOF?L*i+p{@6l$YdI7Hw4cGQ=<47p~_p8WA_u4ci)@H-C}#LWJ=bIZ6O%B(3|R zM@sqZ^H)#=*R{QaUJ0N~2gdz%b-?Erq?+qKTE7FFfj~Av-#%i15 zA^w#I@&=KcE?y$Mj860KwDFYRfzEnt-&{~A?|6)<|2#5h#eTMbe+hX>iuB-MJRxi8 zkeH2p4aVebS8L;8up;$x>NV|V_b@INRXcE16z9I#-=)8!5ahVs2TRsHLYWv&#%L$Q zbhyqa&gH%nIKPyC<6o!FdUJ*GpK#f(N&ma;KOuP?_@Z(J+~+IOyXSHzN!?ycnQKLt zV0q6q5{luArOJKiKFB6hI&G`Yt^$S4Mjob<$_ID_grh91khS-THi2d~>kB!I*X84j zjc~6@mM(ug03P^fo0ZPMg{_CIuaEXf-R!oy)HRxUDboae9?)O*O9$i#PZ2>G58||O z6nDw=pA!(EM_)W;GW4&lF5@4;w=TnYu9SmL>$RhvKMT0l`3b6WDclC^M>XfPR{Dja zJxh1CztDOA#qz!-GPyoZGd!3BFXX7sb-WFxrP%LFr z+z+tB-&*^zs7F`%Dr@DP4Rq7q1!wYTaCV&8^hjA{zWSriqgu5=Ok$7fVmJ2v!#Z(L z1}Al$02C(U`FpR*w?1DnyHX!7;X%zU4^rTO#ArqA7fJb8^I*!1zm)@o-EQvQwE>PI?8_KpiRSW)hGecz!n4>y(+$5|!;)R6~1KB)JY?0IY@-#}2(ERQYP zsxz$aeW~W89Miz|+App4+^_ks|5G<8HXZHn7g#m}K%#pP8n5Q%5~~;QyW2$-a}2>y zhvN%+*YXiwQX^MIwj10jb}Gd1sh}%YZqKqJEb?*OeuVyJQY#_ULi~HRb>PXSRJ{)< zmZgr8=zxMR)7^^>yd<*yC7Sq~v-&-o8*mOQ6-v7q@C!|YJEAh0sf`Z!0TNlT?%K6$ zfl@%`&n@$o2pY6Zo$sVEWtP5seX3QR!M8%D$F_rAi*=M$Z>m1q~oCKfpA zGFH`d{9U%Qu3ok+IWFgf=Ur*cPktVW{YpgwLE_;qGf?5KC(VjYi*T`@nVOP!} z(f9WEiw*?PQ(kIQD&fvd;^fed1br_u5BE!(_*vZz#Aeg48t2W-)#*)70(Xx|C|3hG zi8-VUMHA6I$zEi{^pGKnRrHgC>Q7N zmoo^qpig)krlr3hzEYE@zB6*I*(VvdSRBF-*V*NH7KEjXJ}Y!jv_M9L-nNRkRrFVh zsy$#}GS>1t06r3OamscvAHq++vSd_y3!_0uk}fc*@40q>Se_F6Z$Ap^aFwG=)x$Me z)$Y>R>hWk482>9o?KMWCd#P?Jnp9VI-&D|~UHc-ClyW=T>cq_{7rAx%xp&pjaNK~? zNrk!Ucz9|eN%uIjt2?=#tCbGjVthMq-&7ArN)u3-H>Lh6M4K40N>!f}e2@5|n+>ME zKk7<9VIN{3&Kr)4=rc)0k z(S9hgR#G5c9(Y~0f;l2`VR-^EwX=Mtd4#-FGmo0CznrkLFq&^P1*C{krrqU%JX~PU zaV6*}gGl~;uV2|FQ}XdUi}k}l^tL*ErX~W=^o+{g*{Fzol{+RG__@rGYK^Id08L6| zaD7YF)x+keVuL&t*vpH}kfi~jpXJNH=46FcfAm``j*@6gVD&u5@7o?0=tj^U_`svK z;-fY@A;HWMyk@bY{sPUL(J^4Jx%hfCM49A%@P>%(T!}u*zFYR1&N$lb!ptVcY7j&K zZ!0|9m%V$}uR<01{Jmc*d&G}Fuy*!QbGvDzVeXoCU*+VlfioO6qp8WbvLFLKiB8`B z_nsJ6KmyY5$v8jtqjqXS8giw;-F#&su$I5!*lh_us{Xt^YwYRKw@Im<)m~;FM4<#P zYH8EfVZ^Hpt4Y^4R6AlMCn~c zvHk;G-^IuDfVPrE!xn;elFty=6kS;*e< zI(xg-UH~?-QboeWrTM8&6Osl}Y4u6=ozE=;%SZd2QwgWXmz$Ro=KU(XdUT*F-6SqB z$)3yakiesqxIC5^b$7Y&uns^V43zFHDGIkPiv5jeu>lqFgMMVVRGmul?HfdaS#J|{ zLYw)ocJcn~hPFW~0;55L=9U{SkV}rNE^QB^XrC6m#EA8eW9^u~IJ_ia9(Omy4wK#K z!^n2xop4$8U3kui6gmsNJh-P}`pqZ#FHcHJ=lLiS`j6Iw#ALI-s#CQPZw(bgXzh#p zHU>Jk60kP9idZ{pk4tVk3~U}JX0OprcXX5t<>u=R=mqi6;tbQR zEW@X#YfoELxd@2bK5d-zk6m7*Y#b<@ZAqQ!zjQpvC3ra`F$nr|t=L=dCU4eNkuXpG zJqE-}>V1okhoaR8RxJhpLd}~1)7Th?oajN+m0DE{NjbO6iPN-M^sCiJBEoQWWaH13 zQ*2D{6#KHP=TrReoS=ROMzr!`pN5~hgjXZ_KHPEvQ>(OC+ae?@egL*?2__r}fBO5I zS!rs20J_3jsv5*ZR9|gO|8n_IB4uLx43Uy_Q|#GohGHO!!e)spqZfag>Sns0d?(iYjtbYZKIhd zDQQ4|+P+3floh9^Y!rG%X!=u1o_aBDZL(-M|G@br4th_LJLW0Y))CYiMTTk1%ZT~w zgGQrE7D$SE<7Tm;hp|`)?kW(4K2VH{>b%Cuzb=vjX}bgJhmth-B^BJu5|(q89A% zTWo6S!0Q4aoK|-HsCLTz`=vbLn%@Al8lb>UGMvw8_qbDJgIG3c zamG5nENsB8^}QHP^dMQiXo}yzH`6@ZY(_UxqG(_K?s_?ZHE)+!Oc@!Ms7JFq+d15r zdJCPtZpP(j+2D3J51dAx^Ez7(0&Js{QEyAS%?lc+U{tpNPSh{R81HFZR6Jz;Z>qZ* zqF$&$kc8s(tDwpn#-l=GcQ;nRun+LU^_K$oxZ~q+QA%x#@KQeifg1w-NMPh1?Nwiy zr8!5dbX89|v< zxJ1100bmgG%1{N*T1hiM+U{X*kboeQBG1WAeqEi}q!eYxh31&5Ou{LIh9r5Tq-1xV zG$|P>^amkcWyDC22S^V+Kwu_f<{o+BVS;T_5o_gOX3_j&=~td5=Akm!mY)~s2n%!V z{2czP2VMNObL3?s%Q)Z-)&K?IXjEk23u})hK3P_f!|E*crt?uB8?7WYr1at&oj}$mfoxjdWvC0aB}Y-?)W-zzn@uU1v1^ELVMUfx3K* z5F0)U6;-6n1nJ|QU!Q8`>^zOT<5}de8;**4>LYIP=nT5PBqo7GGbPDChlqg~^arV` zXbW=2pis&nd5=n_p4Z6>PaEBxwau5nq>6aD`=RiZKsMy?R`W|sv5)0CKbn)!@y}Olv zdN>FOI!PS#CjMXPtP0PtpBig0E(g&7?-VDj_|O=|lLBQxT3o~1xL$5m$+WAUUE|pr zgTetw*D_E)FOJHA4GY$^RsX}FR8Mw{h88Ii$Ht+P+fZxN*137COFA5E4q8tp+O|*n zSx#G;--@~NRiOvl_)33MXYOp1E&Q6Ci!(>g89bZ0-pj74^D(Wu6j;Y6BCJ` zAX4yi0xK@-t#ynebafyAnq2d+(SW1f`ml_Z)uhs0J4aec(STH~dKEA9Y@r>hVvrV< z6*G1D-x`uR<|raWh)kZpq9tnu&cH=`d0F}9|0z+>x_-6DJB;sck2(e_)N;17CoeGc zJd8DdaS(Z9hvBHwAs3$Ksc3PeSPT`t>pt;V#*6sr_kXFT&HqO=o%URnlX3sQ|0S8u zfPT^dqk2y8KzmexLaX^#cJ-B4j3y!}i3Ls*?N^#X9qh~|^-O+q{I4SV#OA)+0Qkt| zs)gNxzbJ*fufd|cXb#lAi#EIZ(zn42CZ9$ZS2+yRZfA%fRBe$I{mYl`5qb$f{%mdl zx3p0btqULjR~*WiIsRCHmS2e!L=|CnjZiSmt*7hN)`|AWciy-4o<-D9U>1vXQ;D6kRD(e z|Kskt$IedBLvtOoT}B=41DgTVWr3w?4ol8fxeMZ?9y zaVA}|U$Fk&`l;}l>dvCJfQHZ4m$I1fmBrf@pWu`#;JzEj@jMNL<2fknY)$vFMJ7+E zc}Y+5)|cup@gaVq>vJj5Lnmj0Wc;0rAk%BeD8EcOK~PaYgAGyfH@=%cRtmIG#;RwB z$XU$G=BHk{3WG5Y`3K&y0#gyEWX;A`Sy>;{AwBS~CEW3J8-xJYN3m{3e_i*icHo+n zucK|{oVoC6TMr5HVz$6DWw1_{fs2lC9iw6?cU6aV8Oj>2v=v_TXZ?KOlf@)4^FRe0 zLKz~>wEA>=Hfy^a`Rkqrr=R#D!u`D&(g=Sp9Uhn32X3c`zOxa0*ei0X)cc*Pc*EEV zn~k`Q4LF0&+Q;FLr&3c%0pe>o6eOiYf-FTPLDSz3LJk~-$4i_3PhELowSUH2%0>@E zMRaZkXGPvYeJx5S!^`K(S1qb`G_FOg-2PF8n-FZITJ->a-wja0UQ9=R_h~#DHiNP+ zl+uLWK_*eiRx&w=ZVhV|ZLq4D$0%guBGN{LK;6EosD&HaRHgGx#p8sdRIVkSWp$qi z#{dx6E$AveCnG*1wOpwJnUZxoJc6KIf%?kzd$*Up7LIRDak*%m+fucgkhIcqF)V1+ zZoJdHy)k+Fe;44nIM^IV49U?X`%^5i?xW-Ebm2OM0UffGa(3{J)zR%S-wZl#GT>#%ilPTCso|jH>robUF~9 zyn;aJ&cG2O0fkk6A-2^&uDc9}d9zBI2z$@OZ+@}P#eg{3V@8)?3OAe%0k%7(fO-_a zunPkQ7L4{Ra(RP$AAq_qrEi#wPl#aAZZWW{V*E%YO+wR)=KZuMTINY@pe+~ajEbEk+$Y;}8LbR^N;mBVC7N#Vtw zZ^<(R5wZTLh23|KBi*v*B8T(zh_38n2qJ45IqN#Pp}3YV^L*dFNv;|4mpHbNcj66W zoy`;AA`q<~@Rr_!n3^Yk8|cX0%Cs@}q6Us_TB?xWmD@+4hagt(Ja~D=6R=<>$hB^i zCBcPY=tRIHrq-v^1LnYhxdNj!YfYklo7Cb2FpG1?qmydO*%19baxr*I=Q$xEKK?T$ zp4uSUr#+ey@kIe_e4MD*Y5lB1hP*{FN|P!BSYGORlBDSsd{GDR0(MC~g!l$pB?h1* z+PKFJrWvnZ>}Pp)V+GB8{;K?&T7SK3=?Od)@6baIk06zRiIwilG8y;a4E(O^|ez+kKe7r0!g+wVh4i;SJD}Z(qtE3G!RhuY+oqz)hLj{`}gy5Kvi=XN3 z7vLqZQqS?#nI!0$1`~xmzZAH_+?gZODTzEibbk4^fbTz|%Tuuw-`xXwbW%5fO2f}s zjtWQor_Nlps^Vh=FsV}LG-AzoNQ+b(K{nr24e+rVn%C(WGfN#$@ZrxLJ?0f`UTs#w zesQ?>iQ+*H7_Y}1uMZZ>fN|KsNWe_E(hcNgXtE4 zsIl?8Mwtm8b2%gr{4nik#f4c@MlEFq9P!m(9v)1JUN=JEqtJ~wMeD^fcs?7dSqTxB zPDvo{R*r_)@8J3y=t6A_yVHeBeLSr>p7hb)f{0#GTkDPIw^Js=(qq@&Dok>6^0OS z&1e$qGr>(tjhGNyIei@w;P7&j>Yf`yJHw8v((AZoOUG*wa8n}mD1x#O7P48De)lc_ zus~M?ekzgF{wIqVro?6{RE6y!eEoY08oOxyE~v;AO9cDZVx9hqKh8}5mb7e8#|q=pKbYj)qxQr zub=F&s!S?r#CAffb_VV#+y)sw;PWODe7(d&&!0(;O#CN9;X(MBRDy$DS(+TvrtrV0 zqRH`c?vn91#ZX z0qDyXbPOlWJ3Bmrun4m(93^~vmQAVx0%ARSy5H?QljUWlXX>|orjX7H;H>ZNZ(yLu zG7R)!J%tNG42vPno*VKbXedJrkZyTK|IqfNzxv(`SV)(HYYN8~L}k_{ubFLV3Q4R| zY~q6b`N8lV*7tu@WswT6L5C0Qj@45rZRaU-DWLCj(4&*rk<5)(bbtL4#em8sjFISj z^DM^_pd0a^$ixeUrmh@$-Eaij4_A1!OZ>V2)vinS7cDd#J}JSbm`ji=Z(p6$Dx(xd z9Ee{L^fw#U(c2)jzRDf({eKP#{n=GVswY>WQ951tza(nbozb+EV~rHO)JdxPEko+JWI~})hAe_DV(m|8{9{b-TU|5%t(Y{$qJB3Cuk9S) zvK%>f|7&uqeK|BvJ_mlyy(cvO{=e#9PW{0%frP~q{lTpf5_rvLL;uP4YA90fL2Sc& zfiw#;+?eBnpHQ`fDGcJ&|36+0qCRX{|KE&|!)7~$xeVO>gZ0kA!>a&gxo3mC!s!iY z?~1vH=f6WqJojg=j>syy7+rN{+Ms{R3kD5rdtOdsy2h_)qR5agmCx7PpYfY!YN6+d ze?7ikN+j#JW^NZGgQRskEmsWg9}-Zx!H^#V>E?;l))5LXXA7*)a0J8+T}#(n54r72 zoeFh=-vB3sOaYl=SNzUVoyo9mp0w(!b;v1p7zZXo*Oo?JjSFPE<7Gyp=s6-oJ%zw^ zI=>2ki=KX913C)SU|Y;cfAa>N3UpUilEEyUHx6iZWRJAiEtl8x)3*)o!WU_TFY=LJ z!(ZtB$c5h7oLWtp9+2!!hPNc%9QZ$mmiqO^qd8H)Yr~u=TlIpKndjCZ54;Rv?T`{d zo-8~mdcQX1iD7W>3L+L0h3|H6FSn=Y+{f923$$Pbmf+%9c9ckamzwq&B*}d@>l<5r z!%M2^qJs{kvgZ!)pakfNg!sK{r(R%INA>>g7;Y;j+|&h-2Nz~B0tReo;u+|DPe#!c z@ezvU1DXKw?uBxt4VlbYCGkg~{uToWuTvG|eswOwXZw7R?^bda=t+IFbbmSmPwpv1 z#3U3@V}Z#ziFAXh75D%FKEP|r=0E&BH74)=IZ!87t$;o$WSv4aCr^$xK$8&2kWi$# zv(<53A@CF%aTnC^Z&}E4z=CmNgdP$wp62|vUELCUET_K`IVB{7-J!m|blNgtft8dG z6_n_A0C)XaK@lcCCXWCbEMit#Oy8>U{p5Hkj0%7{S-om;r0=)W`B&HDFkc%MX=1#0Hx zt{wPK0_%cIhl%Ll(a%Gyo>6@LjVADoRC!J_Ttl{crlU6Fz~ zx@&~+=6Fl@+90oNeeJmRs(&gQx0MKrfb)Cp)$(HFyfU26__l!vI$F3^y!y{MdH<8X^VWScKIxxHruv9TrP?p`elSZESZ@ zMU9|+GTm7d$qP=~I1UMPb0G|E>!YDBYjeMRV+yr-)%c!N`TE`-F2Ryz zR=OuXYl9RhjBH*aM+9B@_Z4XZ^2Q4tRZ@?Nc-N&s{A`|Aw&x0i;ZiW!86vLV6YE`7 z%Mm6%jC7PpJ+d`=h&TgoD=F8+sP1E*_s(9XgFw~(3Ui_?)N)vCI(`YWb3_b~qvbBe zhDpgdpJQBbV-C^L&^hjq8&NQ$h>Jk#w=I9|JX%J99QB{KkWq@2kV|e4vstlspHdv$ zf7|}>j-=$P7c)$VM)f!$jA0eW+QtjEg>ovXt**8I^*gGNI5?`y&|=@0LXeV3y$z}@9dUGCD|eddcR(x1>fj_X zGiG>D^OENNcpuG;k8ao5AHbJ%?s1pPS{2lanf{Y1xwgYQg$l46SZ`(QCPnrV*qYq#5=FJ^IJ^b&SVB_IFj?E;kSJylAcfhrxzUnFPfs~M zo%kWc{4olxFlk#dBXkfLq&#m{{vYly%OuoHmoQwuUDfF|)nkRZG%%0|Leg9%B$l%T z959_JRBWEL&s}?QaqKL2VK>HutLq8i>7A|M8s7=vjQ0zbhwD7c`h2w zuO(lfDnjoRdUPnzGYy-<9#=VbEIAP?>3D6=722i;2m6~y0Oq1Q0U(g-U->5a*t^@z zzC@Y+fAj@sKzT*`7qvGdRL<(d;}&fH=XmResE|Rtg7(CrPb+Nbk*Un+QU^vm&Zwncu{ z%4}yCcbBMGwtxrFr36m2`i!A|m zsSEPtAbVTkD<99h_v1H{)Gl{wNc z9jrH_N6wi+c7Tl$;EAAo$Gu3bQDBu<2EnIu{b<>)zG}MMDR*3|YY14r?gRuJY%s*j z;ZcRd8*?H#+$+sDAwVFF)Uz=UH&p94!5+K$^aICZtUX>7gt{c*Nt5PoWoQ)p=y<_+ zr-4rb*o|}Zng_HPkqp>~M)1wRk7ohWZ)KrY{b)S-_Vro*Ew8R$2_US3M#u$4o?-+9r&Gj1#mN1@-9RLX|XK zJy#JE{@{tL1#f-8#X+r(7RckQxO4l7NrKijr(iyaX&$+!9r9>?P!LJ~mQLMnIVoAH;NHy&-N%7~elit(5@{kyCZg=o!?Y{b;< zu8}5PUGxri{zLD~o#o;H3ruXlDAQJ?C2fCwDOxm5k5j676EudsN?O4ijV0o~~p-8c9LM_`?|?;cNUJL+#G` zT@#W8R@yZR)uSi6DimwkW&*>-eiZK!T*Z=BL8cBJ?Snn-kL>LASqn1yqPA%S@aJ&p zE?u6}{3O$%+*oeG`#UXCkpF%C4)$~@u5}BfL}N``re#>ZsJaT9mURv% zEX{|zcvy6?rVr9LIc+-^xY+a{v4?pV?Ofg7AU*%1ypQT9I`G)LaYp0A?HJfMdS0g`mHx&iU;`c_R>yQtabA8zi;a8 z;aMA_Z>=SDI?tlr?_LYjB4!T#1&u4>cYDSZ8j{Fa;rfF1uzJ_S@0vP2=5%EbbM;X{ zp)}Jid@1OMse=kJ(toTua6EY9r5kTqnV+y5xYTd6H&^*Lfv@<&>3etgnizTgSDtFt z;|IoEW^Ql14pk{PI2JChY&^k4cnh&zX#Dc^Tb&ZdwM50VZ!)$#olY zM^8T%7+saC`{19+*6rNS`e3q}Ri)j`NoErHM@XYaVg7lDC03f3w-7EMmhbw7869{W zJYgiNc%?fSyd&UXLz|S0dF3S?XWSo}FslF9so(ldXy2|FS7aZpCl&8C3E(jJRU(+q z6D)3&f%+}RN<4JfTJ)V9A!8!AD*ueCF}y@*`>v_kCU{QH>M% z(0$Nr?not2;ZW(ub^P{pJzrXa5lQk3bKJwUlk2qf^V>u zTdPdxQNF7CzSHPD?eWC*N9!S`+?_eITc;@<$M@nf`)tbU*3FaRi4VRh0GM${SXug~ zpWjfSK*F^CdnUtDg=sS}C279TZXMcL&ZQX4Fi6|^tjRj*jSMuXJtkXS9LJHO{0j_NOyQflXHr8X^=TMqjd1Q(0?Y(I?FDS9Kl zBX?O1*tw0ZQd5$}TC#dAq;ei5v0i(4cidlAME)an+D(`9l#BGu^=t+IG1F)D3I@yY zS@#o|nU1v(-(SJIOZ?|-cA#vyj3LG_9ydF38k6AeOFC4)UU%Abn1xI{3t4dFq%(;x zMW1=t^s5t(s(d?E*egGNedkoEIrlu-Zg}pG_pzF|cQwOAX148k#G_1Q>{h z4}mf_CPoS+L`CoOEx*xhgeN}H_f0?~Qkni_IUA+?w=2`r zS>7_&hWADE{=j3*sr>2XTra{lYlAMfRQm4D?YL~d^zra_^1f+8Co7Kv7LO{+^e-1t z%Lqa+82^?MeWrLRfvhVSP_7JnlqZSPGs;%wB={bq0Rs%ks_v9t4@P-aqy=MW+arEZ z*;1I6W+211=Y%^yylZz)_)kt{grcg3H%6tZ?<-Z9a)OK)>s+S94Rigq+ulNt;`aFn z#oON3B_)@PEbKjT-FYG?9OD>6N1#uq{LB7x)v5gG>Gwrj-o}hnPwQ`&d#ao3VO)7@ zp&gbwvMfHDXdPe zh-NMJ^pPyAX$oq^WAbfJ<{1zp>Q~Yro*}w@wMUq6R zW49bnsq}tczNoy8C=wYX&NUq2JlMg?z}dT=kVat2ou(Om%cZpRE9H3KXo4p5)EJ`T z+V*#mfb`K|xvtcdl!QLJW&4c580Z>33h3xuy2QnQ_?ADR2M-Ton8)|50kg#J@dLvv z4JscHh%oZ)JHurz87TKq&Ce_eL9uBBeHMMr&pXmLhI9=#{vhIH)BEZ21ZHJ&UOs!A zhZ}QK^YORmA)I86i3x8M!?C7z<3rOTIc{Ii?2D9>P^Yl7eIV>&)Wv4No|tn`=~jHf zLEv=aq*(dQu{_V}LH6k>?19B>E2jHkV0TA{h5KW zXq(vieUT39OS26^58+vGY?a(5D*kpEFMTjlx#jP@g?^i(MuN&Jx`ztxTpRU1Y>BF8 zsgCy#gu52^YCX=!-+ENEK*%Qc5Bp(`NhJocNI@zy3)6t>L^sAxt+brr{9FREpPA@S z_Ix8go|s$=)YQw4jg%={Id2^;w6m8QJxZ(dcn1{=!wkH29z?;Qykg5bYC_-4J|1I| zg(Vu-ZRz}b_*Q4@j*(&3EjkSL#q%mewq{Nmm&^A&DzeO!x{M^#(paC8Lq3tNbk60ic%R~L*l~Cp$&dmv zq~{-|9xb??V!jV*<|J)4f7xpizsrxPiB=ZM-mEWHy4W=u_beh)V#x{SaoB7)+2u2{ z4_qXR3F+ojSxXZLCtXCp9(2*g`vo@4>z+7bE@_k|=KKj7YW1 z>dW5$Ov_V(3O3hQPq9{XcEg7PF?!v!Ef0O)LOcyK;iPcG0_er0JuO>%TM(-uI zhsUOq+(D!hVF3N?wfj=&_f=G$LzjVK9{Kqm^V#I(AkGAHvtUEboWa1Kk`c}aae+*Z z6urcQj3*agC&rb{nG(Ar-_oa$h(BNEmhpb}cs#$dA|ymE)tGG#_3ZWIS$~}CocG_5 zvOdl3X7dl@K&h5oEK}~yo|<_Cw_kG{PrnV%0O$~iJ3aQlUzvB!5AE`&JZg`a4s(}x z^&k&1c#_%5+z{Pz+!U~_D*o2AL<&}cVO%r8^1Vw*fDAS)W6JFxr#im2t@Q=LzPdf{GUt}qzS1as zp4D=q8)`>ZH1iH=a)^z9$j{F9;E1tFO=ok{RwQSW5&~eLHbq~5DUR>2Tb%d-on4r^ zV$__Z;%wRpySE&*exGQz=Ikrg*5~jJd{dkYlF#+SZn?qyRqc@%HU6WtLfC-Ls9hZl zCN!n9WX`_^wQ$~V)8*INQ4hmKZr*7OX=fs;txuyISE<%Osn6%8ta-OM@gVNNL>xrT zYNpVuXC%9tt&x`Di*Xjsnp!ehGLb@K6iYD+5}vPVI?K4@X++DKj&n8n(vh$BGZ{$) zVrGLR>bmP%@?Vq*Q{1>2eT}%oq6mp=+x*9X8Xk!R_x<0xG_}ca*hsjwyVI(vEmbc6 zOj7n&03m%s6N$`|3MUKJIVDT6b&KWG(~};LQia-VyGA~C1Pow7G@QzIb1OArc%4f2 zD|d7$@9nu99j45oZE*4Z3%kgh0af!JP^64(tAKNM;6=`o6gil?I}YLeuoTpjhNy( zk95khH>3AZ%JZ7U)tZQ@T6lS;uFgF3ncWJ=Dpu<>3W{)M1CZYlkA7=eh5A599*Ssx$6=2WDGKS~`WsSw7TqL!=3y#!3X$ty zxixyMO(rxWX1`n7@HY0tq^9h=+e$eKg$-nvne-7i#@EPwM>z-Ubv_1#Prn+Wuv6m8 zT3ncsUb(%DM{~oJGkiKs{O!$5Ef42#<8#w@v!1+ZPGx&3_5E0GnmN`Y5@li^I_nTy z&tN@oy|&T0Yjp6$QHzmSU^ViFp?f7N!APU3R>;F>tEDsLu`Ep*$L*G0i^#jw54+z) zMQ9FY^z)VyDKWnqFdoz!Obcqszqg2~&}VpMjrb^af$7x0m6Vf?Y&f)qo3zcDW)^$@ zbLMz>&MH45Ry?#WPdCWi2{0Pa zr}PG^X4&Y4exlWs%V+EF5@!3Bj@y^Aj1{aRv`dOHM7GHAZ(&DLrykL~4+#=qn2Fc? zY#Dg}vqy@`Z#WD?FwKxXzJID+#2m(qJ(WJqrYnLPmpuqgAB`06tEmi<>zbZ z{EytpttOAt+B8WnE7zw2`ix20WNFmx8f8t^zEqV1w5wwZt|)Q~%~N3u$|&m@j7 zX5mPY{3Tguk}`+Ptw51UBtd;e$%2J1Km(!%{RVn{7)S-^OjE)Tz7l*MuPbo1uu}z*tw|%7RE1#7U+4rri ziO8wS<}>A%zMy|nj^}Jzx4O`&at66!-ja_NM0GPeAFajA^SiLfd=UtQSF62tB85g? zLD6Q8kFcHV17R<+^F77#2QBYr@{owT5&1jSS>*I)RQQsFU`!A~>?Ppe^WYXfkL^loV zs$;-w@o9Q*$S7Xfk|n$1d&`X&u9F^NkIko+mcNKD*M|7&uOp@oA^-G7nw2q8CCqDv zppP)a4*o-liBjeCX?I^@a7N&KiD?#EH__jI_uldIN58oR!cWiTlzwNHrJ@Yqbl9=B z)JfzwMvFy@l_llk@bjt>tHnp|zd!oMLeu zhwciL)|z^pN>XfO@AoGJcvY=i!7)u19bnpwsxcs?-|lZV+dH8-+4dI@p`Ri1um6T9sypU3P0!1caoNSxs^G6yccr33<+B)3rZL zS+QweY!$fC46}_ckKJ$GKaeN;+A2xfOB2=;>-%E)Hq;H)%ed8h&19;XW^FhVJ<2g3 zQ@vd4i zj*g~Ot=@o4Fv8;w-fh-c}8ixA>#xeR2R?d{&HKW&OO*qC%s+2CJ9HSXSAu&^m z(M~NBshqnvwh>ri2q z5oTUonTkY5*hM=#W$%_@oTvaz#-i0%q+*}$gHQp|B1yf9IhiY)N3DLm+LU3-+^3!R zKlYH);g)vIe4pHlM8i72Z-y@xu7=IZ^5Lo4oqr-^SV*{b<-#Mu(D0HZ=yI|Sp6UR6j36MEYLZ|Ex(qKvW(wiWt(<+sBX3nIN$Xb3W!8?8N}VgJZEh? zLEL>gdXttiTg`l zrhnf2eG!#+8P$D~F?^Z!eMmGWX6g?Cr@&!*|^Y+27m zuK5yrgw&#zt?eF=-EDWPdq;Wp+_R^^y6VvwC5^#HizYH1ODok$5#>{%HU?WrS1Ji$ zP!Jqs5jwpV(IaJ*bJij)Oom;C{sO<}XT-xY$G>hnn}*t)Yfg-d%SyhyWvcvQz6z!4 zm(Z|MOL0E_sI;^zSwcOnogU$i#%^yZbsS(-=Z|PEYAPf7xnop%c2#yZC)=$hJJPxD z`j&pLnaP)K4wLL%T%4#=&$G$+azB-16Z^*!C8CZUeP!x1dcg|lx}*dXkv2o|;%m}) z{fDObldF9+suu)Tsg#Vy&nXh@hL65smDcKc9I8@mprqq8N$5{88U{b2uS4s}uR>-e z#+@{`nks6@0M{UaCxn^mX*Rum*L6+(!tQn*olMkCOe!UqYihinr#9v?P>bEi@-D!E zceJkR_`$Q}tQ;b>9GeVTwG`&|n#%Ma09hjoi6FRo_rx4}A)W*L7D{mQO3;iS6Q* z%*I>`DAw~UwU~lb zW(rJi+DLA7wP+H?2VvO{IfVqJiFVvCpXT*R6e*=F+XNIpE{sJf0!g}Eu~>J5YplEX24H=@gvG#`5#gN@N(dzZQ? zoyS}z)kKdkZkqV2ov2@&)@NlE`vH~H>fVjmV!ob0m4!Lkd~!m}B|=P{guD1J^4t3( zMkfA#K82+cc#U3U$30+fQvZ3yE_}bs8N2y0ssi@TF7^!3v zu5?=Byb9nFS>#mAYc8RDW8my6$?cSthvpdCT=TL zwwm+Q7H4d(@0tY*$1uOVUVOoBj9o`&G<#E==+DkmgsNb`a9LZKB*&+sZ;e#B35{-K z4>N`wOiDA0)f~QqsL937P@5tl@{OG*!?}p~wPkZ0tPWAu(e`p}y$sWnx+aY^p&y+$q zBr|)BxLXPyHDT0;&)viSJ&0mIIr`3J?DKt#{EqEuaa@X za)MP?_1Bzfvq>i|mHZF)%YqvpubK#m+tOZnq(q0t)+%B~Wz(ng zk~hiVu@As48xBRq?m9&-(u4H`7Ra@C-|3`n_PGteITAB4YKPOLId+ki5x_e|#%M2! zu8CHB<$FkUO+JiK_nN$jXY}|;LW*BBQd*CT^m1d(mtVxomG`@nyyaP+6TBK;ANF){ z4d-vpo|T_JIzN=;8=LdZ5R{e>*5d+NbA9~-WR#qfZu=b{Wqi{^6^Mf8Oan66JEP)h ztezXSqOq||QgHF&b=T?_8*(Uw;m%?B;%jmh+yrB@W1WP0Xzfl(i5df=p=dglBKwj* z7<99n4M`bmYO2_wfd5J4YV!xrT|POW3au&uD1ZaaMu(RyUM^sU39aTY&{|6To<4`4 znJJ=dcU3XW6 z5IC?x9kDdka60}~!j@ZtRPymaHuZka=wnjg%y{IMR|?b(^C1oNc9iZ~gQ`q;uqJ|- ziaOdtGB0w_18I%FxjN1JpSJDo0gKV;8aJQe6@a{U{`90bg&kaDQ@m64u9W!Dd~2?8 zD{$KRVZl4$G5AV@B4>u~BbwzMRoY~-W3%6cEkq z>QU_a8Z=3WdS1TWX*tiE*sXCy+NqK-1PRUS5l3dp$?V}dGVjh!-aDo`u`!2;aKJ0? zv+8}>{@uKLGHjC;Ve9Hf3@6@}4MOR{nqMSu83SV$P1#r1e9mRGs!Z>9>dO2sZZ=7n z$sPVdCgxCI*w6(fs#dwIjVx)6Fjb{Ta8ta0CW6I>BDyCzMi(@^p5mZ&g`r`sjZQ>n z!FIxK0@W8!bWLu>LvvmH%W{wNV#D40%<-~7kmRp^4o!{|Ra3bV!ErRf#>l`>A!Oyd zEd>yU-EgX2;c?sh=?UE;fFIeG*-}>yBK0Rz|Es&1%z>*}%Qi)Og<&X5D#}{?XTUQO ze(p=KiUp^>2v1#ZbixY^ee^=sz}~u=1(*EqpSdXFdc63=CEvT_!b$4K=g;BIZ>ls| z4-E9L)QkBr#rs#}8uK4NHs5+&E5O`QQuNBU_eWI;a z$O~@6Rx1IPIekqw^846T3ja6B#23YLC_G5`NioPE@M(S5V)8$2LnD)Y312s|6p^{Ytos~GVfHx2hd7P*pM7fM_YM& z`wy_G6gvsa*Z4hH7L?efn5_&gy2eIQAfmC&{b^nO_+?NPFEG();Hv- z@=vh^nqB@|n5J|tSA!l6|M>{8IGfxr!ID;js!RbhW>!BRQ$9G~{Kenv35tpSU$;Ad zeT)dp$9cZ>k&2gF8UhEV98!t>-Hzs9G9@8An0NFlYA#sPpN`rxbH~5j6Gm?-cae;& z7Zx>ekZI-u-kvj&%^yb(>7LTg$z@!_E6p`W^Zr?B$Nqz6gBBBa)f#neZCo|&?VXAX zx4ptYd9k+KYVw;!3lf*|Y6qyO@oetaV|LRYqIjMun2E?>suhGLQ3Fl;5`cS45+HAl9BkHV?#4i^iHaJkcLbjhTzZ^=OU3jgVN!>jx|<|ISU?zy z53JC+*~vU<+!3UFzsV7dYSd+v?|T)x%sf!q_{S-&^K~hDtfqN&2_-RF{x*`a4ue9e zsA8x3j$d(Z4ZWBAk+G2WGU2h3rX02RIQR$2EWIVZWjP#fpX3WU98Cz1rkXQDsqokW zRD84;Sf0+r0NNR=wQwJ}k6MyF5P@WADt6FPX$;;a8VL^H-1tj1_-0U7aoN4ty9&81nqNDe+3` zQYQWQ8wtGvNBuQXz)03Lt92iYB9~nkPu6_ad{psb%;L#pep>FF)w#Ehb!yy~J$t{4 zLmFJ`cRN!hG=)cVs}8#~&*C|v%x&H;TrFN3-}p)ganSHe>Rsi|w0)=5=cQ{+w%A9g z3OTYMBsE;RMy*+?Es*=-A0*Wql%?__hV?lh>C_J(rE~N<7+}PzXj?;X4~((#O8f)i z>@Wx%O^9G~VdCg_CGGVGJ>|>s_AKpwRlCm(DQYWl)vOcRxi4VZ6x}XKsCzUE z$#;@nniLG55e%RG>XK4OqO_AT^MA5wBLB%!2>lU^pEnnME>`#J1)?m6I|4Qv3k%Vl zrvihO=83>Y8YR2v+dHy~XifUgdNc`kL=JXup_b&f-Gr37%>EE+eBye|I#vkdqU%$0 zo&Jf!6O}vk@qt|ni$Pf4R^d+4H@RKzCg1+!wY)x_=3MJPX3@<$!HN#IJzlskofK|k zrdeF#l>0H<>x-qRbU6FDcFXPYXv0gBR2m%#k}U(g=0e8bhGdBJQ~fesQ>^uGaTA`t z=4JD+gFMnEg_ntnAdKApn88@?7D3;TYQo@(j1=qAGn^ZUXD@DSo$~5<&}bWY(dww# z_{1xjrD(9@S|HyKfh`6Of+D8ZfT5u+d_N;%8y(-ZY)W`dOQ)~C<)>Gu>E_ZQ>&^k0 zhu45*kL$Q|Pt6Z!_}z8k!9*Ofc~(7-1`Bw=9mpeacN z)-=j*g5q)}6?W@zSY||2J0MR<*}5Ue?TWW#F29eQ7`7XFTJ>R-vTWC(VQA33P}N`y zIpTK1F$m_F(lDJ$ZN7p1$kPJA9E|`^IMs>-B-Nhk zdcM|7UG?woi^JL-SzoG_gX$eL+nJAz=ORT*U|flE-&!4t*QKB}81y&kgFv-54`!$X z$oMAVxNhJRi>$%S>BY<^Mao%T!B;Qt#o5c?YyPOIdw8#tae|VU_a@8*CZY;^-lh6v zwh2~)MMiAPYx3~zV++0+G&dIzj|ety)!XzZDlSfQU+=greP7`T`vP7+wrnZTH*O7i zqluP=ub7ZTRP`xWMC436D3NkqF#@Di$Svii3nC)Hv)8OE!57mhjdjl{i8xqwx0Ylq z6~p5>G;&oqqGrBUM)P<7*Gb&+J~ zDKLhMAD;5!A8E=?K;b#e>3`NA&9WJX;)U?^ONA(Ktr^_5KYM)dILdR!%fcQwb#F_E zftsk*)W>JA^<(!3i*oJ7ADCE8bqau?IP?XQqrE$c)UGS_Miz5a9cZ6 zuCTA+so<^<>L!!kdd>_;o@Pyd;+_NEy^2_ob4J{zyg)A# zb75_t!xlljmbt0EbNUFobesitxkv?-l|wfT?Fd?giua|RQ=P^7Khw0%JV%$Y661O_ zvu~!-Qu1qYRKVF0)ee&);|+q!Rj@9T3yVr;a}sJUtO4za>4*L5$zq+~U3x~_L8f<1%d-bjXJgii^(F9JgFaP{!&H>5we8Cx6{ z4F53404CJF$V?xYaAM(@dDJZ!@Gjlh|JNDe*%P{xYD#iMN0v$#cr2`mnMZUYZX)KT z5Pctu%`YL0SI}3mOuRbVq>_H2?y|-!2CRJXwEOd)j^nV}8^35=5vD-GmTQZ}7l_uR zH+C2Q*4;h@TAV+$;k5j1-|Do;n_uiE3xN-{5`Q+r~xr zX%OUk!+LYv;?i%rXxbhKP4(BVW9Q3eIuYQeW~ zvM+Lj5M|$Jl;5bMc;hM$@uZ~*>xPj1-#=%T0dA&l=55*pd#He)98Za7)vPFN0WM+W z-q=La1kmEJ8$=L9Z>JCLxTMeJKk70AK!Uf#T!__R?UF9|Am=(K0+h{Momm`9%q3Z? z2?$xE1`)g(-Gum3&v7>@Nf+ci`G_inPNc0)t#M`)o4Gng#^?CG{WR(cDDXweC=mz; zy2@Rgp`z^Hd*vPWoaEjvoYGjbTp-#wF`vUN`xQhMm@6#tnMwLTllKP$ND{7<{cN^D z_?0`-$%6+U&kTjIud-kwns6A@jmJ5IY+_g@9_j4dOqT)!O^I{ZGG4>$0^!;N6 zIWDzkeO;Wlytf|>0RaM0`zj?1igOf*EY7P@Yo=btZ~A^$!F&R6l>*t`^0bKaqt+AX zf2q01(q}c_{*qgr2G3mUQ}}2uB<#rBe-s?4r0@fp0U)UMnG6!-!Tq_BT^%;%%IGaT z1l408R^IXq97Y(4BItr)7!=|bT7^ZvEs6U~n?|390hot&qHGdJ@Q=yFO(|Y8?&UxWHo3%33{q-tZLp~;i^r-RepP}o+o;d3-VJ`A~$~a1DTB((kPZji>2Kk~fM~wkwjjsSSzsFc(q|ZfkVh-`>u$>PuPF-x>48%x$X@fvQ z!*ECaLoy8o;9c$CjM=O;M9ko`r}zmd=MP0NJ#g%Ks?aqFdOC#cHA<%J*aDF(3gxBW zLi)kRzg0ifT|}>L8)z06S8NaweIHG_i7k7r!_03+FAb8Yx2K&OS4i>YA>~Vp_}yvi z>YLct#=Xk}^Y|~)Qd6b0TR^EHsAh;6k5iMoStSjn5*xLjSor zzed=t$UaIdo?psZXw`dmzx`2vWKMMrx@$-Aj9&D!*2SYOfad)4o zvStLSg!*YY76aI7oEj!~$bUJIRApe-a5cqVrT#nNsc5Z0kg z#~6y8mtjr~My7ne*G1y$WnoMUVnZse#GK>IppG&Cc%gLExN|6fTFqkO!c7YQ*SO>4 zm_Q~rcj40K#2^7Ab($rb=50!)2$Gu5mxmPu?YFlQMxNRYOee#gEOxz58zp@P=P$Kz zzl|iZ+r7~De}tv-Uv7^(1pO{y_*ba-hTftnSKL5F-s38^P`Lqw7BE@~Zs#isgO3&Y zj8*g1ySN7{yJ~*)yVQ4#6VzWP;u*xnl15+b} zSjPJ}J$cmNb;Ol@)Ub`0t{8An6&vEUEwDho(i^+YKwxkfUKp`Sff~2h$jz?ukY5^=g z`8ipYW5h1_V&MXu6IMv^zql}MH+%csnGP?`d5+JmMr?Rgmop#v^4^ma{()k13}eNa z%Z59rnT(fQ?Fth>E#6$q-Gm>?KAxeNT`nfYHaHq`l0H~y8jF$`pTIoq6F&*b>q{dJ zX?oqj_$=+FS1}elo_vwsSngT#DKuD8OSyGZt1sbE76bh$xk2T|KSmbytN@81f~0MD zT~S6l%-@r7ig$~+e+0&4tV81?2vYq$iO&ud8l;BR4>SOBOcIo~5?u39e`7zJU4|Wl z?8_ltuCVdb8NKN;8Z*a#bVwXJhV=?IZ-M%#!o)Yr70YwxFv5p^^CcJAv0^Qu8O@=_ zbPs%|PlrQSu_dEz$*P;b)Z1-;>{vkFg7;vt~l^WvdWjN(OTL0e0@V<^$y&lT{FqJn9oX`&BnOuN1i{pf<^DA z&~xx@AkpSocK%>*a_-MT1%j&b+Cv2<4W$OOu4-DN8I}u z>3uwm8nqTOh-=OiWHcu@a+tgs<7;=JNdec>tJYvkJme@)3$ypZBME4{&QGbpXBQb5ZYe%^CYsiENgx=8UNtCr zda#)qcry+My$l3RJn5&Q&d@VH8c zC_fQrK;%#g!UZvn(=%_*zk;}_Hp$nw7n%x8ZOgflxYPtLM=o3=a+@em3V|5r%wWF8 zGy+$lE4N)77kQn3sggiT9PlZ0Krmppa%RN47@r-mMWaD?Ind2)38lNf{@eI*$xYSL zm%l!(YK~#`b>?=Y(J4FymAi!pjfOv{KJO221TgMd{u7I+?ymic8$4qhy=Vqh*GD29 zokcBN4c*C|fww7DUCFV2{cJNRGRC-J=t85B?%l|L7yC_a4MHhT#ooQ)JYf&Gj8I9* znF*jDd;NPHjT!tVaf79X3o0fcPkkP&+qw82QqTqz1R-J^7|kaNA0~{F=@fl&msiSW znZ&!1wvJO*7tYw8q2Bd@{$4fNzElPGBn}oL@v-(k$rmT{l9GOO_U~mY*pk#IcC0ci zoCh9M7aADd&tUq z17F^n+CaM-8La=B#N$6Rk9U1hdh`AbVyQ^d zU8pF(Yq7hn^7GcbMdnH7{`r{C#2>`yMIZt(l7Rq9_NUSDoE7pFZ~^G!&&i>WUbX-J zWxKMc(NsW3^T&~{@F(~I>L3c|j$g@1w64vj%K#?;fGVy!Npt*SqAvA#u>#P$z;4+& zcPWqBeqlh!R*(d$D*_I(1vE}fW zDg(HdSq1{_^QHIAwE*Rgch#660g8@C&*Lj+npn&7yU&jHsic6Fqqz_x5%ufb37w+b zyyS7=^p=w%Sct#8tc+31jo_;B=tdc*WLN&~Ec(6TYF(wmF^r!6Wy+e--~x+#bO6A_ zbTly@m1GyF50~(W;yIH(1{{)%>+XW?u#>FZU}>s#BUB6TH(|CFU;nS_8_pW#1@yt$2f}sn$pyQshChK0M@+NI z)fB5K>K7G^rhYTb8+pIQY*fj*#l}TU5v>+rVhlzpBM{FVc*I?`QTR=A4dHZR8O>&v zGZA4-mj=f{1k=uvoVXE{mI^b^iQjS<n4?+$!OLYs^ z*>lOX9;bwcHzWF2fA4{>^sb(g{1hOIbRzecP_Lvm@)Aj(=^z42t@l;J8L3|9h{S+; z9hEZHJ+x}(>c;t&m*Y)Ch#vRf(*-YZD6Vg~)GL(S4A`tGCHQ;oGU6~e$I5Boc!@zV z7LyIeKN|KV%v~u71iUz1y-8I@N@vyfJD535^z1)QrRPPBBH*i7{b@V>(nFX4x;CZ_ zmXQ&3_T>@8%JzQOmY|or zF$;Laa;F| zN7tsI#wPXR?o8P-BsDUP`3KJ+aNZFc=3J45peoa7GC~YFOqU!{Wd1e4nI*fNAY_;> zZkWTy@W6QFw(r^OS9qQtm-)n_>{pLrfC$Z&t4&Ph|a6lB!AB$S75y}-pJ@k>8dA__}vauIt=FKk86q6D%lgA(-A@u~EJdlRG1&PWj9n5%>r> znLjSpOd5nt>2YE{litHYQl@&h14S4w2%52}m3Z9BbHS~ynSV!EyHvHof*twWfLVDM z#q9b2+B#o=7dPkO*S{)7^6B&6uWigOJ|5%DDX!uj_5;VbY1u;YdeH!*lx)=-;7f?sx>MFdrx zYY;p1s8%A+V97ViC`2+^P}+xA?D_<`b;K?z)qC|Wk7(H~L}G_s3A5L;zj+i?;n;X` zsT$@&iES|$nihnd-h$#{f`gA!ykBOqAHCj^DOlg!G2W_dFBeOC;3I(%iv6UY_&h1& zdl?&XdbMxy2g;V zjXv46guQWh)z!2&SgD-|R;8312yW@~&aW~V^&)azJ?0u`?^oOa;e z0U^BLFdkn%Yyq98M_tqLiTegYkb|?mI9H!@nA7=FfOt;PiaQH z5Z${uD1Dd916AFS1tEveNqVNod7B_LKy(QImn%ai4~q)VWiy*&+hR&qk(9(w9v(mG z)l&+M%TFQ*0mXbtNwz*-jH&68of42$;EBunvdo_LWQJ*s1;{MZ;`nU)r~XB#ZL7(B zHar9eo8y@XtsAv`mkR0q8(qInkTmxLOn~OC&Yk8@!eq!Nx*XRW)dF9&*M9Ng-@T|i zTv_u0d*-JJNU3dgYV2Y6(_;WBc^;Vj3w1`%E=f@b!!kIoleh?f)+`+xi?O2;t7RmD zl~4b%QSk?xV0Fd#!7U*KbG5wlQA}#))%NcqP{L!nAwP^V4DC6uMhm(VQLuGknxWbB zgtxRnMuYV*Y&vyK+3&)UqO|-lV|wQP0pY{qFU?w}J+OjHWkF!iU4ZOcQ9fYD%^G8K zan__>_&USqa8=DUx`{qvRq5MGlOc9Oy2DFidT?Ryu*fQ@Oh3R@xFc#bntskZIde|Q zl#Ygv?_~qP^kjkW6zaKbrga{;P3Uszj8A<(IIxx!eog+m`w`CJt!aq_irBf(t4!}7 zr_-Efc+qIABU-0A=uTQC;?I7=rI+)*pySjYJ~$ZtTVqhbN{4g!AIz)hd)0+!kIz$F z>*2D6G%>M&q9m5nC%p`!8Zhx19-91i*yvKZGYw1v5nZ%X37N=Th7=j#Kym*kA;{_4+2KE(O7CYpQ_GIIsr zX)}bqvjn|bbWC?)diG0#m$YZL;|?5hd>TQKPVY_Nug;ZDsmSxWXgE={nB*`@7Wq1F zhI8C``GcIXy`w?EvC_=MvK=|w`DZpvtAtn8@9`ATS5X)9hcEuJwXP}?0;qViTYyB6 z!sU<2n713?!>-kl%@dm|FcWqJvcj(%Yt6*1!cD(WT|$dJ9D~pU)B|VHnz)Vq=yA8q zYWIOod0++IAvb(NFwBYxrCeOBeM7a0irNeyf(a%f4E_>0!{AUEyEVF>w|Drw`PsYg zx>hjVKuNZFCDce7QyKWb zttVkO1g(&$qDM25!!wfKK27A1E}hR+wqHZ|s;U)Kr|!*MRG!UT=oF8ta4_1L!0~<< zd^q)o9I_QTn7CFX6 z(ZWafa6WyPIoF?&01=fR-;sVG-&I(NI+N*yWp`@wwwS-A!k7;wY$Y?w2R(q^puaWW z)&yd`e<~Ab=Gme2z;SRX=0jwi@~2xJRTAJgK0S$EIsyR(NJ=?^F)vp@FW2QHTqLYJ z+62b4nGaL;I@~d<%rf|!!B4IrnzIh`mc(tEx@r{flY(a^5h7;)%ojI)$pTE>H(xiOLcogH6o&~e)vQ0{)$#25P|6wC0um0cuSs$}3Ck<5vywBBA?X}tATo3%kBBvx8 z4`SrwIRNnPVd^5NgLwL|5T%{snq>-zrP zGvPfELf9m#n|hB>F2dJa^@4!QQ6}&y`T5gi5Rf$V;O7=UK8mrNa_CP)Vg#-tc*Iuu zX8yo#{#RHJy;je>1i8R4xI`SnaPeZlrw^8!#V^Nv7ot_Jv8U#tjt;|BhYhsshq=M{ z24>%ercNjyPiJr@^|&vDznf@QP+@{pQfJG4sN!LP0%6sc<+>W;^XLFUw&fiDV;m@+ z0h#B+TywY)7&hO%59?J=qEE5$&?e@pqIRIO@P^Z+eF>f zEYe&M_}A^Wq1^{1MQV!GpK|GBzWPjn6po=A{2>c5e03*f*a&>nnoiF6Co}nAx%3)N zt)yyhjPo9;5*MNco>UduAYFPWz$^!kxKffhYNNTnVE}=`>g|g4#UV3j135w$VKSt# zQHIP=sb%YjwI80*gnO}Zu)XBcz$Ht>2#@gPm1AZtIDGuL`g~5p@8VwZXdP-ElN}^5 z%Jma+6Tp6-m9lX6|MjiQK-L?{_9ATjIMtw`{O%AEjMOi4OJ8V4$-Dy0Gj5yFgIrYAz_r_SHh)@X8 z^g^D770f{NuOTAIQYdaHdb2OJEmhrYDb-MV-Q!<18<7?QvWv20c#it>lw|fu$ZCwW zpoE6AMU+v=MznkoHR+y6*q=V9EX8@*x?ed z!<_T#cNmXqFF|)=7nc?~3_e8P7gjG}(i>9giPPZXUr4Dr-z#F+~MZUnW1@2e!iK4v=|?~)j$6d zP2pRfl7t@z%l`P5XAES}`780HM>iCeSV_WbaUjvAOZFZ~ZxZOvUpmwfzXe5jp5DBM zC^NCNm+FkcrMPBe1vq^{zdJA#!k}(nu2}S06U`y(-F=3Y1F8&qKTARRzBRAK6IAg% zC@_>mt@(gA)l`~uF?T!*BNUzs1uP(%BXZi{KUrqj#qcfT4-}Xc5-kUrX3S>HCRo*7 z>vNvfe%sFjSD6|(Dyo_}uE{+PP@(&XkZtIHwarMX2nJ)JxVC%8FU>Ps-KE6+%m$`= zI^XQ#h!kNxB7tWzS;~765yJ!@v$1hCaxC6ayIrs`Xeqi7zg%uMW4FW7cPy_4nLu9( zTqVv1a`)8+F)0x!hcd6-(B!EZh!NwN^XJfe4zglhz65#66X8dhd|S2lpbYU^6z}y7 zkk0?Ei*YplyvL<8u-xPB;LoIVim6E9m|{;z&uWH|NaEeAtqioM1dVi{0eAGbGhvD$S%@XB3zdg9GhNy zLbS{v;(FTKBI0NW1~xBViR)l5C%0DYLvj8Fq^iEHhzq=4s6?9O;tNCHSqelSte;lp zslLDXRPXr{dQo^^JwZ+f7Fdtmap5ud`a0xt((r0iHEB#^TQ51xf6vl7cfuy4w{(NM z%Pux%Aa?+<`7g%ho%F5o@?LjH)1iu11S!e;4!T)0aB2ESr-{?4eF3E=Kz%Ezmg*%p zm*&4q$WrZ8^lcS;e zgt@+*wRS2gi6HR7pn)Cr1W6gHFL_mvXVk)1D;Zy_cbDy@k+p`wQ%mv(ppH1QtK8^*Ws-`O6`G~e=f;81x`U|WQAL>OhF7r`#}BpG zv&kQM)@?MbH7sfh2EuK462gYU`l;XeUL8{YzT~S4Yv=I~-?{VCiAtv5Gltl^-#@Wq zVFyaA>A`L1shNaHh$dG@-;D{#B4N9n6ux_Yrn%94LJLt?>w4Tlb4nv$%b5}4(A((- zWvjh!C|Mpv@R;Z#VW@8&J2Nw{<=+gD@#(j+HHmyZ-d1PM)wC?OO@x`f^#NCyEy0}4owG!f~&ca$Coy^7QX2+~6D9g!j> z5)hgB5kC;!Dq8+gWEGFI7|5cH%sDtc%@4k4f)z#m{&|cQaW&7_fHuHX!gy`_=SB zNmI${kmQe>{-N}irefL3QUONy6h;aI49wwv4HGa@1q}$gUn^)g{HKM{Wi3Lh%R+X& z$TMAI>6FzsUPv$ncc)np0!_$Mw;SoQEP zB=WoRK0u?Cmm+B+I%xj?7Syf_1d2okk^=%!H`)B;c3Y}M`6Xv>?|{3z+L-`>$@KAK zzkzYF6Mavp?&%4UrvlWv?`Il|3jgjc*BTn?Is~j*SsDW%RG_-hL>7GHJ=%oGtqmHF z3>C{Hht1qYe-^4lAjkpu%q?T+0TZ#d@<{vG?A9B;zGcId=5L89_bqL1gMyQPyoI*G zb=up2oc)V(E)|QF4Nw1J#>RxmZvm)RlWm=qyfXj3Py|p41I@X@e9!b9hbiPr=qW9| zxt{sq6-dCdi}0+oUvg#~P2LKT5$-)6Nnc+8g?SCiOv@c{`TQBwSa?z&Bq~IBW3yf- z6Qz@#vd}VqVq?6KlMlQ_Q(OWsOWhRQUNKA=_u%O-Qfrg|Dw1F9iyr*8#p@Uxs;Ga= zvZd6ES{Zrf?)bxOCJ2_U{zxg2_g**zV)5vFUdliEc?ha5%EBTtP`JVywbx_s`!CZf zo=u&e%o1I}u^%BnS0}EZA!^}1vc*ZEKNixDeAMdLH{UTdH6Qx8SWwP<*R zd4HVr*q_G_x4~#L<gJ2>jXW!#2lgPbcfiiS7jQ& ztpDEr)%ndHBO*IB_sFqBY>|mBPJ4B!zanwR(-nfP&#n~BHDp<)_6+8JYt$JV|Dxpg!QXym$@Rsg8 z$62}SlZhet_Zt(`1R@rUem$UGC)M<;@DRsO~NpOx8; zQy!pe=3MI|rt#)l?w_L1O)8(OfDbSHoUz3fuso|hAP-D2duPqLn!>||8+?STuUg!l zcR2B1sdk2p{r)n@V}L`!aLqbA%j$(zMcr67Y%ueBC5x(to==FudVAE zJBhEoO0V|?#tb<6B!7OK-0^e9R^TgnL3Eb>0_O+0h|+4m*U!NioW_yaJQcl`rWAplbaZF?EK6 zZ|f3)c%7R1?e$U#aw}U#L@H9X?vLh&V?M5Ub;5^hq9yhhAJw-NZ+OGm*lFVOrFnX3 z;P)=G?>e~7P+Ah&oKJc8;IXqIbavrxdu{|WIotiS;}5$r?J!}HkQDmTca|R`g*dWO z=o{`(aEjo!gS(|&K#B12-M*CpE(@<3Vd>3T$%~8^khrw8Miy$VnCH)B58XYbKE5_` z*z~f96ygsIudH37fDR2IntN&bA#f`Qm+CzLaAI3*%j0s!viUn?#Nk$P`Zo?T;FyQo z9G@siH|zQPmfZgxnZ3A)93LH(XkYjbzkXNT!l^r~bzgOI`2Af9QXYCD1+4>15FqpM z#n4?M5HXlS6~x3j_q?<15F+#`Fjy2R^;I#s)lZc~KicM}6P zHq(Yrwg#tn`_g`e|LhrTdhN0l`#{MiLjqWOb`A7*5Hlf5tSf8jmQPRGrz*+S8oA0n<@>_vX7 zL=BQByH6q~=bW>eR{s2MN7y;spWa-4L|jPdA3`xC!rn!QWF+vw`AS{Gm>bH%RJ+Mi zd<-X0nc;^r&B!f#Hwa5Wrnn`#%w!&mq;$zO2yi*M`eRYo0R$IstruM1O2qFyfe=Ac zQ^H8S5DBSc-v$=_8|8&kY*do{t|mR$YM!+)%WD~6FxtX^samK#67x=*XfB!^p>%vq z=(*5Z;1v`$_0Bd-=6Whmm%p{{ zW`E_kSwm0JkM$RGj(^OR_u}>Ou`Vi~WO9~DiTkN0XTF1@_|1O$SNkuP;?pvFp}5{? zio><~b>wvKvnP_d+Lzk-mF$;uxWRZQ^LX}l2!yZFkIo5e+gvLWR6^B}FcZdS&~~3b z%9HyIJLnl#IuQUE)-f(VbHS~Y%zsio8Xsfg!A-tYoS^5j8E*$KxFGJKHqB^zX-nrz zNA($c?W^YgiP!^C)PoU{C`iyd*wvH#Eq12V>=+G8&)qa|faCL5KQv z5W|X7dj|QaJ50n1y;KBf8-WaWs;XQK^F%8{#G<5!KR1Bb?uP`DyP{!ww^prGb4p%F z(DX+D0OYR~AKkt*Sf>T6X3vT-p~YVgSv^h;?^Z zhnq1%Reo?V+Qlf|mLsBgkJNShAgx$GrWTN6lJM0inCz_r3EbauQ~-K4jsUq8G4@_Mw@49Jew#wL zy-_4G&ZT~OM$v8bxJAtbJDDjP<9)GQL5h6U{&ZkD6@EFa>371h!#24?%_1m2$~Yt< zFJWa?@n9_jsYaqk6z|Ndv$5tW6CdvCcyQh(e-(o94m(>~+Lgc2!OSBy##KY;b0e60T-ghcLC=0VF^|k1w z{NnAN%ht1$9n~reccV8Ij1dvZ3@aBm3!g7rubIO-@@|ez4w5+NDYd)#0C_1;xG>iV@|*<-@V}Pzl?E>>LxW{ITwzJv1-5djhCrG zkqYM1rqnlS0hrdxaWh|mUACL}SAxvANY?t)e@NxIYGWLV6j)*TxV!GW6t@tewnTo^ zRU=Fjre;z8 z(oR*d zW!mM6;aM_||6xu|=a%PgD5}22!V2~_lxcGedKQ{pm#0!)uNrpt8eE4gOQ73SvjJ?O zk)l#yI~Js@rb)}20RIQPyy^}gsA$SEmv2s5t4H2c>*g??NP%dXnUZ>$^MW^u}Id!(!ucmI^Ju#W< zXuWE|9Hs7sq!Hh&vkg(pqXgdE8q|JN9T7*7aeIqr>U`YvY);(z)#vYXd~;HzC5%86 z=yMAFffqwLh+0DPdA1UdFh{4SO9>X9W%oXzVjEo$6U?5P)vZARQ;|d(LB8}Q9ZX>yQ{zc!lzMl7NdbYJsW}n;ts1KkM-Up{F+6H_0+8pPV+vihpZA|3MNYmbJL3WrTcIRIt#Cyrscq`i%I6{$mCm2Svc%)ClBk@V${vsS&`*#SI?)55(U{KMyG2KOY|Z6@LT z-9JvIha@v{)ujH%CKn#_T#zyD_xStYu4Iod_KGr9m5qyk=}}lM0bIWheCuJb2qz*U z5FO?d&FaX?g%6O2mWC=0Z^J=#4>bXH>{l`}rHC!EDbhD&jU@2e$KRGN^^*6;M@24*iK$^Cu?%!f^ht<{e`H zVV~pp|6}bg-nx`X>h*Thl7YjNmgN1-v!pba$c1ia5`&MZp@f@dNgy*+%MA2cl`?vD z`h6bi-lh~H;7CQmV+#{M5pt+Y-3a#eg%y+(3=Mt4Sot zX(2CfiQZu#3WVNtR_bD1%VA1dXvuF7CACiu#setA9ya9&&rUl}zagH>W=^^b21Pdw z;~%W?&hy8dmn18I+6%KELIc;~h@@@A2X>3+*Sogk^A=kR!_jvQy!{55NJEa-4+u67 z-88K?9KN(+vs9QAj0X)3jivGl?TCH80f56gr%(p@dE+w|!59~9J~fddf-4{YFeh`^ z@YaXe!KWh^NhDYI`jRBc8xH_L{ifaZ0tWY(JRs_h_9my>9(5UI1ZjEN;gt*LVNbA| zZS*0iKiK2Z@vESy4)3C_T8QP&UDHQ@j^dN)$!K^54zJEjRxil5ot;A9!G$igp+dJY zj%MEpE!jX$txLBux<1iPg90trOGl;=y}JEVnAhyB+O?sXHHqNm6vm_BO@kNAU-AjH zfaR(tZrJ?!op34I{16!nGpvAgQxDv}$Ifo%^73w3u@SCT2iKU8a-VojJ*5x9C-Bqw z@knG7zxmufG>G!F)~!rPFpYs>Kg_TkvH*RVJm5#=X3QmJJnjC;HFp{R@-dF1QbiUJQ|r&$A?jbp{5V z)%aZ;|D6+8J~Uo%^>lJpvX0bU}UknX1*lr9lZm~G3aT)Zwh|id;bc$~ydfq(Zn;grVHVgP^zDLPF4UV*3Lh z36aWF7)vCAPK~56`9x}^#eK7jS>d`J)v zTN^2|Ef68Kg7%rE*srJD$8e3PbmsGT{_~ho)Mayttbp`GPu_c46BU5*LMv&pgD}IX zak{DX0|&9{hYWEb>zU$SQQPJGj9)CT4JlHnvGGJ5&j$G#QgUgWi+sb`{^H^xaD zwf|RBmn4-+XU!3~Jb3w->%+*J;YJe%{$R@dd80USxaMYx>+YG1jMPf01SOToGwuM& zgkZ&I+FCIp(o^;9L_MbyDnVW;$4B0TEF}LQcUY&(<3b~ zRtP5*weIx*Oo&UcQM1>LJy>t$6CHji z3roUwmXm&Z?&z-AU=Sgfu(IZ=O#GQ%{B}CG!sO4N`c96Wy@@d2ao?8uSCcq&)f>h* zZHN$HDx{34gpXcb?}o3qq-LMC!uyUHDmg8!>kTTZT8c)+m7u`V=YiZ7VP`IDs7#=9 z_pS2pfKPt=d@myL#w}axmZS#5bh5(wL`1xOwqc|Mvy3j$oyYPzi47FrpZ&q8+4*pg z@6bHq*}viLakj`nS0Tz&WT!^VS+EzO7$f6QI9$(JgNTs>Q!Qv3WH+;JRh>`t%Ip5y zVmAEF^=)Y9xFy4m3Y+ZLpkDLMRE(lt&JaSaXGy7a|z`SQ>FN^>mtLznCT^1ckGe zE&twsUCtaX^Yt9+y(_~&Ml;xN;W}WY>M>a=M5APEmN_-WXWOlE$AdUln=GAKhBs+6 z=Pr)8+L1!F4gG{Q&zxN89lHE0Vr-`~kd643(5J(*wlTKi6pgV%VS!ZZN3!crgTIj1 z)Of0-*W-5TA|{1S6T^C@RZ{LB@_eE()opsE7uPDqct6nDyDb~8boL;6t4kQXC zIa3=cga--F&a<5DewovBVP57fYu`EfPXB0HL&NB0EETMvk4Co zo?T@2Ukh~D{?H)WxBsz@VUdkQtIa5~(jGbeu5BUsLBj2b&Q@u2KR17tXNJX~*#1-PNz^t-= zU0URPOVS13eswe+WPFK&&E{?jzxr82rpH%VUN?;O>?UOCKIZ_?J!@zc>94wnknG4$ zi)u?Bl-`dZ9Ntr1t=(`nc`r-XpSF>X?0=lid}eAI9p?3Qb2LK|9Lma+SmB7S)5dt{ z2}v-t%Gna=q0iIBHaL7HG>RDH?WlWt(?Xppn<6fsF`wNK*^rS3Jvh_#YEsp3TsFSJ z-S|eQ*))Ps?}E=U+WBYMZd)raSHs9qCV)}9R85i-@%OS($>sS&Ck1Tg8S*YC%A6nJ z3MAo6K91C8SSuUSsbVQ80=evS;AUi$IEGZFZFb`9m~FUj^$HOz*FWFA4IUcWZK;!I z_n4N|!DmtEqtUcCf;jxa8jqh0zn=BwiksiTIj64)m}){bO6q!1612gBZE~tCfof!? zEE6ymYwh=u@6alW7{HYDRyIN2pP=q&r$s*zMnpU?uTp{_?yYQ9rywNxUHFj--OtFJ z0CnZg_v9`rhv40xgMRYwR|!UWpO+`Ix<58<3{{H>K#`NQ*t+d)D0lzT)MDqw?G-o^OdD3 z^~jYrz-uv}Or}%zyM|bmdOCnDB4K1eqc&x**6?R}q1~^VOznCJiy{RDlEK85D|D#} zL~PJ!8(ot2{CPX3AFUW^JD?g&o>@>FR2ejC?o|VOcdP1~)xm0Yeutfv{nJTt(XPE} z3F^vZF>RJddE=ls61XSgxiaeTbo6#?5H2E}8Ickc1hBEr$1%oCsTVy-rKjt)RPiwn zkU{RTeumb6zRI*H{IH+;(mMi+{7PE*|2fb4KaI>T16n660YGP#mxQq&KwU)#TB&3n F{9mv{-?sn& literal 7419 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 diff --git a/web/public/pwa-192x192.png b/web/public/pwa-192x192.png index 32b61dc7c23fe88d45863d19fbbcf41a67011388..50e7541fb3f3f0e6aaab38cec8dc20638f82c6c7 100644 GIT binary patch literal 7586 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 diff --git a/web/public/pwa-512x512.png b/web/public/pwa-512x512.png index 4a5b421db6a4145b7a820ab7326b64d4fac29854..a3b72b9156ac51bedeeb40f9e8c9c7a1f0fe657c 100644 GIT binary patch literal 13012 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 literal 13595 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 SP6DqV($zB5EW2#~=sy5&B^fmU diff --git a/web/public/pwa-64x64.png b/web/public/pwa-64x64.png index 2476ff7af9c4ba74a999dcd8291f2ebde65738bd..a2b9f58b7e56f6cf6e4b07e66ff08cc3897fbf44 100644 GIT binary patch literal 2507 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 P00000NkvXXu0mjf-E6#h diff --git a/web/public/pwa-maskable-192x192.png b/web/public/pwa-maskable-192x192.png index c9f5befde611c4377c3afe8ea4b7f823ef61aee4..a0e024c1e616e3fcb568a0acba56de4d9f884927 100644 GIT binary patch literal 6781 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 literal 6995 zcmdUUS6CBm)NMe(P^1?Hl#U<>3etP;p(;&`6hVoE-b<*`n>2yYJ5m&s(2?E)NEbqv zPLP^VbMl?5b93(h|6*qLJkLDu?Ah-wYpsbk0BPJKV6t}=9Z*Ir8 zcFB@P81nrSJuZ!jg>;xI^z*hBZagrPV_Y86)!)r*Vg$YK2&TK)5WkHUdj&{0v;CiH ztf#jS@TdTj2w&uTt|s=L>MgU?926NMw;6*|^Tnidln~ze7D7d}KMI8!@>~4bfr|0B z8P_>Lf&`*5m6>VI$NI{;l}ezjsQbpG38Iy~&n}6m6YQGHm^?RGJ7*)0sMu5xua$p# zhaQF*6`e;cF(%wG;_Nfwd_2RxCB7o`CU{bk#Za!3f=@fGCC!`uxg*)T$VmkB;MX#S zp-B#$4}yzxwarBEFS)s2CSpmcq)I3;?L0nIs6@CF3ivJ+Xzc-1)b=oCx~*y3kiQIH z88QEN2Xg97Rsskq&kRNc@Go&el0q*ORR;@mp74+P)x&U2WQbG^udnh>~ z2V?;V&LJ7dh?;-bu3*B!7vVmu{N(n6ZDhx_$02l^Tv0nTOz-rz7F^U)LwOLv7ja*P z1Rog}Ha~#F(#IaGt3Ao!-9U4)OH*E`w$=N-8iu0@k5K0IjrO$Q*Oew z^wU514-5R6y@b1ggw|3r9A3V^XS61mtG`xpuQ7H@h4Xo3STp)QAvJH5fz?M2#5DW1s_hMgt9c|X#;hCDLh59?Jc{3>>>CPN;sLy{y6{O#}-U1KnC{&FBnS!0kw!IJPXCyI#;+8pH zoif+&}Z|;hV`~l4Cg387J0~B zOJ|n@#s`rLFSeC8j$MUF878by zJd`5<>;}}6jz1xEKnmjb_7$K+ovfZxlgIhiJS1}>HlU0rFwRZlExU>TU94Ihqw>l~ z7G(|Uf?rCTt86B7QAv$LzOs!j=Y$JE8I=o2eR8|fbE4Z*P$C#LMAn0~@mdO(8XIKv zEd(nUJ_b}zWR8q-rT$dsri&t7cQSF18fX4YYB!C|hP?u`CklxdmxSZT5Rp=Hbp~)& zz&qA9Hi=di(MX{Ap#|7rR``h_m}ka}z|1E8|8MC1m@G_93Gn-MJ?POs@_FyA<8w8^ z{v`TRDXWC-JL%b)b_b-(5{eRoxPKA(F zJ1WCjUNUy|_`5gY?2S`D+Qe$LDHA$Yd3ERlrHB4u{DCKT=LDVI@udF*yt>uT$Rp{R%Z7 z(SaGBV^J_80DOtz^UY9#m<<2zYVTOjmu?<+dKkunpe`41DnDV0M6jz$S`t47G2G&I zU)CS+zXM-Q_3B8py<~#R#v@gQbyO<&^T@e-0GOcRZ~E2rm&a+Lop+iJ^4RzGT$o-I z`uh*IIf}JJI8@q^q|JVq@-H zgs2H(lpJrhR>h@khfYatw(oIAJB0&%2~+U1vb5$l_mxG3KdC$X4=lhkv}7Sr$m5%A z)8y&tZQVZXt;CHK1+K)I083zDCDVMBclcP+jn&XR1iHt!ZxgsRb~F(JuG@WY!GDUV z=4+f7FHwzSi!9eo3-E9FK?u~zv0uIkUin(unF2mLPltl7v{Otl&0e2J3$h{&*mQtB zU!Hd_yt>Dkrs|XXy&!1x_rsj+1kg#Z4K11Jt1m-OOB8_~8jss>TZq!@-)EX3#tICF zS9`99=C8BFBT_+t3yZprE8%*;x-^q!r1_n2z)ot7OA-A5|Ia&2Q+!@ZUzJR_lT#c& zA*wA@-tiQcG7G(M*L!@gA}WV8g*uI)#9by;WUsv4JD{5Sde{_*1Q)MmD2Vbi>HJU> zMsZ43Vb@^38ir|meIlr6K{<&?DqU$XYv zQrq^qS2k;Oi(RtRwg7_#m*eBLnjm1AE2!Ycm&Jwk6k$35oriYmYpd=M<%@NGPf0p) z#x~b~OOAov-ff+xDyVPzE%fP!Iwt_OtPx#by|8 zWozLU4h_nNlGH7=Tl;4?96&6WQ!M4*D35MRK75@Ss4&))J$nBo)e)%9YVWQi`}2{x z%=aIJ)cGwfgRMuD^9b>ECo_#*Hdts?j)z{}9=qb+?;QP9EGxUo&zUIFm&I=`NC(+q zdHb8y#kh5``X~X7rxh-Ynr)}7bu|q>G<*@FwILhi3hHrBN4N9(tq`AkE`LV&;5c(> z#(0jx`t{@@+``o&3l^GkTC{MxIF7xt&zE@}znUP{^IboNy{UPL3pp1fwzaZTM%)U; zke}6xgDynr)>zxCSl;LceMd_+3Zy4r{uNjqF|+^)k9^Q`oONE}FqJCtXeDqFVOf^n8gq4rheKmun_t zSLq~UH&d?mghETX@Un*52Whlf5A7}2|0D9+9mGboAO?JV! zdVGFU|4bR#FY)wU-&F~7V;t$uTnL8lDT*QT{q?+@@YM`9vNKcm=ugAu;oa1>HfAGI_o3SbQ&LK{Lx?wSD`|31 zr#bgY4&Xb3XUuIuL1^p;rCTrkrmnGvI`qqTsvm zh9838iL1A2%ql*4Dbks40w`>bnG%5MoR8{ZBPp|;CI~=aPhOTmKcn;oMeUxsn2^9g z{^S&h7CvL~q=hG^TDyg0oY`CjZ0FI8$_uR4((>Ye#=tu6)gRUEo)uYlgv1E#n>zlg+?Hvq(J0g-o z$Sz-Qarp$eg~TO=GSEX>1zq$IE8~?7iA-bg5lCNk^11)K)a_KxouBZm{jDFaz>F71l zR)CwO_x*Km_%ppot!bKo!27tbDn$a6T0j#BOVfc&{L$7VpZ16F@3u_4$|u`?o1D?h zbmSpu^z#dayD@5Hgx9r{1uZoRlwm-P%ay(E@|km%yb2hsCkfl6XraPM!yCc8a%Hpz zYe@7E*ICoh|HNdYzRhX)9|@c92+)NN_w)8#`%OTc!~uPj?yp*+XcY(mg`5%oxVF+C z2J*({urB(I&OuS`nc7}|ABIA|P_12QS`T&}yLv1*NbkJuDUn73Rj!~ZKitW7QheDFBHVu;Ou0Owg-eIb9Sj~85WEuBx{R^9+OXTmDI|4@{+DG zi;)XQm;h^`Q;bdyB|Qbo6W{W=j@Bl5^!I2P*7yO{c=W~%_F=|^Tp4U-lP0ioA-2c| zHf!`2?&9A+>3_+J{v6?xSkq}GyZm|~b|`Id{NMEa$%g!G>S9@5Ch;x1+6lbrUZVh7 z1C$#+Dd})};Bzm;hYWBjw2AK->w>w72tQ)v;=J8bz!clDUFn*Vgo+B#lSI}mIP zf`4i8{u8fK0abII{IMj`(9Z&ry5?A>g4SW}Y`C{{$W|$z;f%G#wz*>F5r;32gu-?+ zbj`pAypmpourc|1$n>9A*jvCDUu9nkwv(jw4AL!oeZEFxMnDgYpJ{S2-!4@-?qL`L$`Ji{mWy{<6)kN;|BdiIuv%~EL`=YCQ#=Xvg8`Q! zCt>$|uc$*|DS*_ofLQ)!aigs&H@G}ExGFF7z67JW*ds%$ca`;x*4rUQ+aDQGo(zE{ z4^fk0%^p%C+OyH^ehk+uf0o6}j-QouCjZ9;=dg_r&Q8o%1ts1R|FhzYhg)dumW{0o zw`DJuR%7)v$X1E&a1crMNqS0$$uCoE3DdH^7b$9458h)hF0R#1n0a3vpM2FmZ^LjC zwDeX81nWw+nweddt7CRPBdc$*)wnt2E+piW7b|>pUKeMN>9O*Y0IjoLk6}_4n!{E$ zKG;@Ci0bm!1U{0tJ+Wdw8_B3GpGFuj6_-M5ZW?>FV$^#0R0EV} zZ=Yu*=oay;S5RbrEzWXCQ$1T7UNslj! zHEl$ZiJ5}GFOtG!P^GOCw#rf)BRh?l=gs37uB13h3W0cshJU^4)a!lpi%CYpc!y)pzoL5E1jb@jtrPa5a{&M;-@4H-UW{=N z+34~VI?CdCHmF+{-Zww+!FZIRFTlLm4ZTGUMCMWvySNh%{E4dSLM9--AViD$Y#Bk*pB7*d1Zu_|A*wBJ#6?G?(o9d!8lJJ*;Oe zt0%-Q?+#Rr#oC(5%;{#1M_VY92hXUm1WJ<=YvP2z8b9PR{2HBLmio?YC`u5B=1sE+ zR}5Ux{&!|=huYZofG#OUD4M+Dd?JuMSxQfG6CiViFx?=J+(M{Fr*~pBfKw&Tcr8x| z`Sav89rR(RKf^@Jaj}@qwa-SJUrdbDd7xWffPDbk%{2YNhOh1_*~@Z4Q3D^Bao@k8iL-hLyzaMFE$Mx%;{Zv<07Dkf`72R;ZIt{s2I@ds!7`o zx)5VC-x~|OyvV3f3DJXQYx$-5*cWL*XoFB9L|u~*)P$DTZ7M@XK(JAx_tRfi?Qx3@IlUV6j{h(Z*(JS0dbl7irotMaEVH@D2AlivXMQg+ zr?jjeIa@w_)%q%8aQUUvfY}_ZVbd+`zQxfcF&+*oD_W3#M!tA5dV0H8J2F~T&?sj8 zHVZC3O_YH0&uIE$WNuyh_d3KFKDgkKKb|RwAE2G9wpjmj8dfQ2&1=f(G!H8y;f}X4 zZ!`kFMYjX;oe9!!&kno)MR5qZbWiBSRj7h$)R1s{sN>6G-Hi_>VbeOn=YiWhYTg)X zxJ+X6oK2k#QAo5Nv*RpZ1ZKbyzZh^ZoZ;6)6@C#|P~N^}w(j*{_5SgxH|#|qsjICX zc*h7T=O&i;e-*XMzhk=TaZxM^s)6^$+4?z-!tQNg*((d9Ici{kLCI z9uE?x<0b3fvQf2bnit1g4>AdZpLJ(;;dUW)wH*+lvaJs2gEy(ly5*cl*zVHyxw#=4 z3H)PCgacf}mu>>{PZtIQuPd6q$g^hrerg}C@6)Kmn2evbaN-lPSy2r>E-+wRg5^I| z=j#3A+x9!#2}tEj#o_9dWoZzv>!$dZAJ2x-&&|JUc6{!Gxi;0#W;{{{fiSSW} zY*h0c;f}8LGTZ+Cv->x-j*h=Plf>v;7+%>#JS{ zxXi6d($CXn3_N>MSx+9=#DGFMC0uE2uN@?uCv$NmD}pMObjy|FSi;JyC{X<(W#9+s zezs}C#?=pZhpJff`q|*$6lzErqBT0=;094LoqX`Fr-(=t=l33F0f0``cfxP~3ydrr a6)ikhSYfKz7XPyhprr;1e4j!nj}%2!!$BgZqym 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 D{4C)T literal 13680 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 diff --git a/website/public/apple-touch-icon-180x180.png b/website/public/apple-touch-icon-180x180.png index 33a45e14196c4d04516789d583ad51040661e66a..441cf210837633dfe845aaaa7a0a3f4a5eda8b16 100644 GIT binary patch literal 7197 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 F{9mv{-?sn& literal 7419 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 From fdf4fdaa38b88f4c04f4735e0d8d5b828027abe5 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 20 Aug 2026 11:11:43 +0800 Subject: [PATCH 084/168] feat(web,hub): make large conversation export warning-only (#1648) * feat(export): warn before large session downloads * fix(export): surface server size errors --- hub/src/sync/messageService.test.ts | 43 +++++-- hub/src/sync/messageService.ts | 70 +++++++++-- hub/src/sync/syncEngine.ts | 4 +- hub/src/web/routes/sessions.test.ts | 58 ++++++++- hub/src/web/routes/sessions.ts | 17 ++- shared/src/sessionExport.ts | 20 ++- web/src/api/client.test.ts | 26 ++++ web/src/api/client.ts | 12 +- .../components/SessionExportDialog.test.tsx | 76 +++++++++++ web/src/components/SessionExportDialog.tsx | 83 +++++++++++- web/src/lib/locales/en.ts | 4 + web/src/lib/locales/zh-CN.ts | 4 + web/src/lib/sessionExport/download.test.ts | 118 ++++++++++++++++++ web/src/lib/sessionExport/download.ts | 53 ++++++-- web/src/types/api.ts | 6 +- 15 files changed, 536 insertions(+), 58 deletions(-) create mode 100644 web/src/components/SessionExportDialog.test.tsx create mode 100644 web/src/lib/sessionExport/download.test.ts diff --git a/hub/src/sync/messageService.test.ts b/hub/src/sync/messageService.test.ts index 1c7d664854..6841da44d1 100644 --- a/hub/src/sync/messageService.test.ts +++ b/hub/src/sync/messageService.test.ts @@ -14,6 +14,7 @@ import { join } from 'node:path' import { MessageService } from './messageService' import { Store } from '../store' import type { Server } from 'socket.io' +import { SESSION_EXPORT_MESSAGE_LIMIT } from '@hapi/protocol/sessionExport' import type { Session, SyncEvent } from '@hapi/protocol/types' // --------------------------------------------------------------------------- @@ -175,21 +176,39 @@ describe('MessageService goal status filtering', () => { expect(result.payload.messages.map((message) => message.id)).toEqual([normal.id, scheduled.id]) }) - it('returns too-large instead of truncating an export over the cap', () => { - const store = makeStore() - const session = makeSession(store, 'session-export-cap') - - store.messages.addMessage(session.id, { role: 'user', content: 'One' }) - store.messages.addMessage(session.id, { role: 'agent', content: 'Two' }) + it('warns above the recommended threshold and exports the full history after confirmation', () => { + const sessionStore = makeStore() + const session = makeSession(sessionStore, 'session-export-warning') + const rows = Array.from({ length: SESSION_EXPORT_MESSAGE_LIMIT + 1 }, (_, index) => ({ + id: `message-${index}`, + sessionId: session.id, + content: { role: 'user', content: `Message ${index}` }, + createdAt: index + 1, + seq: index + 1, + localId: null, + invokedAt: index + 1, + scheduledAt: null + })) as ReturnType + const store = { + messages: { getAllMessages: () => rows }, + scratchlist: { list: () => [] } + } as unknown as Store const service = new MessageService(store, makeIo(() => {}), makePublisher() as any) - const result = service.getSessionExport(session.id, toProtocolSession(session), 1) - - expect(result).toEqual({ - type: 'too-large', - count: 2, - limit: 1 + const warning = service.getSessionExport(session.id, toProtocolSession(session)) + + if (warning.type !== 'warning') throw new Error('Expected export warning') + expect(typeof warning.estimatedBytes).toBe('number') + expect(warning).toMatchObject({ + type: 'warning', + count: SESSION_EXPORT_MESSAGE_LIMIT + 1, + limit: SESSION_EXPORT_MESSAGE_LIMIT }) + + const confirmed = service.getSessionExport(session.id, toProtocolSession(session), { force: true }) + expect(confirmed.type).toBe('success') + if (confirmed.type !== 'success') throw new Error('Expected confirmed export') + expect(confirmed.payload.messages).toHaveLength(SESSION_EXPORT_MESSAGE_LIMIT + 1) }) it('includes scratchlist text and attachment metadata in chronological order (tiann/hapi#1235)', () => { diff --git a/hub/src/sync/messageService.ts b/hub/src/sync/messageService.ts index 3b714fa373..c1e310e89f 100644 --- a/hub/src/sync/messageService.ts +++ b/hub/src/sync/messageService.ts @@ -1,6 +1,8 @@ import { HAPI_SESSION_EXPORT_SCHEMA_VERSION, + SESSION_EXPORT_MAX_BYTES, SESSION_EXPORT_MESSAGE_LIMIT, + type HapiSessionExport, type HapiSessionExportResult } from '@hapi/protocol/sessionExport' import type { AttachmentMetadata, DecryptedMessage, Session } from '@hapi/protocol/types' @@ -51,6 +53,37 @@ function toVisibleDecryptedMessages(messages: StoredMessageForDelivery[]): Decry return messages.filter(isWebVisibleStoredMessage).map(toDecryptedMessage) } +function jsonByteLength(value: unknown): number { + const json = JSON.stringify(value) + return json === undefined ? Number.MAX_SAFE_INTEGER : Buffer.byteLength(json, 'utf8') +} + +function estimateSessionExportBytes( + session: Session, + exportedAt: number, + messages: StoredMessageForDelivery[], + scratchlist: HapiSessionExport['scratchlist'] +): number { + const prefix = JSON.stringify({ + schemaVersion: HAPI_SESSION_EXPORT_SCHEMA_VERSION, + exportedAt, + session + }) + const suffix = JSON.stringify({ scratchlist }) + if (prefix === undefined || suffix === undefined) { + return Number.MAX_SAFE_INTEGER + } + + const messageBytes = messages.reduce( + (total, message, index) => total + jsonByteLength(toDecryptedMessage(message)) + (index > 0 ? 1 : 0), + 0 + ) + return Buffer.byteLength(prefix.slice(0, -1), 'utf8') + + Buffer.byteLength(',"messages":[', 'utf8') + + messageBytes + + Buffer.byteLength(`],${suffix.slice(1)}`, 'utf8') +} + function isQueuedUserMessage(message: StoredMessageForDelivery): boolean { const record = unwrapRoleWrappedRecordEnvelope(message.content) return record?.role === 'user' && message.invokedAt === null @@ -177,24 +210,15 @@ export class MessageService { getSessionExport( sessionId: string, session: Session, - limit: number = SESSION_EXPORT_MESSAGE_LIMIT + options: { force?: boolean } = {} ): HapiSessionExportResult { - const messages = this.store.messages.getAllMessages(sessionId) + const storedMessages = this.store.messages.getAllMessages(sessionId) .filter(isExportVisibleStoredMessage) .sort((a, b) => { const aAt = a.invokedAt ?? a.createdAt const bAt = b.invokedAt ?? b.createdAt return aAt !== bAt ? aAt - bAt : a.seq - b.seq }) - .map(toDecryptedMessage) - - if (messages.length > limit) { - return { - type: 'too-large', - count: messages.length, - limit - } - } // Chronological ASC for archive readability (store list is DESC). const scratchlist = this.store.scratchlist.list(sessionId) @@ -211,13 +235,33 @@ export class MessageService { attachments: row.attachments })) + const exportedAt = Date.now() + const estimatedBytes = estimateSessionExportBytes(session, exportedAt, storedMessages, scratchlist) + if (estimatedBytes > SESSION_EXPORT_MAX_BYTES) { + return { + type: 'too-large', + count: storedMessages.length, + estimatedBytes, + maxBytes: SESSION_EXPORT_MAX_BYTES + } + } + + if (!options.force && storedMessages.length > SESSION_EXPORT_MESSAGE_LIMIT) { + return { + type: 'warning', + count: storedMessages.length, + limit: SESSION_EXPORT_MESSAGE_LIMIT, + estimatedBytes + } + } + return { type: 'success', payload: { schemaVersion: HAPI_SESSION_EXPORT_SCHEMA_VERSION, - exportedAt: Date.now(), + exportedAt, session, - messages, + messages: storedMessages.map(toDecryptedMessage), scratchlist } } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 049398f46b..42067f5a10 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -407,8 +407,8 @@ export class SyncEngine { return this.messageService.getQueuedState(sessionId, localIds) } - getSessionExport(sessionId: string, session: Session): HapiSessionExportResult { - return this.messageService.getSessionExport(sessionId, session) + getSessionExport(sessionId: string, session: Session, options?: { force?: boolean }): HapiSessionExportResult { + return this.messageService.getSessionExport(sessionId, session, options) } getDeliverableMessagesAfter(sessionId: string, options: { afterSeq: number; limit: number; now: number }): DecryptedMessage[] { diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index f2107f2f1a..d690e0521c 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -60,7 +60,7 @@ function createApp(session: Session, opts?: { resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> reopenSession?: (sessionId: string, namespace: string) => Promise listSlashCommands?: SyncEngine['listSlashCommands'] - getSessionExport?: (sessionId: string, session: Session) => unknown + getSessionExport?: (sessionId: string, session: Session, options?: { force?: boolean }) => unknown sessionExists?: boolean archiveSession?: (sessionId: string) => Promise getCursorChatStoreStatus?: SyncEngine['getCursorChatStoreStatus'] @@ -359,23 +359,69 @@ describe('sessions routes', () => { expect(body.messages.map((message) => message.id)).toEqual(['msg-1', 'msg-2']) }) - it('returns 413 when the export exceeds the hard message cap', async () => { + it('returns a structured warning instead of rejecting an export above the message threshold', async () => { + const session = createSession() + const warning = { + type: 'warning' as const, + count: 20_001, + limit: 20_000, + estimatedBytes: 12_345_678 + } + const { app } = createApp(session, { + getSessionExport: () => warning + }) + + const response = await app.request('/api/sessions/session-1/export') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(warning) + }) + + it('passes an explicit force confirmation through for the complete export', async () => { + const session = createSession() + let receivedOptions: { force?: boolean } | undefined + const payload = { + schemaVersion: 2 as const, + exportedAt: 1_762_000_000_000, + session, + messages: [], + scratchlist: [] + } + const { app } = createApp(session, { + getSessionExport: (_sessionId, _session, options) => { + receivedOptions = options + return { type: 'success', payload } + } + }) + + const response = await app.request('/api/sessions/session-1/export?force=true') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(payload) + expect(receivedOptions).toEqual({ force: true }) + }) + + it('returns structured 413 details for exports over the resource limit', async () => { const session = createSession() const { app } = createApp(session, { getSessionExport: () => ({ type: 'too-large', count: 20_001, - limit: 20_000 + estimatedBytes: 104_857_601, + maxBytes: 104_857_600 }) }) - const response = await app.request('/api/sessions/session-1/export') + const response = await app.request('/api/sessions/session-1/export?force=true') expect(response.status).toBe(413) expect(await response.json()).toEqual({ - error: 'Session export too large', + type: 'too-large', + error: 'Session export exceeds the resource limit', + code: 'session_export_too_large', count: 20_001, - limit: 20_000 + estimatedBytes: 104_857_601, + maxBytes: 104_857_600 }) }) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index e4191dbdbf..44238c9e4f 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -139,14 +139,25 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return sessionResult } - const result = engine.getSessionExport(sessionResult.sessionId, sessionResult.session) + const force = c.req.query('force') === 'true' + const result = engine.getSessionExport( + sessionResult.sessionId, + sessionResult.session, + { force } + ) if (result.type === 'too-large') { return c.json({ - error: 'Session export too large', + type: 'too-large', + error: 'Session export exceeds the resource limit', + code: 'session_export_too_large', count: result.count, - limit: result.limit + estimatedBytes: result.estimatedBytes, + maxBytes: result.maxBytes }, 413) } + if (result.type === 'warning') { + return c.json(result) + } return c.json(result.payload) }) diff --git a/shared/src/sessionExport.ts b/shared/src/sessionExport.ts index b65734d3fb..88cca8678c 100644 --- a/shared/src/sessionExport.ts +++ b/shared/src/sessionExport.ts @@ -12,6 +12,7 @@ import { DecryptedMessageSchema, ScratchlistEntrySchema, SessionSchema } from '. */ export const HAPI_SESSION_EXPORT_SCHEMA_VERSION = 2 export const SESSION_EXPORT_MESSAGE_LIMIT = 20_000 +export const SESSION_EXPORT_MAX_BYTES = 100 * 1024 * 1024 export const HapiSessionExportSchema = z.object({ schemaVersion: z.literal(HAPI_SESSION_EXPORT_SCHEMA_VERSION), @@ -23,6 +24,23 @@ export const HapiSessionExportSchema = z.object({ export type HapiSessionExport = z.infer +export type HapiSessionExportWarning = { + type: 'warning' + count: number + limit: number + estimatedBytes: number +} + +export type HapiSessionExportTooLarge = { + type: 'too-large' + count: number + estimatedBytes: number + maxBytes: number +} + +export type HapiSessionExportResponse = HapiSessionExport | HapiSessionExportWarning + export type HapiSessionExportResult = | { type: 'success'; payload: HapiSessionExport } - | { type: 'too-large'; count: number; limit: number } + | HapiSessionExportWarning + | HapiSessionExportTooLarge diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index 35b16006d3..15ff856f11 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -80,6 +80,32 @@ describe('ApiClient error mapping', () => { } }) + it('returns export warnings and sends explicit confirmation for large exports', async () => { + const warning = { + type: 'warning', + count: 20_001, + limit: 20_000, + estimatedBytes: 12_345_678 + } + const payload = { + schemaVersion: 2, + exportedAt: 1_762_000_000_000, + session: { id: 'session-1' }, + messages: [], + scratchlist: [] + } + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify(warning), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(payload), { status: 200 })) + + const api = new ApiClient('test-token') + await expect(api.getSessionExport('session-1')).resolves.toEqual(warning) + await expect(api.getSessionExport('session-1', { force: true })).resolves.toEqual(payload) + + expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/sessions/session-1/export') + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/sessions/session-1/export?force=true') + }) + it('loads the Cursor chat store status for the selected session', async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ onDisk: false, store: null }), { status: 200 }) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6f9af31e89..6ab7a91989 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -24,7 +24,7 @@ import type { SkillsResponse, SpawnResponse, VisibilityPayload, - HapiSessionExport, + HapiSessionExportResponse, HubHealthResponse, SessionResponse, SessionTitleSuggestionResponse, @@ -353,9 +353,13 @@ export class ApiClient { return await this.request(`/api/sessions/${encodeURIComponent(sessionId)}`) } - async getSessionExport(sessionId: string, options?: { signal?: AbortSignal }): Promise { - return await this.request( - `/api/sessions/${encodeURIComponent(sessionId)}/export`, + async getSessionExport( + sessionId: string, + options?: { force?: boolean; signal?: AbortSignal } + ): Promise { + const query = options?.force ? '?force=true' : '' + return await this.request( + `/api/sessions/${encodeURIComponent(sessionId)}/export${query}`, { signal: options?.signal } ) } diff --git a/web/src/components/SessionExportDialog.test.tsx b/web/src/components/SessionExportDialog.test.tsx new file mode 100644 index 0000000000..6c4790bda3 --- /dev/null +++ b/web/src/components/SessionExportDialog.test.tsx @@ -0,0 +1,76 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError, type ApiClient } from '@/api/client' +import { I18nProvider } from '@/lib/i18n-context' +import { ToastProvider } from '@/lib/toast-context' +import { SessionExportDialog } from './SessionExportDialog' + +function renderDialog(api: ApiClient, onClose: () => void) { + return render( + + + + + + ) +} + +afterEach(() => cleanup()) + +describe('SessionExportDialog large export confirmation', () => { + it('shows count and estimated size, and cancel does not retry or download', async () => { + const getSessionExport = vi.fn().mockResolvedValue({ + type: 'warning', + count: 20_001, + limit: 20_000, + estimatedBytes: 12_345_678 + }) + const api = { getSessionExport } as unknown as ApiClient + const onClose = vi.fn() + + renderDialog(api, onClose) + fireEvent.click(screen.getByRole('button', { name: 'Download' })) + + await waitFor(() => { + expect(screen.getByText(/20,001/)).toBeInTheDocument() + expect(screen.getByText(/11\.8 MiB/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Download anyway' })).toBeInTheDocument() + }) + expect(getSessionExport).toHaveBeenCalledOnce() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(onClose).toHaveBeenCalledOnce() + expect(getSessionExport).toHaveBeenCalledOnce() + }) + + it('localizes server resource-limit details instead of showing the raw 413 response', async () => { + const getSessionExport = vi.fn().mockRejectedValue(new ApiError( + 'HTTP 413: server response', + 413, + 'session_export_too_large', + JSON.stringify({ + type: 'too-large', + error: 'Session export exceeds the resource limit', + code: 'session_export_too_large', + count: 20_001, + estimatedBytes: 104_857_601, + maxBytes: 104_857_600 + }) + )) + const api = { getSessionExport } as unknown as ApiClient + + renderDialog(api, vi.fn()) + fireEvent.click(screen.getByRole('button', { name: 'Download' })) + + await waitFor(() => { + expect(screen.getByText(/too large to safely prepare/)).toBeInTheDocument() + expect(screen.getByText(/20,001/)).toBeInTheDocument() + expect(screen.queryByText(/HTTP 413/)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/src/components/SessionExportDialog.tsx b/web/src/components/SessionExportDialog.tsx index ab04a6d04f..94c3c17be1 100644 --- a/web/src/components/SessionExportDialog.tsx +++ b/web/src/components/SessionExportDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' -import type { ApiClient } from '@/api/client' +import type { HapiSessionExportTooLarge, HapiSessionExportWarning } from '@hapi/protocol/sessionExport' +import { ApiError, type ApiClient } from '@/api/client' import { downloadSessionExport, readSessionExportFormat, @@ -24,17 +25,51 @@ type SessionExportDialogProps = { api: ApiClient | null } +function formatExportSize(bytes: number): string { + const units = ['B', 'KiB', 'MiB', 'GiB'] + let value = bytes + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}` +} + +function parseSessionExportTooLarge(error: unknown): HapiSessionExportTooLarge | null { + if (!(error instanceof ApiError) || error.code !== 'session_export_too_large' || !error.body) { + return null + } + + try { + const details = JSON.parse(error.body) as Partial + if ( + details.type !== 'too-large' + || typeof details.count !== 'number' + || typeof details.estimatedBytes !== 'number' + || typeof details.maxBytes !== 'number' + ) { + return null + } + return details as HapiSessionExportTooLarge + } catch { + return null + } +} + export function SessionExportDialog(props: SessionExportDialogProps) { const { t } = useTranslation() const toast = useToast() const [format, setFormat] = useState('json') const [isExporting, setIsExporting] = useState(false) + const [warning, setWarning] = useState(null) const [error, setError] = useState(null) const abortRef = useRef(null) useEffect(() => { if (!props.isOpen) return setFormat(readSessionExportFormat()) + setWarning(null) setError(null) }, [props.isOpen]) @@ -48,6 +83,7 @@ export function SessionExportDialog(props: SessionExportDialogProps) { abortRef.current?.abort() abortRef.current = null setIsExporting(false) + setWarning(null) props.onClose() } @@ -65,8 +101,21 @@ export function SessionExportDialog(props: SessionExportDialogProps) { try { const result = await downloadSessionExport(props.api, props.sessionId, format, { + force: warning !== null, signal: controller.signal }) + if (result.type === 'warning') { + setWarning(result.warning) + return + } + if (result.type === 'too-large') { + setError(t('session.export.error.tooLarge', { + count: result.count.toLocaleString(), + size: formatExportSize(result.estimatedBytes), + maxSize: formatExportSize(result.maxBytes) + })) + return + } toast.addToast({ title: t('session.export.toast.success.title'), body: t('session.export.toast.success.body', { filename: result.filename }), @@ -78,9 +127,16 @@ export function SessionExportDialog(props: SessionExportDialogProps) { if (controller.signal.aborted) { return } - const message = error instanceof Error && error.message - ? error.message - : t('session.export.error.default') + const resourceLimit = parseSessionExportTooLarge(error) + const message = resourceLimit + ? t('session.export.error.tooLarge', { + count: resourceLimit.count.toLocaleString(), + size: formatExportSize(resourceLimit.estimatedBytes), + maxSize: formatExportSize(resourceLimit.maxBytes) + }) + : error instanceof Error && error.message + ? error.message + : t('session.export.error.default') setError(message) toast.addToast({ title: t('session.export.toast.error.title'), @@ -141,6 +197,19 @@ export function SessionExportDialog(props: SessionExportDialogProps) { + {warning ? ( +

+ ) : null} + {error ? (
{error} @@ -152,7 +221,11 @@ export function SessionExportDialog(props: SessionExportDialogProps) { {t('button.cancel')}
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index e2ed947685..7fab5b23cf 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -302,8 +302,12 @@ export default { 'session.export.format.markdown': 'Markdown', 'session.export.format.markdown.description': 'Readable view generated from the same export payload.', 'session.export.download': 'Download', + 'session.export.downloadAnyway': 'Download anyway', 'session.export.downloading': 'Exporting…', + 'session.export.warning.title': 'Large export', + 'session.export.warning.description': 'This export contains {count} messages and is estimated at {size}. That exceeds the recommended threshold of {limit} messages.', 'session.export.error.noApi': 'Not connected to server', + 'session.export.error.tooLarge': 'This export is too large to safely prepare ({count} messages, {size}; maximum {maxSize}).', 'session.export.error.default': 'Failed to export conversation', 'session.export.toast.success.title': 'Conversation exported', 'session.export.toast.success.body': 'Downloaded {filename}', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2efb95c237..60c1813d5e 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -306,8 +306,12 @@ export default { 'session.export.format.markdown': 'Markdown', 'session.export.format.markdown.description': '从同一份导出载荷生成的可读视图。', 'session.export.download': '下载', + 'session.export.downloadAnyway': '仍然下载', 'session.export.downloading': '导出中…', + 'session.export.warning.title': '导出内容较大', + 'session.export.warning.description': '此导出包含 {count} 条消息,预计大小为 {size},超过建议的 {limit} 条消息阈值。', 'session.export.error.noApi': '未连接到服务器', + 'session.export.error.tooLarge': '导出内容过大,无法安全准备({count} 条消息,{size};上限 {maxSize})。', 'session.export.error.default': '导出对话失败', 'session.export.toast.success.title': '对话已导出', 'session.export.toast.success.body': '已下载 {filename}', diff --git a/web/src/lib/sessionExport/download.test.ts b/web/src/lib/sessionExport/download.test.ts new file mode 100644 index 0000000000..1ef655b115 --- /dev/null +++ b/web/src/lib/sessionExport/download.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ApiClient } from '@/api/client' +import type { HapiSessionExport, HapiSessionExportWarning } from '@hapi/protocol/sessionExport' +import { + downloadSessionExport, + type SessionExportDownloadResult +} from './download' + +function makePayload(): HapiSessionExport { + return { + schemaVersion: 2, + exportedAt: Date.UTC(2026, 5, 5, 12, 0, 0), + session: { + id: 'session-abcdef123456', + namespace: 'default', + seq: 1, + createdAt: Date.UTC(2026, 5, 5, 10, 0, 0), + updatedAt: Date.UTC(2026, 5, 5, 12, 0, 0), + active: false, + activeAt: 1, + metadata: { + path: '/tmp/project', + host: 'localhost', + name: 'Large session' + }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 1, + thinking: false, + thinkingAt: 1, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + permissionMode: 'default', + collaborationMode: 'default' + }, + messages: [], + scratchlist: [] + } +} + +function makeApi(response: HapiSessionExport | HapiSessionExportWarning) { + return { + getSessionExport: vi.fn().mockResolvedValue(response) + } as unknown as ApiClient +} + +describe('downloadSessionExport', () => { + const createObjectURL = vi.fn((_blob: Blob) => 'blob:session-export') + const revokeObjectURL = vi.fn() + + beforeEach(() => { + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: createObjectURL + }) + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: revokeObjectURL + }) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + createObjectURL.mockClear() + revokeObjectURL.mockClear() + }) + + it('returns the warning without creating a file download', async () => { + const warning: HapiSessionExportWarning = { + type: 'warning', + count: 20_001, + limit: 20_000, + estimatedBytes: 12_345_678 + } + const api = makeApi(warning) + + const result = await downloadSessionExport(api, 'session-1', 'json') + + expect(result).toEqual({ type: 'warning', warning }) + expect(createObjectURL).not.toHaveBeenCalled() + expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled() + }) + + it('downloads confirmed JSON exactly once', async () => { + const api = makeApi(makePayload()) + + const result = await downloadSessionExport(api, 'session-1', 'json', { force: true }) + + expect((result as SessionExportDownloadResult).type).toBe('downloaded') + expect(api.getSessionExport).toHaveBeenCalledWith('session-1', { force: true, signal: undefined }) + expect(createObjectURL).toHaveBeenCalledOnce() + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce() + const blob = createObjectURL.mock.calls[0]?.[0] as Blob + expect(blob.type).toBe('application/json;charset=utf-8') + expect(result.type).toBe('downloaded') + if (result.type !== 'downloaded') throw new Error('Expected JSON download') + expect(result.filename).toMatch(/\.json$/) + }) + + it('downloads confirmed Markdown exactly once', async () => { + const api = makeApi(makePayload()) + + const result = await downloadSessionExport(api, 'session-1', 'markdown', { force: true }) + + expect((result as SessionExportDownloadResult).type).toBe('downloaded') + expect(api.getSessionExport).toHaveBeenCalledWith('session-1', { force: true, signal: undefined }) + expect(createObjectURL).toHaveBeenCalledOnce() + expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledOnce() + const blob = createObjectURL.mock.calls[0]?.[0] as Blob + expect(blob.type).toBe('text/markdown;charset=utf-8') + expect(result.type).toBe('downloaded') + if (result.type !== 'downloaded') throw new Error('Expected Markdown download') + expect(result.filename).toMatch(/\.md$/) + }) +}) diff --git a/web/src/lib/sessionExport/download.ts b/web/src/lib/sessionExport/download.ts index 16bace68fd..b03b4323a8 100644 --- a/web/src/lib/sessionExport/download.ts +++ b/web/src/lib/sessionExport/download.ts @@ -1,11 +1,25 @@ +import { SESSION_EXPORT_MAX_BYTES } from '@hapi/protocol/sessionExport' import type { ApiClient } from '@/api/client' -import type { HapiSessionExport } from '@/types/api' +import type { + HapiSessionExport, + HapiSessionExportResponse, + HapiSessionExportWarning +} from '@/types/api' import { serializeSessionMarkdown } from './markdown' export type SessionExportFormat = 'json' | 'markdown' +export type SessionExportDownloadResult = + | { type: 'warning'; warning: HapiSessionExportWarning } + | { type: 'too-large'; count: number; estimatedBytes: number; maxBytes: number } + | { type: 'downloaded'; filename: string; messageCount: number } + export const SESSION_EXPORT_FORMAT_STORAGE_KEY = 'hapi.sessionExportFormat' +function isSessionExportWarning(response: HapiSessionExportResponse): response is HapiSessionExportWarning { + return 'type' in response && response.type === 'warning' +} + export function readSessionExportFormat(): SessionExportFormat { if (typeof window === 'undefined') return 'json' const value = window.localStorage.getItem(SESSION_EXPORT_FORMAT_STORAGE_KEY) @@ -47,8 +61,7 @@ export function buildSessionExportFilename(payload: HapiSessionExport, format: S return `${slug}-${shortId}-${formatDate(payload.exportedAt)}.${extension}` } -function downloadTextFile(filename: string, text: string, mimeType: string): void { - const blob = new Blob([text], { type: mimeType }) +function downloadBlobFile(filename: string, blob: Blob): void { const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url @@ -64,14 +77,32 @@ export async function downloadSessionExport( api: ApiClient, sessionId: string, format: SessionExportFormat, - options?: { signal?: AbortSignal } -): Promise<{ filename: string; messageCount: number }> { - const payload = await api.getSessionExport(sessionId, { signal: options?.signal }) + options?: { force?: boolean; signal?: AbortSignal } +): Promise { + const response: HapiSessionExportResponse = await api.getSessionExport(sessionId, { + force: options?.force, + signal: options?.signal + }) + if (isSessionExportWarning(response)) { + return { type: 'warning', warning: response } + } + + const payload: HapiSessionExport = response const filename = buildSessionExportFilename(payload, format) - if (format === 'json') { - downloadTextFile(filename, `${JSON.stringify(payload, null, 2)}\n`, 'application/json;charset=utf-8') - } else { - downloadTextFile(filename, serializeSessionMarkdown(payload), 'text/markdown;charset=utf-8') + const text = format === 'json' + ? `${JSON.stringify(payload, null, 2)}\n` + : serializeSessionMarkdown(payload) + const mimeType = format === 'json' ? 'application/json;charset=utf-8' : 'text/markdown;charset=utf-8' + const blob = new Blob([text], { type: mimeType }) + const estimatedBytes = blob.size + if (estimatedBytes > SESSION_EXPORT_MAX_BYTES) { + return { + type: 'too-large', + count: payload.messages.length, + estimatedBytes, + maxBytes: SESSION_EXPORT_MAX_BYTES + } } - return { filename, messageCount: payload.messages.length } + downloadBlobFile(filename, blob) + return { type: 'downloaded', filename, messageCount: payload.messages.length } } diff --git a/web/src/types/api.ts b/web/src/types/api.ts index d7a9a4ddbd..baf8807518 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -77,7 +77,11 @@ export type { WorktreeMetadata } from '@hapi/protocol/types' -export type { HapiSessionExport } from '@hapi/protocol/sessionExport' +export type { + HapiSessionExport, + HapiSessionExportResponse, + HapiSessionExportWarning +} from '@hapi/protocol/sessionExport' export type SessionMetadataSummary = { path: string From e3f13c4eb6a25271c367fbea2f8d519037c77ba0 Mon Sep 17 00:00:00 2001 From: KorenKrita Date: Thu, 20 Aug 2026 18:19:15 +0800 Subject: [PATCH 085/168] feat(web): anchor composer settings sheet to the clicked value button's section (#1627) --- .../HappyComposer.modelEffortButtons.test.tsx | 41 +++++++- .../AssistantChat/HappyComposer.tsx | 95 +++++++++++++------ 2 files changed, 105 insertions(+), 31 deletions(-) diff --git a/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx b/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx index 72b34c756c..d9d8032d05 100644 --- a/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx +++ b/web/src/components/AssistantChat/HappyComposer.modelEffortButtons.test.tsx @@ -167,15 +167,51 @@ describe('HappyComposer generic model/effort value buttons', () => { expect(screen.getByRole('button', { name: 'Settings' })).toBeTruthy() }) - it('opens the settings sheet from the model button with Model before Permission', () => { + it('opens only the Model section from the model button (anchored open)', () => { renderComposer('claude') fireEvent.click(screen.getByRole('button', { name: 'Sonnet 4' })) + expect(screen.getByText('Model')).toBeTruthy() + // Anchored open: the other sections stay collapsed. + expect(screen.queryByText('Permission Mode')).toBeNull() + expect(screen.queryByText('Effort')).toBeNull() + }) + + it('opens only the Effort section from the effort button (anchored open)', () => { + renderComposer('claude') + fireEvent.click(screen.getByRole('button', { name: 'High' })) + expect(screen.getByText('Effort')).toBeTruthy() + expect(screen.queryByText('Model')).toBeNull() + expect(screen.queryByText('Permission Mode')).toBeNull() + }) + + it('switches from the Model to the Effort section without closing the sheet', () => { + renderComposer('claude') + fireEvent.click(screen.getByRole('button', { name: 'Sonnet 4' })) + expect(screen.getByText('Model')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'High' })) + expect(screen.getByText('Effort')).toBeTruthy() + expect(screen.queryByText('Model')).toBeNull() + }) + + it('opens the full sheet from the gear with Model before Permission', () => { + renderComposer('claude') + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) const model = screen.getByText('Model') const permission = screen.getByText('Permission Mode') expect(model.compareDocumentPosition(permission) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(screen.getByText('Effort')).toBeTruthy() }) + it('expands an anchored sheet to the full sheet when the gear is clicked', () => { + renderComposer('claude') + fireEvent.click(screen.getByRole('button', { name: 'Sonnet 4' })) + expect(screen.queryByText('Effort')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Settings' })) + expect(screen.getByText('Model')).toBeTruthy() + expect(screen.getByText('Effort')).toBeTruthy() + expect(screen.getByText('Permission Mode')).toBeTruthy() + }) + it('shows generic value buttons for Pi with the provider-qualified model label', () => { renderComposer('pi', { piModels: [ @@ -203,7 +239,8 @@ describe('HappyComposer generic model/effort value buttons', () => { // The value button label and the matching sheet row share the model name. expect(screen.getAllByText('Gemini 2.5 Pro').length).toBeGreaterThan(1) expect(screen.getByText('Vertex Gemini 2.5 Pro')).toBeTruthy() - expect(screen.getByText('Effort')).toBeTruthy() + // Anchored open from the model button: the Effort section stays collapsed. + expect(screen.queryByText('Effort')).toBeNull() }) it('keeps the gear reachable on narrow viewports even when the toolbar layout hides it', () => { diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 6f01979c12..a412372bbe 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -525,6 +525,9 @@ export function HappyComposer(props: { const lastSendAcceptanceRef = useRef(props.sendAcceptance) const pendingSendAttemptIdRef = useRef(null) const [showSettings, setShowSettings] = useState(false) + // Anchored settings sheet: the model/effort value buttons open only their + // own section; the gear (null) opens the full sheet. + const [settingsSection, setSettingsSection] = useState<'model' | 'effort' | null>(null) const [isAborting, setIsAborting] = useState(false) const [isSwitching, setIsSwitching] = useState(false) const [showContinueHint, setShowContinueHint] = useState(false) @@ -1394,16 +1397,27 @@ export function HappyComposer(props: { } }, [api, pendingSchedule]) - const handleSettingsToggle = useCallback(() => { + // Opens (or closes) the settings sheet. `section` anchors the sheet to a + // single section ('model' / 'effort'); the gear passes nothing = full sheet. + // Re-clicking with a different anchor while open switches the anchor + // instead of closing, so model->effort moves between sections directly. + const handleSettingsToggle = useCallback((section: 'model' | 'effort' | null = null) => { haptic('light') - setShowSettings((prev) => { - if (prev) { - setCursorDrillDownBase(null) - setCursorDrillDownDefaultVariant(null) - } - return !prev - }) - }, [haptic]) + if (showSettings && section !== settingsSection) { + // Open with a different anchor: switch sections, keep the sheet up. + setSettingsSection(section) + return + } + if (showSettings) { + setCursorDrillDownBase(null) + setCursorDrillDownDefaultVariant(null) + setShowSettings(false) + setSettingsSection(null) + return + } + setSettingsSection(section) + setShowSettings(true) + }, [haptic, showSettings, settingsSection]) const clearCursorDrillDown = useCallback(() => { setCursorDrillDownBase(null) @@ -1413,6 +1427,7 @@ export function HappyComposer(props: { const dismissSettings = useCallback(() => { clearCursorDrillDown() setShowSettings(false) + setSettingsSection(null) }, [clearCursorDrillDown]) const handleModelChange = useCallback((nextModel: { provider: string; modelId: string } | string | null) => { @@ -1623,14 +1638,20 @@ export function HappyComposer(props: { return option?.label ?? (effort ? effort : undefined) }, [isNarrowViewport, onEffortChange, agentFlavor, selectedPiModel, effort, claudeEffortOptions]) + // Wrapper for DOM onClick consumers: never leak the MouseEvent into the + // `section` parameter (the gear must always open the full sheet). + const handleGearToggle = useCallback(() => { + handleSettingsToggle(null) + }, [handleSettingsToggle]) + const handleModelValueToggle = useCallback(() => { if (modelEffortControlsDisabled) return - handleSettingsToggle() + handleSettingsToggle('model') }, [modelEffortControlsDisabled, handleSettingsToggle]) const handleEffortValueToggle = useCallback(() => { if (modelEffortControlsDisabled) return - handleSettingsToggle() + handleSettingsToggle('effort') }, [modelEffortControlsDisabled, handleSettingsToggle]) const overlayPositionClass = isExpanded @@ -1639,11 +1660,26 @@ export function HappyComposer(props: { const overlays = useMemo(() => { // Unified settings sheet for every flavor (Pi included). - if (showSettings && (showCollaborationSettings || showCopilotAgentModeSettings || showPermissionSettings || showModelSettings || showModelEffortSettings || showModelReasoningEffortSettings || showEffortSettings || showFastModeSettings)) { + // Anchored open (settingsSection): a model/effort value button expands + // only its own area; the gear (null) expands the full sheet. + const sheetModelAreaOn = settingsSection !== 'effort' + const sheetEffortAreaOn = settingsSection !== 'model' + const sheetOthersOn = settingsSection === null + const sheetModelSettings = showModelSettings && sheetModelAreaOn + const sheetModelEffortSettings = showModelEffortSettings && sheetModelAreaOn + const sheetModelReasoningEffortSettings = showModelReasoningEffortSettings && sheetEffortAreaOn + const sheetEffortSettings = showEffortSettings && sheetEffortAreaOn + const sheetPermissionSettings = showPermissionSettings && sheetOthersOn + const sheetFastModeSettings = showFastModeSettings && sheetOthersOn + const sheetCollaborationSettings = showCollaborationSettings && sheetOthersOn + const sheetCopilotAgentModeSettings = showCopilotAgentModeSettings && sheetOthersOn + const sheetModelAreaSettings = sheetModelSettings || sheetModelEffortSettings || sheetModelReasoningEffortSettings || sheetEffortSettings + const sheetOtherSettings = sheetFastModeSettings || sheetCollaborationSettings || sheetCopilotAgentModeSettings + if (showSettings && (sheetCollaborationSettings || sheetCopilotAgentModeSettings || sheetPermissionSettings || sheetModelSettings || sheetModelEffortSettings || sheetModelReasoningEffortSettings || sheetEffortSettings || sheetFastModeSettings)) { return (
- {showModelSettings ? ( + {sheetModelSettings ? (
{t('misc.model')} @@ -1734,11 +1770,11 @@ export function HappyComposer(props: {
) : null} - {showModelSettings && showModelEffortSettings ? ( + {sheetModelSettings && sheetModelEffortSettings ? (
) : null} - {showModelEffortSettings ? ( + {sheetModelEffortSettings ? ( ) : null} - {(showModelSettings || showModelEffortSettings) && showModelReasoningEffortSettings ? ( + {(sheetModelSettings || sheetModelEffortSettings) && sheetModelReasoningEffortSettings ? (
) : null} - {showModelReasoningEffortSettings ? ( + {sheetModelReasoningEffortSettings ? (
{t('misc.reasoningEffort')} @@ -1798,11 +1834,11 @@ export function HappyComposer(props: {
) : null} - {showModelReasoningEffortSettings && showEffortSettings ? ( + {sheetModelReasoningEffortSettings && sheetEffortSettings ? (
) : null} - {showEffortSettings ? ( + {sheetEffortSettings ? (
{t('misc.effort')} @@ -1841,11 +1877,11 @@ export function HappyComposer(props: {
) : null} - {showModelAreaSettings && showPermissionSettings ? ( + {sheetModelAreaSettings && sheetPermissionSettings ? (
) : null} - {showPermissionSettings ? ( + {sheetPermissionSettings ? (
{t('misc.permissionMode')} @@ -1882,11 +1918,11 @@ export function HappyComposer(props: {
) : null} - {(showPermissionSettings || showModelAreaSettings) && showOtherSettings ? ( + {(sheetPermissionSettings || sheetModelAreaSettings) && sheetOtherSettings ? (
) : null} - {showFastModeSettings ? ( + {sheetFastModeSettings ? (
{t('misc.fastMode')} @@ -1923,11 +1959,11 @@ export function HappyComposer(props: {
) : null} - {showFastModeSettings && (showCollaborationSettings || showCopilotAgentModeSettings) ? ( + {sheetFastModeSettings && (sheetCollaborationSettings || sheetCopilotAgentModeSettings) ? (
) : null} - {showCollaborationSettings ? ( + {sheetCollaborationSettings ? (
{t('misc.collaborationMode')} @@ -1964,7 +2000,7 @@ export function HappyComposer(props: {
) : null} - {showCopilotAgentModeSettings ? ( + {sheetCopilotAgentModeSettings ? (
{t('misc.copilotAgentMode')} @@ -2024,6 +2060,7 @@ export function HappyComposer(props: { return null }, [ showSettings, + settingsSection, agentFlavor, piModels, piSelectedModel, @@ -2266,7 +2303,7 @@ export function HappyComposer(props: { settingsDisabled={modelEffortControlsDisabled} modelValueButtonRef={modelValueButtonRef} effortValueButtonRef={effortValueButtonRef} - onSettingsToggle={handleSettingsToggle} + onSettingsToggle={handleGearToggle} expanded={isExpanded} onExpandedToggle={handleExpandedToggle} showTerminalButton={showTerminalButton} @@ -2294,11 +2331,11 @@ export function HappyComposer(props: { hasAttachments={blocksScheduling} modelValueLabel={modelValueLabel} modelValueDisabled={modelEffortControlsDisabled} - modelValueOpen={showSettings} + modelValueOpen={showSettings && settingsSection !== 'effort'} onModelValueToggle={handleModelValueToggle} effortValueLabel={effortValueLabel} effortValueDisabled={modelEffortControlsDisabled} - effortValueOpen={showSettings} + effortValueOpen={showSettings && settingsSection !== 'model'} onEffortValueToggle={handleEffortValueToggle} scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} From 3d94e8eef356674553f6e7f0b1b8a344a4dc0fd5 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Thu, 20 Aug 2026 19:36:36 +0800 Subject: [PATCH 086/168] fix(cli): preserve source extensions for generated media (#1650) --- cli/src/claude/utils/startHappyServer.test.ts | 20 +++++++++++++++++++ .../modules/common/generatedImages.test.ts | 13 ++++++++++++ cli/src/modules/common/generatedImages.ts | 13 ++++++++++-- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index b067ee22b0..b507c1c4e0 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -148,6 +148,26 @@ describe('startHappyServer skill_lookup', () => { })) }) + it('preserves the source extension when display_media title omits one', async () => { + const path = join(sandboxDir, 'plan-a.zip') + await writeFile(path, Buffer.from([0x50, 0x4b, 0x03, 0x04])) + const mcp = await connect(false) + + const result = await mcp.callTool({ + name: 'display_media', + arguments: { path, title: 'Cursor Plan A Markdown 导出' } + }) as ToolResult + + expect(result.isError).toBe(false) + expect(result.content?.[0]?.text).toContain('Displayed media: Cursor Plan A Markdown 导出.zip') + expect(sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({ + type: 'generated-image', + fileName: 'Cursor Plan A Markdown 导出.zip', + mimeType: 'application/octet-stream', + source: { ingress: 'mcp', toolName: 'display_media' } + })) + }) + it('does not expose change_title when native ACP titles are enabled', async () => { const sessionClient = { updateMetadata: vi.fn(), diff --git a/cli/src/modules/common/generatedImages.test.ts b/cli/src/modules/common/generatedImages.test.ts index 989a6d8dc9..2baaae1bc9 100644 --- a/cli/src/modules/common/generatedImages.test.ts +++ b/cli/src/modules/common/generatedImages.test.ts @@ -116,6 +116,19 @@ describe('generatedImages', () => { clearGeneratedImages() }) + it('preserves the source extension when a custom file name omits one', () => { + const file = registerGeneratedImage({ + id: 'test-zip-with-title', + path: '/tmp/plan-a.zip', + fileName: 'Cursor Plan A Markdown 导出', + mimeType: 'application/octet-stream', + bytes: Buffer.from('PK\\x03\\x04') + }) + + expect(file.fileName).toBe('Cursor Plan A Markdown 导出.zip') + clearGeneratedImages() + }) + it('snapshots image bytes at registration time', () => { const source = Buffer.from('original image bytes') const image = registerGeneratedImage({ diff --git a/cli/src/modules/common/generatedImages.ts b/cli/src/modules/common/generatedImages.ts index 7d268d5c0b..7676750645 100644 --- a/cli/src/modules/common/generatedImages.ts +++ b/cli/src/modules/common/generatedImages.ts @@ -1,4 +1,4 @@ -import { basename } from 'node:path' +import { basename, extname } from 'node:path' import { fileURLToPath } from 'node:url' import { open } from 'node:fs/promises' import { randomUUID } from 'node:crypto' @@ -277,6 +277,14 @@ export function isInlineMediaMimeType(mimeType: string): boolean { return mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/') } +function preserveSourceExtension(fileName: string, path: string): string { + const sourceExtension = extname(basename(path)) + if (!sourceExtension || extname(fileName)) { + return fileName + } + return `${fileName}${sourceExtension}` +} + export function detectDisplayMediaMimeType(bytes: Uint8Array): string { const imageMimeType = detectImageMimeType(bytes) if (imageMimeType) return imageMimeType @@ -312,9 +320,10 @@ export function registerGeneratedImage(args: { id: string; path: string; mimeTyp generatedImageBytes -= previous.content.byteLength } + const fallbackFileName = basename(args.path) || `${args.id}.png` const metadata: GeneratedImageMetadata = { id: args.id, - fileName: args.fileName || basename(args.path) || `${args.id}.png`, + fileName: preserveSourceExtension(args.fileName || fallbackFileName, args.path), content, mimeType: args.mimeType, createdAt: Date.now() From dd2e978fe3b5a01d0b9704aa37394747a72dc0e7 Mon Sep 17 00:00:00 2001 From: Ananovo Date: Thu, 20 Aug 2026 19:36:55 +0800 Subject: [PATCH 087/168] feat(web): add mark unread session action (#1649) * feat(web): add mark unread session action * fix(web): keep explicit unread state on selected sessions * fix(web): address mark unread review feedback * fix(web): preserve newer manual unread state --- web/src/components/SessionActionMenu.test.tsx | 19 +++ web/src/components/SessionActionMenu.tsx | 34 ++++ web/src/components/SessionHeader.tsx | 2 + web/src/components/SessionList.tsx | 25 ++- web/src/components/SessionRowSummary.test.tsx | 93 +++++++++++ web/src/components/SessionRowSummary.tsx | 23 ++- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/lib/sessionAttention.test.ts | 32 ++++ web/src/lib/sessionAttention.ts | 10 +- web/src/lib/sessionLastSeen.test.ts | 72 +++++++++ web/src/lib/sessionLastSeen.ts | 150 +++++++++++++++++- 12 files changed, 448 insertions(+), 14 deletions(-) diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx index 9afb661e66..7747bc7326 100644 --- a/web/src/components/SessionActionMenu.test.tsx +++ b/web/src/components/SessionActionMenu.test.tsx @@ -74,6 +74,25 @@ describe('SessionActionMenu - Pin action', () => { }) }) +describe('SessionActionMenu - Mark unread action', () => { + it('fires the mark-unread handler and closes the menu', () => { + const onMarkUnread = vi.fn() + const onClose = vi.fn() + renderMenu({ onMarkUnread, onClose }) + + fireEvent.click(screen.getByRole('menuitem', { name: 'Mark as unread' })) + + expect(onMarkUnread).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('does not render the action when no handler is provided', () => { + renderMenu() + + expect(screen.queryByRole('menuitem', { name: 'Mark as unread' })).toBeNull() + }) +}) + describe('SessionActionMenu - positioning', () => { it('centers the menu on the supplied anchor', () => { const originalInnerWidth = window.innerWidth diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index ca44e8e840..1d8c704f5b 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -25,6 +25,7 @@ type SessionActionMenuProps = { sessionGlobalPinned?: boolean onSetPinMode?: (mode: 'none' | 'project' | 'global') => void onExport?: () => void + onMarkUnread?: () => void onSyncCodex?: () => void onSyncPi?: () => void onArchive: () => void @@ -57,6 +58,21 @@ function EditIcon(props: { className?: string }) { ) } +function UnreadIcon(props: { className?: string }) { + return ( + + + + ) +} + function PinIcon(props: { className?: string; filled?: boolean }) { return ( { + onClose() + onMarkUnread?.() + } + const handleSyncCodex = () => { onClose() onSyncCodex?.() @@ -391,6 +413,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { {t('session.action.copyReference')} + {onMarkUnread ? ( + + ) : null} + {onSetPinMode ? ( <>
@@ -1650,6 +1663,7 @@ export function SessionList(props: { titleSuggestionAvailable={titleSuggestionAvailable} selected={s.id === selectedSessionId} showDetailedStatus={showDetailedStatus} + lastSeenVersion={lastSeenVersion} />
))} @@ -2015,6 +2029,7 @@ export function SessionList(props: { inRunningSection projectLabel={getGroupDisplayName(s.metadata?.worktree?.basePath ?? s.metadata?.path ?? 'Other')} machineLabel={resolveMachineLabel(s.metadata?.machineId ?? null)} + lastSeenVersion={lastSeenVersion} /> ))}
diff --git a/web/src/components/SessionRowSummary.test.tsx b/web/src/components/SessionRowSummary.test.tsx index 31dc989d9f..8e6cbeca27 100644 --- a/web/src/components/SessionRowSummary.test.tsx +++ b/web/src/components/SessionRowSummary.test.tsx @@ -61,4 +61,97 @@ describe('SessionRowSummary background status', () => { expect(tooltip).toHaveTextContent('Background tasks running') expect(tooltip).toHaveTextContent('2 tasks running') }) + + it('refreshes unread attention when the local watermark version changes', () => { + const session = makeSummary({ + active: false, + backgroundTaskCount: 0, + updatedAt: 2_000, + }) + localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 2_000 })) + const view = render( + + + + ) + + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument() + + localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 1_999 })) + view.rerender( + + + + ) + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('New activity') + }) + + it('shows an explicit unread dot for the selected session only', () => { + const session = makeSummary({ + id: 'selected-unread', + active: false, + backgroundTaskCount: 0, + updatedAt: 2_000, + }) + localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 2_000 })) + localStorage.setItem('hapi.sessionManualUnread.v1', JSON.stringify({ [session.id]: 2_000 })) + + const view = render( + + + + ) + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('New activity') + + view.rerender( + + + + ) + + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument() + }) + + it('shows an explicit unread dot before the thinking spinner', () => { + const session = makeSummary({ + id: 'selected-thinking-unread', + thinking: true, + updatedAt: 2_000, + }) + localStorage.setItem('hapi.sessionLastSeen.v1', JSON.stringify({ [session.id]: 2_000 })) + localStorage.setItem('hapi.sessionManualUnread.v1', JSON.stringify({ [session.id]: 2_000 })) + + render( + + + + ) + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('New activity') + }) }) diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index 8f6ad7cafd..0602f7ae45 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -5,7 +5,7 @@ import { ScheduleIcon } from '@/components/icons' import { HoverTooltip, SESSION_ROW_TOOLTIP_FOCUS_CLASS, useSessionRowTooltipIds } from '@/components/HoverTooltip' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' import { classifySessionAttention } from '@/lib/sessionAttention' -import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' +import { getSessionLastSeenAt, getSessionManualUnreadAt } from '@/lib/sessionLastSeen' import { formatRelativeTime } from '@/lib/relativeTime' import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt } from '@/lib/codexImportedSessions' @@ -109,6 +109,8 @@ export function SessionRowSummary(props: { nestedTooltips?: boolean /** Pass from parent when the parent owns `aria-describedby` (session list). */ attentionTooltipId?: string + /** Recompute local unread attention when the session-list watermark changes. */ + lastSeenVersion?: number scheduleTooltipId?: string className?: string /** Rows inside the pinned "in progress" section skip the text label (dot only). */ @@ -125,6 +127,7 @@ export function SessionRowSummary(props: { selected = false, nestedTooltips = true, attentionTooltipId: attentionTooltipIdProp, + lastSeenVersion, scheduleTooltipId: scheduleTooltipIdProp, className, inRunningSection = false, @@ -140,9 +143,10 @@ export function SessionRowSummary(props: { ? classifySessionAttention(s, { selected, lastSeenAt: getSessionLastSeenAt(s.id), + manualUnreadAt: getSessionManualUnreadAt(s.id), }) : null, - [s, selected, showDetailedStatus] + [s, selected, showDetailedStatus, lastSeenVersion] ) const attentionLabel = attention ? getAttentionLabel(attention, t) : null const urgentAttention = attention !== null @@ -170,7 +174,20 @@ export function SessionRowSummary(props: { > {sessionName}
- {s.active && s.thinking ? ( + {attention?.kind === 'unread' && nestedTooltips && attentionId ? ( + + ) : attention?.kind === 'unread' ? ( + + ) : s.active && s.thinking ? ( ) : urgentAttention && nestedTooltips && attentionId ? ( { expect(attention).toBeNull() }) + it('shows an explicitly marked unread dot for the selected session', () => { + const attention = classifySessionAttention( + makeSummary({ id: 'a', updatedAt: 5000 }), + { selected: true, lastSeenAt: 5000, manualUnreadAt: 5000 } + ) + expect(attention).toEqual({ kind: 'unread' }) + }) + + it('keeps an explicitly marked unread dot while the selected session is thinking', () => { + const attention = classifySessionAttention( + makeSummary({ id: 'a', thinking: true, updatedAt: 5000 }), + { selected: true, lastSeenAt: 5000, manualUnreadAt: 5000 } + ) + expect(attention).toEqual({ kind: 'unread' }) + }) + + it('does not show selected-session attention for ordinary new activity', () => { + const attention = classifySessionAttention( + makeSummary({ id: 'a', updatedAt: 5000 }), + { selected: true, lastSeenAt: 1000 } + ) + expect(attention).toBeNull() + }) + + it('does not carry an explicit unread dot across newer activity', () => { + const attention = classifySessionAttention( + makeSummary({ id: 'a', updatedAt: 6000 }), + { selected: true, lastSeenAt: 5000, manualUnreadAt: 5000 } + ) + expect(attention).toBeNull() + }) + it('prioritizes permission over unread activity', () => { const attention = classifySessionAttention( makeSummary({ diff --git a/web/src/lib/sessionAttention.ts b/web/src/lib/sessionAttention.ts index c4d5186d01..a3260c9b4a 100644 --- a/web/src/lib/sessionAttention.ts +++ b/web/src/lib/sessionAttention.ts @@ -16,9 +16,15 @@ export function sessionIsUnread( export function classifySessionAttention( summary: SessionSummary, - options: { selected: boolean; lastSeenAt: number } + options: { selected: boolean; lastSeenAt: number; manualUnreadAt?: number | null } ): SessionAttention | null { - if (options.selected || summary.thinking) { + if (options.selected) { + return options.manualUnreadAt === summary.updatedAt + ? { kind: 'unread' } + : null + } + + if (summary.thinking) { return null } diff --git a/web/src/lib/sessionLastSeen.test.ts b/web/src/lib/sessionLastSeen.test.ts index c82f94d8ee..0ccfeb57c1 100644 --- a/web/src/lib/sessionLastSeen.test.ts +++ b/web/src/lib/sessionLastSeen.test.ts @@ -1,11 +1,21 @@ +import { createElement } from 'react' +import { act, render } from '@testing-library/react' import { describe, expect, it, beforeEach, vi } from 'vitest' import { getSessionLastSeenAt, getSessionLastSeenSnapshot, + getSessionManualUnreadAt, initializeSessionLastSeen, + markSessionUnread, markSessionSeen, + useSessionLastSeenVersion, } from './sessionLastSeen' +function SessionLastSeenVersionProbe() { + const version = useSessionLastSeenVersion() + return createElement('output', { 'data-testid': 'last-seen-version' }, version) +} + describe('sessionLastSeen', () => { beforeEach(() => { localStorage.clear() @@ -32,6 +42,68 @@ describe('sessionLastSeen', () => { expect(getSessionLastSeenAt('session-a')).toBe(5000) }) + it('moves the watermark behind the current activity when marking unread', () => { + markSessionSeen('session-a', 5000) + + markSessionUnread('session-a', 5000) + + expect(getSessionLastSeenAt('session-a')).toBe(4999) + expect(getSessionManualUnreadAt('session-a')).toBe(5000) + }) + + it('does not change an already-unread watermark when marking unread', () => { + markSessionSeen('session-a', 1000) + + markSessionUnread('session-a', 5000) + + expect(getSessionLastSeenAt('session-a')).toBe(1000) + expect(getSessionManualUnreadAt('session-a')).toBe(5000) + }) + + it('clears the explicit unread marker when the session is seen', () => { + markSessionUnread('session-a', 5000) + expect(getSessionManualUnreadAt('session-a')).toBe(5000) + + markSessionSeen('session-a', 5000) + + expect(getSessionManualUnreadAt('session-a')).toBeNull() + }) + + it('preserves an explicit unread marker when a stale seen timestamp arrives', () => { + markSessionUnread('session-a', 5000) + + markSessionSeen('session-a', 4000) + + expect(getSessionLastSeenAt('session-a')).toBe(4999) + expect(getSessionManualUnreadAt('session-a')).toBe(5000) + }) + + it('notifies same-tab consumers when the watermark changes', () => { + const view = render(createElement(SessionLastSeenVersionProbe)) + const initialVersion = Number(view.getByTestId('last-seen-version').textContent) + + act(() => { + markSessionUnread('session-a', 5000) + }) + + expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 1)) + }) + + it('notifies consumers when either read-state key changes in another tab', () => { + const view = render(createElement(SessionLastSeenVersionProbe)) + const initialVersion = Number(view.getByTestId('last-seen-version').textContent) + + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'hapi.sessionLastSeen.v1' })) + }) + expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 1)) + + act(() => { + window.dispatchEvent(new StorageEvent('storage', { key: 'hapi.sessionManualUnread.v1' })) + }) + expect(view.getByTestId('last-seen-version')).toHaveTextContent(String(initialVersion + 2)) + }) + it('uses the first session list as the unread baseline', () => { initializeSessionLastSeen('hub-a', [ { id: 'session-a', updatedAt: 1000 }, diff --git a/web/src/lib/sessionLastSeen.ts b/web/src/lib/sessionLastSeen.ts index 0364eb6183..a26834f6f2 100644 --- a/web/src/lib/sessionLastSeen.ts +++ b/web/src/lib/sessionLastSeen.ts @@ -1,7 +1,14 @@ +import { useSyncExternalStore } from 'react' + const STORAGE_KEY = 'hapi.sessionLastSeen.v1' +const MANUAL_UNREAD_KEY = 'hapi.sessionManualUnread.v1' const BASELINE_KEY = 'hapi.sessionLastSeenBaseline.v1' +const CHANGE_EVENT = 'hapi.sessionLastSeen.changed' + +let changeVersion = 0 type LastSeenStore = Record +type ManualUnreadStore = Record function getLocalStorage(): Storage | null { if (typeof window === 'undefined') { @@ -35,22 +42,106 @@ function readStore(): LastSeenStore { } } -function writeStore(store: LastSeenStore): void { +function readManualUnreadStore(): ManualUnreadStore { const storage = getLocalStorage() if (!storage) { - return + return {} + } + + try { + const raw = storage.getItem(MANUAL_UNREAD_KEY) + if (!raw) { + return {} + } + const parsed: unknown = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') { + return {} + } + return parsed as ManualUnreadStore + } catch { + return {} + } +} + +function writeStore(store: LastSeenStore): boolean { + const storage = getLocalStorage() + if (!storage) { + return false } try { storage.setItem(STORAGE_KEY, JSON.stringify(store)) + return true } catch { // Ignore storage errors + return false } } +function writeManualUnreadStore(store: ManualUnreadStore): boolean { + const storage = getLocalStorage() + if (!storage) { + return false + } + try { + storage.setItem(MANUAL_UNREAD_KEY, JSON.stringify(store)) + return true + } catch { + // Ignore storage errors + return false + } +} + +function notifyStoreChanged(): void { + changeVersion += 1 + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(CHANGE_EVENT)) + } +} + +function subscribeToStoreChanges(listener: () => void): () => void { + if (typeof window === 'undefined') { + return () => {} + } + + const handleStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY && event.key !== MANUAL_UNREAD_KEY) { + return + } + changeVersion += 1 + listener() + } + + window.addEventListener(CHANGE_EVENT, listener) + window.addEventListener('storage', handleStorage) + return () => { + window.removeEventListener(CHANGE_EVENT, listener) + window.removeEventListener('storage', handleStorage) + } +} + +function getStoreChangeVersion(): number { + return changeVersion +} + +/** Re-render consumers when same-tab read-state changes. */ +export function useSessionLastSeenVersion(): number { + return useSyncExternalStore( + subscribeToStoreChanges, + getStoreChangeVersion, + () => 0 + ) +} + export function getSessionLastSeenAt(sessionId: string): number { return readStore()[sessionId] ?? 0 } +/** Timestamp of the activity the operator explicitly marked unread, if any. */ +export function getSessionManualUnreadAt(sessionId: string): number | null { + const value = readManualUnreadStore()[sessionId] + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + /** One localStorage read/parse for bulk filters (e.g. unread-only lens). */ export function getSessionLastSeenSnapshot(): Readonly> { return readStore() @@ -83,6 +174,57 @@ export function markSessionSeen(sessionId: string, seenAt: number): void { return } const store = readStore() - store[sessionId] = Math.max(store[sessionId] ?? 0, seenAt) - writeStore(store) + const manualUnreadStore = readManualUnreadStore() + const nextSeenAt = Math.max(store[sessionId] ?? 0, seenAt) + const seenChanged = store[sessionId] !== nextSeenAt + const manualUnreadAt = manualUnreadStore[sessionId] + const manualUnreadChanged = typeof manualUnreadAt === 'number' + && Number.isFinite(manualUnreadAt) + && nextSeenAt >= manualUnreadAt + if (!seenChanged && !manualUnreadChanged) { + return + } + + if (seenChanged) { + store[sessionId] = nextSeenAt + } + if (manualUnreadChanged) { + delete manualUnreadStore[sessionId] + } + + const seenWritten = !seenChanged || writeStore(store) + const manualUnreadWritten = !manualUnreadChanged || writeManualUnreadStore(manualUnreadStore) + if (seenWritten || manualUnreadWritten) { + notifyStoreChanged() + } +} + +/** Move the local watermark just behind the current activity and remember the explicit action. */ +export function markSessionUnread(sessionId: string, updatedAt: number): void { + if (!sessionId || !Number.isFinite(updatedAt)) { + return + } + + const store = readStore() + const manualUnreadStore = readManualUnreadStore() + const unreadBefore = updatedAt - 1 + const currentSeenAt = store[sessionId] + const seenChanged = !(typeof currentSeenAt === 'number' && currentSeenAt <= unreadBefore) + const manualUnreadChanged = manualUnreadStore[sessionId] !== updatedAt + if (!seenChanged && !manualUnreadChanged) { + return + } + + if (seenChanged) { + store[sessionId] = unreadBefore + } + if (manualUnreadChanged) { + manualUnreadStore[sessionId] = updatedAt + } + + const seenWritten = !seenChanged || writeStore(store) + const manualUnreadWritten = !manualUnreadChanged || writeManualUnreadStore(manualUnreadStore) + if (seenWritten || manualUnreadWritten) { + notifyStoreChanged() + } } From c9e243a466f1f318ca7fbd55fe89c34614f6e98b Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 20 Aug 2026 19:44:01 +0800 Subject: [PATCH 088/168] docs(macOS): raise launchd runner file limit (#1646) --- docs/guide/deployment.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 3eb470976c..36cfa77c02 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -203,6 +203,11 @@ Create plist files for automatic startup on macOS. HAPI_RUNNER_SUPERVISED 1 + SoftResourceLimits + + NumberOfFiles + 65536 + RunAtLoad KeepAlive From 4c7653f3f6af14700541877a4d9827de14714bac Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:44:02 +0000 Subject: [PATCH 089/168] feat: session-attached long-running jobs (#1404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hub-persisted jobs that outlive the agent process: register/update/clear via REST + `hapi job`, surface primary running progress on the session list even when active=false. Opt-in registration only — not thinking progress. Co-authored-by: Cursor --- cli/src/commands/job.ts | 290 ++++++++++++++++++ cli/src/commands/registry.ts | 4 +- cli/src/modules/sessionJob/sessionJob.ts | 269 ++++++++++++++++ hub/src/store/index.ts | 56 +++- hub/src/store/migration-v23.test.ts | 5 +- hub/src/store/migration-v24.test.ts | 6 +- hub/src/store/migration-v25.test.ts | 78 +++++ hub/src/store/sessionJobs.ts | 267 ++++++++++++++++ hub/src/store/sessionJobsStore.ts | 57 ++++ hub/src/store/types.ts | 15 + hub/src/sync/sessionCache.ts | 33 ++ hub/src/sync/syncEngine.ts | 89 ++++++ hub/src/web/routes/sessions-jobs.test.ts | 129 ++++++++ hub/src/web/routes/sessions.ts | 95 +++++- shared/src/index.ts | 12 + shared/src/schemas.sessionPatch.test.ts | 31 +- shared/src/schemas.ts | 54 +++- shared/src/sessionSummary.test.ts | 19 ++ shared/src/sessionSummary.ts | 13 +- shared/src/types.ts | 4 + .../SessionAttentionIndicator.test.tsx | 1 + .../SessionList.directory-action.test.tsx | 1 + .../SessionList.machine-filter.test.tsx | 1 + web/src/components/SessionList.test.ts | 1 + web/src/components/SessionRowSummary.tsx | 50 ++- web/src/hooks/useSSE.test.ts | 1 + web/src/hooks/useSSE.ts | 14 + web/src/lib/attachedJob.test.ts | 46 +++ web/src/lib/attachedJob.ts | 31 ++ web/src/lib/sessionAttention.test.ts | 1 + web/src/lib/sessionReference.test.ts | 1 + 31 files changed, 1660 insertions(+), 14 deletions(-) create mode 100644 cli/src/commands/job.ts create mode 100644 cli/src/modules/sessionJob/sessionJob.ts create mode 100644 hub/src/store/migration-v25.test.ts create mode 100644 hub/src/store/sessionJobs.ts create mode 100644 hub/src/store/sessionJobsStore.ts create mode 100644 hub/src/web/routes/sessions-jobs.test.ts create mode 100644 web/src/lib/attachedJob.test.ts create mode 100644 web/src/lib/attachedJob.ts diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts new file mode 100644 index 0000000000..cbd2fa55fc --- /dev/null +++ b/cli/src/commands/job.ts @@ -0,0 +1,290 @@ +import chalk from 'chalk' +import { initializeToken } from '@/ui/tokenInit' +import type { AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + clearSessionJob, + exitCodeForSessionJobError, + listSessionJobs, + setSessionJob, + updateSessionJob +} from '@/modules/sessionJob/sessionJob' +import type { CommandDefinition } from './types' + +type ParsedJobArgs = { + help: boolean + action?: 'set' | 'update' | 'clear' | 'list' + sessionIdPrefix?: string + jobKey?: string + label?: string + status?: 'running' | 'completed' | 'failed' + done?: number + total?: number + remaining?: number + unit?: string + detail?: string +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi job')} - Attach long-running work to a HAPI session (tiann/hapi#1404) + +${chalk.bold('Usage:')} + hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] + hapi job clear + hapi job list + +${chalk.bold('Notes:')} + Hub-persisted. Works while the agent is idle/offline — not thinking progress. + Prefer honest remaining/done+total; never invent a fake percent. + Job key: 1-128 chars, alnum / . _ - + +${chalk.bold('Env:')} + HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) +`) +} + +function parseOptionalNumber(flag: string, value: string | undefined): number { + if (value === undefined) { + throw new SessionJobError('bad_args', `${flag} requires a number`) + } + const n = Number(value) + if (!Number.isFinite(n)) { + throw new SessionJobError('bad_args', `${flag} must be a number`) + } + return n +} + +export function parseJobArgs(args: string[]): ParsedJobArgs { + const result: ParsedJobArgs = { help: false } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === '--help' || arg === '-h') { + result.help = true + continue + } + if (arg === '--label') { + result.label = args[++i] + if (!result.label) throw new SessionJobError('bad_args', '--label requires a value') + continue + } + if (arg.startsWith('--label=')) { + result.label = arg.slice('--label='.length) + continue + } + if (arg === '--status') { + const value = args[++i] + if (value !== 'running' && value !== 'completed' && value !== 'failed') { + throw new SessionJobError('bad_args', '--status must be running|completed|failed') + } + result.status = value + continue + } + if (arg.startsWith('--status=')) { + const value = arg.slice('--status='.length) + if (value !== 'running' && value !== 'completed' && value !== 'failed') { + throw new SessionJobError('bad_args', '--status must be running|completed|failed') + } + result.status = value + continue + } + if (arg === '--done') { + result.done = parseOptionalNumber('--done', args[++i]) + continue + } + if (arg.startsWith('--done=')) { + result.done = parseOptionalNumber('--done', arg.slice('--done='.length)) + continue + } + if (arg === '--total') { + result.total = parseOptionalNumber('--total', args[++i]) + continue + } + if (arg.startsWith('--total=')) { + result.total = parseOptionalNumber('--total', arg.slice('--total='.length)) + continue + } + if (arg === '--remaining') { + result.remaining = parseOptionalNumber('--remaining', args[++i]) + continue + } + if (arg.startsWith('--remaining=')) { + result.remaining = parseOptionalNumber('--remaining', arg.slice('--remaining='.length)) + continue + } + if (arg === '--unit') { + result.unit = args[++i] + if (!result.unit) throw new SessionJobError('bad_args', '--unit requires a value') + continue + } + if (arg.startsWith('--unit=')) { + result.unit = arg.slice('--unit='.length) + continue + } + if (arg === '--detail') { + result.detail = args[++i] + if (result.detail === undefined) throw new SessionJobError('bad_args', '--detail requires a value') + continue + } + if (arg.startsWith('--detail=')) { + result.detail = arg.slice('--detail='.length) + continue + } + if (arg.startsWith('-')) { + throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) + } + if (!result.action) { + if (arg !== 'set' && arg !== 'update' && arg !== 'clear' && arg !== 'list') { + throw new SessionJobError('bad_args', `unknown action '${arg}' (set|update|clear|list)`) + } + result.action = arg + continue + } + if (!result.sessionIdPrefix) { + result.sessionIdPrefix = arg + continue + } + if (!result.jobKey && result.action !== 'list') { + result.jobKey = arg + continue + } + throw new SessionJobError('bad_args', `unexpected arg: ${arg}`) + } + + return result +} + +function formatJobLine(job: { + key: string + label: string + status: string + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + heartbeatAt: number +}): string { + const parts = [`${job.key}`, job.label, job.status] + if (job.remaining !== undefined) { + parts.push(`${job.remaining}${job.unit ? ` ${job.unit}` : ''} left`) + } else if (job.done !== undefined && job.total !== undefined) { + parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`) + } + if (job.detail) parts.push(job.detail) + const ageSec = Math.max(0, Math.round((Date.now() - job.heartbeatAt) / 1000)) + parts.push(`heartbeat ${ageSec}s ago`) + return parts.join(' · ') +} + +export async function handleJobCommand(args: string[]): Promise { + const parsed = parseJobArgs(args) + if (parsed.help || !parsed.action) { + showHelp() + if (!parsed.action && !parsed.help) { + throw new SessionJobError('bad_args', 'missing action; usage: hapi job set|update|clear|list ...') + } + return + } + + await initializeToken() + + if (!parsed.sessionIdPrefix) { + showHelp() + throw new SessionJobError('bad_args', 'missing session id') + } + + if (parsed.action === 'list') { + const result = await listSessionJobs({ sessionIdPrefix: parsed.sessionIdPrefix }) + console.log(`session ${result.sessionId}`) + if (result.jobs.length === 0) { + console.log('(no jobs)') + return + } + for (const job of result.jobs) { + const mark = result.primary?.key === job.key ? '*' : ' ' + console.log(`${mark} ${formatJobLine(job)}`) + } + return + } + + if (!parsed.jobKey) { + throw new SessionJobError('bad_args', 'missing job key') + } + + if (parsed.action === 'clear') { + const result = await clearSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey + }) + console.log(`cleared ${parsed.jobKey} on ${result.sessionId}`) + return + } + + if (parsed.action === 'set') { + if (!parsed.label) { + throw new SessionJobError('bad_args', 'set requires --label') + } + const body: AttachedJobUpsert = { + label: parsed.label, + status: parsed.status ?? 'running', + ...(parsed.done !== undefined ? { done: parsed.done } : {}), + ...(parsed.total !== undefined ? { total: parsed.total } : {}), + ...(parsed.remaining !== undefined ? { remaining: parsed.remaining } : {}), + ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), + ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}) + } + const result = await setSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + body + }) + console.log(`set ${formatJobLine(result.job)}`) + return + } + + // update + const body: AttachedJobPatch = { + ...(parsed.label !== undefined ? { label: parsed.label } : {}), + ...(parsed.status !== undefined ? { status: parsed.status } : {}), + ...(parsed.done !== undefined ? { done: parsed.done } : {}), + ...(parsed.total !== undefined ? { total: parsed.total } : {}), + ...(parsed.remaining !== undefined ? { remaining: parsed.remaining } : {}), + ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), + ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}) + } + if (Object.keys(body).length === 0) { + throw new SessionJobError('bad_args', 'update requires at least one field') + } + const result = await updateSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + body + }) + console.log(`updated ${formatJobLine(result.job)}`) +} + +export const jobCommand: CommandDefinition = { + name: 'job', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + try { + await handleJobCommand(commandArgs) + } catch (error) { + if (error instanceof SessionJobError) { + console.error(chalk.red('hapi job:'), error.message) + process.exit(exitCodeForSessionJobError(error)) + } + console.error( + chalk.red('hapi job:'), + error instanceof Error ? error.message : 'Unknown error' + ) + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 8bab771ea9..766b3a558f 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -19,6 +19,7 @@ import { notifyCommand } from './notify' import { hubCommand } from './hub' import { pingPeerCommand } from './pingPeer' import { inspectPeerCommand } from './inspectPeer' +import { jobCommand } from './job' import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on @@ -58,7 +59,8 @@ const COMMANDS: CommandDefinition[] = [ runnerCommand, notifyCommand, pingPeerCommand, - inspectPeerCommand + inspectPeerCommand, + jobCommand ] const commandMap = new Map() diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts new file mode 100644 index 0000000000..554f403243 --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -0,0 +1,269 @@ +/** + * Register / update / clear session-attached jobs (tiann/hapi#1404). + * Same hub JWT flow as ping-peer — works while the agent session is idle. + */ + +import axios, { type AxiosInstance } from 'axios' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { configuration } from '@/configuration' +import { getAuthToken } from '@/api/auth' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' + +export type SessionJobErrorCode = + | 'bad_args' + | 'auth_failed' + | 'not_found' + | 'ambiguous' + | 'request_failed' + +export class SessionJobError extends Error { + readonly code: SessionJobErrorCode + + constructor(code: SessionJobErrorCode, message: string) { + super(message) + this.name = 'SessionJobError' + this.code = code + } +} + +const AUTH_RECOVERY_HINT = + 'On a remote runner, set HAPI_API_URL to the runner hub, and set CLI_API_TOKEN ' + + 'or run `hapi auth login`. Prefer `hapi job` over raw JWT+curl.' + +function resolveApiUrl(apiUrl?: string): string { + const raw = (apiUrl ?? configuration.apiUrl).trim().replace(/\/+$/, '') + if (!raw) { + throw new SessionJobError('bad_args', `HAPI API URL is empty. ${AUTH_RECOVERY_HINT}`) + } + return raw +} + +function resolveAccessToken(accessToken?: string): string { + let token = '' + try { + token = (accessToken ?? getAuthToken()).trim() + } catch { + token = (accessToken ?? '').trim() + } + if (!token) { + throw new SessionJobError( + 'bad_args', + `CLI_API_TOKEN is required (run \`hapi auth login\`). ${AUTH_RECOVERY_HINT}` + ) + } + return token +} + +async function exchangeJwt( + apiUrl: string, + accessToken: string, + http: AxiosInstance +): Promise { + try { + const response = await http.post( + `${apiUrl}/api/auth`, + { accessToken }, + { + headers: buildHubRequestHeaders({ 'Content-Type': 'application/json' }), + timeout: 10_000, + validateStatus: () => true + } + ) + const token = typeof response.data?.token === 'string' ? response.data.token : '' + if (response.status < 200 || response.status >= 300 || !token) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SessionJobError( + 'auth_failed', + `failed to exchange access token for JWT (${detail}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` + ) + } + return token + } catch (error) { + if (error instanceof SessionJobError) throw error + throw new SessionJobError( + 'auth_failed', + `failed to exchange access token for JWT (${error instanceof Error ? error.message : String(error)}). Hub URL: ${apiUrl}. ${AUTH_RECOVERY_HINT}` + ) + } +} + +function authHeaders(jwt: string): Record { + return buildHubRequestHeaders({ + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json' + }) +} + +type SessionListItem = { id: string } + +function resolveSessionByPrefix(sessions: SessionListItem[], prefix: string): SessionListItem { + const trimmed = prefix.trim() + if (!trimmed) { + throw new SessionJobError('bad_args', 'session id prefix is required') + } + const exact = sessions.filter((session) => session.id === trimmed) + if (exact.length === 1) return exact[0]! + const matches = sessions.filter((session) => session.id.startsWith(trimmed)) + if (matches.length === 0) { + throw new SessionJobError('not_found', `no session matching prefix '${trimmed}'`) + } + if (matches.length > 1) { + const sample = matches.slice(0, 5).map((session) => session.id.slice(0, 8)).join(', ') + throw new SessionJobError( + 'ambiguous', + `prefix '${trimmed}' matches ${matches.length} sessions (${sample}${matches.length > 5 ? ', ...' : ''}); use a longer prefix` + ) + } + return matches[0]! +} + +async function resolveSessionId( + apiUrl: string, + jwt: string, + http: AxiosInstance, + sessionIdPrefix: string +): Promise { + const response = await http.get(`${apiUrl}/api/sessions`, { + headers: authHeaders(jwt), + params: { limit: 500, order: 'updatedAt' }, + timeout: 15_000, + validateStatus: () => true + }) + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `list sessions failed: HTTP ${response.status}`) + } + const sessions = Array.isArray(response.data?.sessions) + ? (response.data.sessions as SessionListItem[]) + : [] + return resolveSessionByPrefix(sessions, sessionIdPrefix).id +} + +export type SessionJobClientOptions = { + sessionIdPrefix: string + apiUrl?: string + accessToken?: string + http?: AxiosInstance +} + +async function withClient( + options: SessionJobClientOptions, + fn: (ctx: { apiUrl: string; jwt: string; sessionId: string; http: AxiosInstance }) => Promise +): Promise { + const http = options.http ?? axios + const apiUrl = resolveApiUrl(options.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const jwt = await exchangeJwt(apiUrl, accessToken, http) + const sessionId = await resolveSessionId(apiUrl, jwt, http, options.sessionIdPrefix) + return fn({ apiUrl, jwt, sessionId, http }) +} + +export async function listSessionJobs( + options: SessionJobClientOptions +): Promise<{ sessionId: string; jobs: AttachedJob[]; primary: AttachedJob | null }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.get(`${apiUrl}/api/sessions/${sessionId}/jobs`, { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + }) + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) + } + return { + sessionId, + jobs: Array.isArray(response.data?.jobs) ? response.data.jobs : [], + primary: response.data?.primary ?? null + } + }) +} + +export async function setSessionJob( + options: SessionJobClientOptions & { jobKey: string; body: AttachedJobUpsert } +): Promise<{ sessionId: string; job: AttachedJob }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.put( + `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, + options.body, + { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + } + ) + if (response.status === 404) { + throw new SessionJobError('not_found', 'session or job not found') + } + if (response.status < 200 || response.status >= 300 || !response.data?.job) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SessionJobError('request_failed', `set job failed: ${detail}`) + } + return { sessionId, job: response.data.job as AttachedJob } + }) +} + +export async function updateSessionJob( + options: SessionJobClientOptions & { jobKey: string; body: AttachedJobPatch } +): Promise<{ sessionId: string; job: AttachedJob }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.patch( + `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, + options.body, + { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + } + ) + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + if (response.status < 200 || response.status >= 300 || !response.data?.job) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new SessionJobError('request_failed', `update job failed: ${detail}`) + } + return { sessionId, job: response.data.job as AttachedJob } + }) +} + +export async function clearSessionJob( + options: SessionJobClientOptions & { jobKey: string } +): Promise<{ sessionId: string }> { + return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { + const response = await http.delete( + `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, + { + headers: authHeaders(jwt), + timeout: 15_000, + validateStatus: () => true + } + ) + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + } + return { sessionId } + }) +} + +export function exitCodeForSessionJobError(error: SessionJobError): number { + switch (error.code) { + case 'bad_args': + return 2 + case 'auth_failed': + return 3 + case 'not_found': + return 4 + case 'ambiguous': + return 5 + default: + return 1 + } +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 5c12c5eb23..b50ac52ac9 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -9,6 +9,7 @@ import type { StoredMessage } from './types' import { PushStore } from './pushStore' import { FcmStore } from './fcmStore' import { ScratchlistStore } from './scratchlistStore' +import { SessionJobsStore } from './sessionJobsStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' import { UsageStore } from './usageStore' @@ -22,6 +23,7 @@ export type { StoredPushSubscription, StoredFcmDevice, StoredScratchlistEntry, + StoredSessionJob, StoredSession, StoredUser, VersionedUpdateResult @@ -32,6 +34,7 @@ export { MessageStore } from './messageStore' export { PushStore } from './pushStore' export { FcmStore } from './fcmStore' export { ScratchlistStore } from './scratchlistStore' +export { SessionJobsStore } from './sessionJobsStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' export { UsageStore } from './usageStore' @@ -42,7 +45,7 @@ export { WorkGraphValidationError } from './workGraph' -const SCHEMA_VERSION: number = 25 +const SCHEMA_VERSION: number = 26 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -52,6 +55,7 @@ const REQUIRED_TABLES = [ 'push_subscriptions', 'fcm_devices', 'session_scratchlist', + 'session_jobs', 'usage_events', 'usage_scan_state', 'events', @@ -70,6 +74,7 @@ export class Store { readonly push: PushStore readonly fcm: FcmStore readonly scratchlist: ScratchlistStore + readonly sessionJobs: SessionJobsStore readonly usage: UsageStore readonly workGraph: WorkGraphStore @@ -124,6 +129,7 @@ export class Store { this.push = new PushStore(this.db) this.fcm = new FcmStore(this.db) this.scratchlist = new ScratchlistStore(this.db) + this.sessionJobs = new SessionJobsStore(this.db) this.usage = new UsageStore(this.db) this.workGraph = new WorkGraphStore(this.db) } @@ -343,10 +349,14 @@ export class Store { 18: () => this.migrateFromV18ToV19(), 19: () => this.migrateFromV19ToV20(), 20: () => this.migrateFromV20ToV21(), + // Upstream #1115 dual-pin at v21→v22; #1467 A2A events at v22→v23; + // iOS push_key at v23→v24; steer delivery_state at v24→v25; + // #1404 session_jobs at v25→v26. 21: () => this.migrateFromV21ToV22(), 22: () => this.migrateFromV22ToV23(), 23: () => this.migrateFromV23ToV24(), 24: () => this.migrateFromV24ToV25(), + 25: () => this.migrateFromV25ToV26(), }) if (currentVersion === 0) { @@ -512,6 +522,25 @@ export class Store { CREATE INDEX IF NOT EXISTS idx_session_scratchlist_session_created ON session_scratchlist(session_id, created_at DESC); + CREATE TABLE IF NOT EXISTS session_jobs ( + session_id TEXT NOT NULL, + job_key TEXT NOT NULL, + label TEXT NOT NULL, + status TEXT NOT NULL, + done REAL, + total REAL, + remaining REAL, + unit TEXT, + detail TEXT, + heartbeat_at INTEGER NOT NULL, + started_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, job_key), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status_updated + ON session_jobs(session_id, status, updated_at DESC); + CREATE TABLE IF NOT EXISTS usage_events ( session_id TEXT NOT NULL, source_key TEXT NOT NULL, @@ -951,6 +980,7 @@ export class Store { } private migrateFromV21ToV22(): void { + // Upstream #1115 dual-pin columns. const columns = this.getSessionColumnNames() if (columns.size === 0) return if (!columns.has('pinned')) { @@ -1035,6 +1065,30 @@ export class Store { `) } + private migrateFromV25ToV26(): void { + // tiann/hapi#1404 — session-attached long-running jobs. + this.db.exec(` + CREATE TABLE IF NOT EXISTS session_jobs ( + session_id TEXT NOT NULL, + job_key TEXT NOT NULL, + label TEXT NOT NULL, + status TEXT NOT NULL, + done REAL, + total REAL, + remaining REAL, + unit TEXT, + detail TEXT, + heartbeat_at INTEGER NOT NULL, + started_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (session_id, job_key), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_jobs_session_status_updated + ON session_jobs(session_id, status, updated_at DESC); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index cd2733b379..d9027f4add 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -13,7 +13,7 @@ afterEach(() => { } }) -describe('schema migration v22 to v25', () => { +describe('schema migration v22 to v26', () => { it('adds events and event_links tables to a V22 database', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v23-')) tempDirs.push(dir) @@ -42,7 +42,8 @@ describe('schema migration v22 to v25', () => { expect(links?.name).toBe('event_links') const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> expect(columns.map((column) => column.name)).toContain('delivery_state') - expect(version.user_version).toBe(25) + // Tip after #1404: V23 A2A + V24 push_key + V25 delivery_state + V26 session_jobs. + expect(version.user_version).toBe(26) migrated.close() }) }) diff --git a/hub/src/store/migration-v24.test.ts b/hub/src/store/migration-v24.test.ts index 7dd9517945..4e7278803b 100644 --- a/hub/src/store/migration-v24.test.ts +++ b/hub/src/store/migration-v24.test.ts @@ -13,7 +13,7 @@ afterEach(() => { } }) -describe('schema migration v23 to v25', () => { +describe('schema migration v23 to v26', () => { it('adds fcm_devices.push_key to a V23 database and keeps existing rows', () => { const dir = mkdtempSync(join(tmpdir(), 'hapi-migration-v24-')) tempDirs.push(dir) @@ -37,7 +37,7 @@ describe('schema migration v23 to v25', () => { expect(columns.some((col) => col.name === 'push_key')).toBe(true) const messageColumns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> expect(messageColumns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(25) + expect(version.user_version).toBe(26) // Existing Android rows survive with a NULL push key. const devices = migrated.fcm.getDevicesByNamespace('default') @@ -74,7 +74,7 @@ describe('schema migration v23 to v25', () => { const columns = internalDb.prepare('PRAGMA table_info(messages)').all() as Array<{ name: string }> const version = internalDb.prepare('PRAGMA user_version').get() as { user_version: number } expect(columns.some((col) => col.name === 'delivery_state')).toBe(true) - expect(version.user_version).toBe(25) + expect(version.user_version).toBe(26) migrated.close() }) }) diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts new file mode 100644 index 0000000000..60b3807f11 --- /dev/null +++ b/hub/src/store/migration-v25.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' +import { Store } from './index' + +function getColumns(store: Store, table: string): string[] { + const db: Database = (store as unknown as { db: Database }).db + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + return rows.map((row) => row.name) +} + +describe('Store V25→V26 migration: session_jobs table', () => { + it('fresh DB has session_jobs with expected columns', () => { + const store = new Store(':memory:') + const cols = getColumns(store, 'session_jobs') + expect(cols).toContain('session_id') + expect(cols).toContain('job_key') + expect(cols).toContain('label') + expect(cols).toContain('status') + expect(cols).toContain('done') + expect(cols).toContain('total') + expect(cols).toContain('remaining') + expect(cols).toContain('heartbeat_at') + expect(cols).toContain('started_at') + expect(cols).toContain('updated_at') + store.close() + }) + + it('upserts, patches, deletes a job and surfaces primary running', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + + const created = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 100, + unit: 'tracks' + }) + expect(created.outcome).toBe('upserted') + if (created.outcome !== 'upserted') throw new Error('unreachable') + + const primary = store.sessionJobs.getPrimaryRunning(session.id) + expect(primary?.key).toBe('beets') + expect(primary?.remaining).toBe(100) + + const patched = store.sessionJobs.patch(session.id, 'beets', { remaining: 80 }) + expect(patched?.remaining).toBe(80) + + expect(store.sessionJobs.delete(session.id, 'beets')).toBe(true) + expect(store.sessionJobs.getPrimaryRunning(session.id)).toBeNull() + store.close() + }) + + it('cascade-deletes jobs when session is deleted', async () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + store.sessionJobs.upsert(session.id, 'job', { label: 'x', status: 'running' }) + expect(store.sessionJobs.list(session.id)).toHaveLength(1) + await store.sessions.deleteSession(session.id, 'default') + expect(store.sessionJobs.list(session.id)).toHaveLength(0) + store.close() + }) + + it('transfers jobs on merge without colliding keys', () => { + const store = new Store(':memory:') + const oldSession = store.sessions.getOrCreateSession('old', { path: '/a' }, null, 'default') + const newSession = store.sessions.getOrCreateSession('new', { path: '/b' }, null, 'default') + store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'beets', + status: 'running', + remaining: 5 + }) + const result = store.sessionJobs.transfer(oldSession.id, newSession.id) + expect(result.moved).toBe(1) + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.remaining).toBe(5) + expect(store.sessionJobs.list(oldSession.id)).toHaveLength(0) + store.close() + }) +}) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts new file mode 100644 index 0000000000..c44b5831df --- /dev/null +++ b/hub/src/store/sessionJobs.ts @@ -0,0 +1,267 @@ +import type { Database } from 'bun:sqlite' +import type { AttachedJob, AttachedJobPatch, AttachedJobStatus, AttachedJobUpsert } from '@hapi/protocol' + +import type { StoredSessionJob } from './types' + +/** + * Per-session attached jobs (tiann/hapi#1404). + * + * Registration-first long-running work that outlives the agent process. + * Hub is source of truth; list chrome reads the primary `running` job. + */ + +type DbJobRow = { + session_id: string + job_key: string + label: string + status: string + done: number | null + total: number | null + remaining: number | null + unit: string | null + detail: string | null + heartbeat_at: number + started_at: number + updated_at: number +} + +const JOB_COLUMNS = `session_id, job_key, label, status, done, total, remaining, unit, detail, heartbeat_at, started_at, updated_at` + +function toStored(row: DbJobRow): StoredSessionJob { + return { + sessionId: row.session_id, + key: row.job_key, + label: row.label, + status: row.status as AttachedJobStatus, + done: row.done ?? undefined, + total: row.total ?? undefined, + remaining: row.remaining ?? undefined, + unit: row.unit ?? undefined, + detail: row.detail ?? undefined, + heartbeatAt: row.heartbeat_at, + startedAt: row.started_at, + updatedAt: row.updated_at + } +} + +export function toAttachedJob(job: StoredSessionJob): AttachedJob { + return { + key: job.key, + label: job.label, + status: job.status, + ...(job.done !== undefined ? { done: job.done } : {}), + ...(job.total !== undefined ? { total: job.total } : {}), + ...(job.remaining !== undefined ? { remaining: job.remaining } : {}), + ...(job.unit !== undefined ? { unit: job.unit } : {}), + ...(job.detail !== undefined ? { detail: job.detail } : {}), + heartbeatAt: job.heartbeatAt, + startedAt: job.startedAt, + updatedAt: job.updatedAt + } +} + +export function listSessionJobs(db: Database, sessionId: string): StoredSessionJob[] { + const rows = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE session_id = ? + ORDER BY updated_at DESC, job_key ASC` + ).all(sessionId) as DbJobRow[] + return rows.map(toStored) +} + +export function getSessionJob( + db: Database, + sessionId: string, + jobKey: string +): StoredSessionJob | null { + const row = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE session_id = ? AND job_key = ?` + ).get(sessionId, jobKey) as DbJobRow | undefined + return row ? toStored(row) : null +} + +/** Newest `running` job for a session, or null. */ +export function getPrimaryRunningJob(db: Database, sessionId: string): StoredSessionJob | null { + const row = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE session_id = ? AND status = 'running' + ORDER BY updated_at DESC, job_key ASC + LIMIT 1` + ).get(sessionId) as DbJobRow | undefined + return row ? toStored(row) : null +} + +/** + * Batch primary running jobs for session list enrichment. + * Returns Map sessionId → AttachedJob. + */ +export function getPrimaryRunningJobsBySessionIds( + db: Database, + sessionIds: string[] +): Map { + const result = new Map() + if (sessionIds.length === 0) return result + + const placeholders = sessionIds.map(() => '?').join(', ') + const rows = db.prepare( + `SELECT ${JOB_COLUMNS} + FROM session_jobs + WHERE status = 'running' AND session_id IN (${placeholders}) + ORDER BY updated_at DESC, job_key ASC` + ).all(...sessionIds) as DbJobRow[] + + for (const row of rows) { + if (result.has(row.session_id)) continue + result.set(row.session_id, toAttachedJob(toStored(row))) + } + return result +} + +export type UpsertSessionJobResult = + | { outcome: 'upserted'; job: StoredSessionJob } + | { outcome: 'session-not-found' } + +export function upsertSessionJob( + db: Database, + sessionId: string, + jobKey: string, + body: AttachedJobUpsert, + now: number = Date.now() +): UpsertSessionJobResult { + const existing = getSessionJob(db, sessionId, jobKey) + const heartbeatAt = body.heartbeatAt ?? now + const startedAt = body.startedAt ?? existing?.startedAt ?? now + const status = body.status ?? 'running' + + try { + db.prepare( + `INSERT INTO session_jobs ( + session_id, job_key, label, status, done, total, remaining, unit, detail, + heartbeat_at, started_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, job_key) DO UPDATE SET + label = excluded.label, + status = excluded.status, + done = excluded.done, + total = excluded.total, + remaining = excluded.remaining, + unit = excluded.unit, + detail = excluded.detail, + heartbeat_at = excluded.heartbeat_at, + started_at = session_jobs.started_at, + updated_at = excluded.updated_at` + ).run( + sessionId, + jobKey, + body.label, + status, + body.done ?? null, + body.total ?? null, + body.remaining ?? null, + body.unit ?? null, + body.detail ?? null, + heartbeatAt, + startedAt, + now + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('FOREIGN KEY') || message.includes('foreign key')) { + return { outcome: 'session-not-found' } + } + throw error + } + + const job = getSessionJob(db, sessionId, jobKey) + if (!job) { + return { outcome: 'session-not-found' } + } + return { outcome: 'upserted', job } +} + +export function patchSessionJob( + db: Database, + sessionId: string, + jobKey: string, + patch: AttachedJobPatch, + now: number = Date.now() +): StoredSessionJob | null { + const existing = getSessionJob(db, sessionId, jobKey) + if (!existing) return null + + const next: StoredSessionJob = { + ...existing, + label: patch.label ?? existing.label, + status: patch.status ?? existing.status, + done: patch.done === null ? undefined : (patch.done ?? existing.done), + total: patch.total === null ? undefined : (patch.total ?? existing.total), + remaining: patch.remaining === null ? undefined : (patch.remaining ?? existing.remaining), + unit: patch.unit === null ? undefined : (patch.unit ?? existing.unit), + detail: patch.detail === null ? undefined : (patch.detail ?? existing.detail), + heartbeatAt: patch.heartbeatAt ?? now, + updatedAt: now + } + + db.prepare( + `UPDATE session_jobs SET + label = ?, status = ?, done = ?, total = ?, remaining = ?, unit = ?, detail = ?, + heartbeat_at = ?, updated_at = ? + WHERE session_id = ? AND job_key = ?` + ).run( + next.label, + next.status, + next.done ?? null, + next.total ?? null, + next.remaining ?? null, + next.unit ?? null, + next.detail ?? null, + next.heartbeatAt, + next.updatedAt, + sessionId, + jobKey + ) + + return getSessionJob(db, sessionId, jobKey) +} + +export function deleteSessionJob(db: Database, sessionId: string, jobKey: string): boolean { + const result = db.prepare( + 'DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?' + ).run(sessionId, jobKey) + return result.changes > 0 +} + +/** + * Re-point jobs during session merge (same contract as scratchlist transfer). + * Call BEFORE deleteSession so CASCADE does not race the move. + */ +export function transferSessionJobs( + db: Database, + fromSessionId: string, + toSessionId: string +): { moved: number; collided: number } { + const rows = listSessionJobs(db, fromSessionId) + let moved = 0 + let collided = 0 + + for (const job of rows) { + const existing = getSessionJob(db, toSessionId, job.key) + if (existing) { + db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') + .run(fromSessionId, job.key) + collided += 1 + continue + } + db.prepare( + `UPDATE session_jobs SET session_id = ? + WHERE session_id = ? AND job_key = ?` + ).run(toSessionId, fromSessionId, job.key) + moved += 1 + } + + return { moved, collided } +} diff --git a/hub/src/store/sessionJobsStore.ts b/hub/src/store/sessionJobsStore.ts new file mode 100644 index 0000000000..cc59c5c2f6 --- /dev/null +++ b/hub/src/store/sessionJobsStore.ts @@ -0,0 +1,57 @@ +import type { Database } from 'bun:sqlite' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' + +import type { StoredSessionJob } from './types' +import { + deleteSessionJob, + getPrimaryRunningJob, + getPrimaryRunningJobsBySessionIds, + getSessionJob, + listSessionJobs, + patchSessionJob, + toAttachedJob, + transferSessionJobs, + upsertSessionJob, + type UpsertSessionJobResult +} from './sessionJobs' + +export class SessionJobsStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + list(sessionId: string): StoredSessionJob[] { + return listSessionJobs(this.db, sessionId) + } + + get(sessionId: string, jobKey: string): StoredSessionJob | null { + return getSessionJob(this.db, sessionId, jobKey) + } + + getPrimaryRunning(sessionId: string): AttachedJob | null { + const job = getPrimaryRunningJob(this.db, sessionId) + return job ? toAttachedJob(job) : null + } + + getPrimaryRunningBySessionIds(sessionIds: string[]): Map { + return getPrimaryRunningJobsBySessionIds(this.db, sessionIds) + } + + upsert(sessionId: string, jobKey: string, body: AttachedJobUpsert): UpsertSessionJobResult { + return upsertSessionJob(this.db, sessionId, jobKey, body) + } + + patch(sessionId: string, jobKey: string, patch: AttachedJobPatch): StoredSessionJob | null { + return patchSessionJob(this.db, sessionId, jobKey, patch) + } + + delete(sessionId: string, jobKey: string): boolean { + return deleteSessionJob(this.db, sessionId, jobKey) + } + + transfer(fromSessionId: string, toSessionId: string): { moved: number; collided: number } { + return transferSessionJobs(this.db, fromSessionId, toSessionId) + } +} diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index d22773b846..dd36de4077 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -98,6 +98,21 @@ export type StoredScratchlistEntry = { attachments: import('@hapi/protocol').ScratchlistAttachmentMetadata[] } +export type StoredSessionJob = { + sessionId: string + key: string + label: string + status: import('@hapi/protocol').AttachedJobStatus + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + heartbeatAt: number + startedAt: number + updatedAt: number +} + export type VersionedUpdateResult = | { result: 'success'; version: number; value: T } | { result: 'version-mismatch'; version: number; value: T } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index cbab45975f..33c1bc4c9e 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -599,6 +599,26 @@ export class SessionCache { }) } + /** + * tiann/hapi#1404 — emit primary attached job (or null) so session-list + * caches update inline without a dedicated refetch. + */ + emitAttachedJobChanged( + sessionId: string, + attachedJob: import('@hapi/protocol').AttachedJob | null + ): void { + const cached = this.sessions.get(sessionId) + const namespace = cached?.namespace + ?? this.store.sessions.getSession(sessionId)?.namespace + if (!namespace) return + this.publisher.emit({ + type: 'session-updated', + sessionId, + namespace, + data: { attachedJob } satisfies SessionPatch + }) + } + handleSessionEnd(payload: { sid: string; time: number }): void { const t = clampAliveTime(payload.time) ?? Date.now() @@ -1139,6 +1159,19 @@ export class SessionCache { // the operator's per-session notes, contradicting the v2.0 // promise that scratchlist survives reloads. const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) + const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) + if (movedJobs.moved > 0 || movedJobs.collided > 0) { + this.emitAttachedJobChanged( + newSessionId, + this.store.sessionJobs.getPrimaryRunning(newSessionId) + ) + if (!options.deleteOldSession) { + this.emitAttachedJobChanged( + oldSessionId, + this.store.sessionJobs.getPrimaryRunning(oldSessionId) + ) + } + } if (movedScratchlist.moved > 0) { // Attachment hub paths embed the old session id. Re-key files + // metadata so quota/resolve stay correct on the consolidated id. diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 42067f5a10..bbd8b0c863 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -760,6 +760,95 @@ export class SyncEngine { return removed } + listSessionJobs(sessionId: string) { + return this.store.sessionJobs.list(sessionId).map((job) => ({ + key: job.key, + label: job.label, + status: job.status, + ...(job.done !== undefined ? { done: job.done } : {}), + ...(job.total !== undefined ? { total: job.total } : {}), + ...(job.remaining !== undefined ? { remaining: job.remaining } : {}), + ...(job.unit !== undefined ? { unit: job.unit } : {}), + ...(job.detail !== undefined ? { detail: job.detail } : {}), + heartbeatAt: job.heartbeatAt, + startedAt: job.startedAt, + updatedAt: job.updatedAt + })) + } + + getPrimaryAttachedJob(sessionId: string) { + return this.store.sessionJobs.getPrimaryRunning(sessionId) + } + + getPrimaryAttachedJobsBySessionIds(sessionIds: string[]) { + return this.store.sessionJobs.getPrimaryRunningBySessionIds(sessionIds) + } + + upsertSessionJob( + sessionId: string, + jobKey: string, + body: import('@hapi/protocol').AttachedJobUpsert + ): + | { outcome: 'upserted'; job: import('@hapi/protocol').AttachedJob } + | { outcome: 'session-not-found' } { + const result = this.store.sessionJobs.upsert(sessionId, jobKey, body) + if (result.outcome === 'session-not-found') { + return result + } + const primary = this.store.sessionJobs.getPrimaryRunning(sessionId) + this.sessionCache.emitAttachedJobChanged(sessionId, primary) + const job = result.job + return { + outcome: 'upserted', + job: { + key: job.key, + label: job.label, + status: job.status, + ...(job.done !== undefined ? { done: job.done } : {}), + ...(job.total !== undefined ? { total: job.total } : {}), + ...(job.remaining !== undefined ? { remaining: job.remaining } : {}), + ...(job.unit !== undefined ? { unit: job.unit } : {}), + ...(job.detail !== undefined ? { detail: job.detail } : {}), + heartbeatAt: job.heartbeatAt, + startedAt: job.startedAt, + updatedAt: job.updatedAt + } + } + } + + patchSessionJob( + sessionId: string, + jobKey: string, + patch: import('@hapi/protocol').AttachedJobPatch + ): import('@hapi/protocol').AttachedJob | null { + const updated = this.store.sessionJobs.patch(sessionId, jobKey, patch) + if (!updated) return null + const primary = this.store.sessionJobs.getPrimaryRunning(sessionId) + this.sessionCache.emitAttachedJobChanged(sessionId, primary) + return { + key: updated.key, + label: updated.label, + status: updated.status, + ...(updated.done !== undefined ? { done: updated.done } : {}), + ...(updated.total !== undefined ? { total: updated.total } : {}), + ...(updated.remaining !== undefined ? { remaining: updated.remaining } : {}), + ...(updated.unit !== undefined ? { unit: updated.unit } : {}), + ...(updated.detail !== undefined ? { detail: updated.detail } : {}), + heartbeatAt: updated.heartbeatAt, + startedAt: updated.startedAt, + updatedAt: updated.updatedAt + } + } + + deleteSessionJob(sessionId: string, jobKey: string): boolean { + const removed = this.store.sessionJobs.delete(sessionId, jobKey) + if (removed) { + const primary = this.store.sessionJobs.getPrimaryRunning(sessionId) + this.sessionCache.emitAttachedJobChanged(sessionId, primary) + } + return removed + } + private async withScratchlistUploadLock( namespace: string, sessionId: string, diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts new file mode 100644 index 0000000000..a10c09924c --- /dev/null +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createSessionsRoutes } from './sessions' + +function createSession(overrides?: Partial): Session { + return { + id: '11111111-1111-1111-1111-111111111111', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: false, + activeAt: 1, + metadata: { path: '/music', host: 'local', name: 'Lidarr' }, + metadataVersion: 1, + agentState: null, + agentStateVersion: 0, + thinking: false, + thinkingAt: 0, + model: null, + modelReasoningEffort: null, + effort: null, + serviceTier: null, + ...overrides + } +} + +describe('session-attached jobs routes (tiann/hapi#1404)', () => { + it('lists attachedJob on GET /sessions for inactive session', async () => { + const session = createSession() + const jobs = new Map() + + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + getSessionsByNamespace: () => [session], + getFutureScheduledMessageCounts: () => new Map(), + getNextScheduledAtBySessionIds: () => new Map(), + getPrimaryAttachedJobsBySessionIds: (ids: string[]) => { + const map = new Map() + const primary = [...jobs.values()].find((j) => j.status === 'running') + if (primary) { + for (const id of ids) map.set(id, primary) + } + return map + }, + getPrimaryAttachedJob: () => [...jobs.values()].find((j) => j.status === 'running') ?? null, + listSessionJobs: () => [...jobs.values()], + upsertSessionJob: (_sid: string, key: string, body: AttachedJobUpsert) => { + const now = Date.now() + const job: AttachedJob = { + key, + label: body.label, + status: body.status ?? 'running', + ...(body.done !== undefined ? { done: body.done } : {}), + ...(body.total !== undefined ? { total: body.total } : {}), + ...(body.remaining !== undefined ? { remaining: body.remaining } : {}), + ...(body.unit !== undefined ? { unit: body.unit } : {}), + ...(body.detail !== undefined ? { detail: body.detail } : {}), + heartbeatAt: body.heartbeatAt ?? now, + startedAt: body.startedAt ?? now, + updatedAt: now + } + jobs.set(key, job) + return { outcome: 'upserted' as const, job } + }, + patchSessionJob: (_sid: string, key: string, patch: AttachedJobPatch) => { + const existing = jobs.get(key) + if (!existing) return null + const next: AttachedJob = { + ...existing, + ...(patch.label !== undefined ? { label: patch.label } : {}), + ...(patch.status !== undefined ? { status: patch.status } : {}), + ...(patch.done !== undefined && patch.done !== null ? { done: patch.done } : {}), + ...(patch.remaining !== undefined && patch.remaining !== null + ? { remaining: patch.remaining } + : {}), + heartbeatAt: patch.heartbeatAt ?? Date.now(), + updatedAt: Date.now() + } + jobs.set(key, next) + return next + }, + deleteSessionJob: (_sid: string, key: string) => jobs.delete(key) + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + + const put = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/beets`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: 'beets import', + remaining: 120, + unit: 'tracks' + }) + } + ) + expect(put.status).toBe(200) + + const res = await app.request('http://localhost/api/sessions') + expect(res.status).toBe(200) + const body = await res.json() as { + sessions: Array<{ active: boolean; attachedJob: AttachedJob | null }> + } + expect(body.sessions[0]!.active).toBe(false) + expect(body.sessions[0]!.attachedJob?.key).toBe('beets') + expect(body.sessions[0]!.attachedJob?.remaining).toBe(120) + + const del = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/beets`, + { method: 'DELETE' } + ) + expect(del.status).toBe(200) + const list = await app.request(`http://localhost/api/sessions/${session.id}/jobs`) + const listed = await list.json() as { jobs: AttachedJob[]; primary: AttachedJob | null } + expect(listed.jobs).toEqual([]) + expect(listed.primary).toBeNull() + }) +}) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 44238c9e4f..b7f3da5954 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,4 +1,6 @@ import { + AttachedJobPatchSchema, + AttachedJobUpsertSchema, CursorMigrateToAcpRequestSchema, DeleteUploadRequestSchema, ForkConversationRequestSchema, @@ -116,8 +118,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) + const attachedJobs = engine.getPrimaryAttachedJobsBySessionIds(sessionRecords.map((session) => session.id)) const sessions = sessionRecords.map((session) => { - const summary = toSessionSummary(session) + const summary = toSessionSummary(session, { + attachedJob: attachedJobs.get(session.id) ?? null + }) return { ...summary, futureScheduledMessageCount: scheduledCounts.get(session.id) ?? 0, @@ -1246,6 +1251,94 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ ok: true }) }) + // tiann/hapi#1404 — session-attached long-running jobs (works while agent idle). + const JOB_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ + + app.get('/sessions/:id/jobs', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + return c.json({ + jobs: engine.listSessionJobs(sessionResult.sessionId), + primary: engine.getPrimaryAttachedJob(sessionResult.sessionId) + }) + }) + + app.put('/sessions/:id/jobs/:jobKey', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const jobKey = c.req.param('jobKey') + if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) + } + const body = await c.req.json().catch(() => null) + const parsed = AttachedJobUpsertSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + const result = engine.upsertSessionJob(sessionResult.sessionId, jobKey, parsed.data) + if (result.outcome === 'session-not-found') { + return c.json({ error: 'Session not found' }, 404) + } + return c.json({ job: result.job }) + }) + + app.patch('/sessions/:id/jobs/:jobKey', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const jobKey = c.req.param('jobKey') + if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) + } + const body = await c.req.json().catch(() => null) + const parsed = AttachedJobPatchSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body', issues: parsed.error.issues }, 400) + } + const job = engine.patchSessionJob(sessionResult.sessionId, jobKey, parsed.data) + if (!job) { + return c.json({ error: 'Job not found' }, 404) + } + return c.json({ job }) + }) + + app.delete('/sessions/:id/jobs/:jobKey', (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + const jobKey = c.req.param('jobKey') + if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) + } + const removed = engine.deleteSessionJob(sessionResult.sessionId, jobKey) + if (!removed) { + return c.json({ error: 'Job not found' }, 404) + } + return c.json({ ok: true }) + }) + app.get('/sessions/:id/slash-commands', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/index.ts b/shared/src/index.ts index 4fa14330f9..d00263bba5 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -25,4 +25,16 @@ export * from './slashCommands' export * from './utils' export * from './usage' export * from './version' +export { + AttachedJobSchema, + AttachedJobUpsertSchema, + AttachedJobPatchSchema, + AttachedJobStatusSchema +} from './schemas' +export type { + AttachedJob, + AttachedJobUpsert, + AttachedJobPatch, + AttachedJobStatus +} from './schemas' export type * from './types' diff --git a/shared/src/schemas.sessionPatch.test.ts b/shared/src/schemas.sessionPatch.test.ts index 36afa290aa..6515ea5c66 100644 --- a/shared/src/schemas.sessionPatch.test.ts +++ b/shared/src/schemas.sessionPatch.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { SessionPatchSchema } from './schemas'; +import { AttachedJobSchema, SessionPatchSchema } from './schemas'; // Guard the contract for the second-half-of-#884 fix. The web client routes // `session-updated` events to the structured-patch path only when the event's @@ -101,4 +101,33 @@ describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => }; expect(SessionPatchSchema.safeParse(fullSession).success).toBe(false); }); + + it('accepts attachedJob payload or null (tiann/hapi#1404)', () => { + const job = AttachedJobSchema.parse({ + key: 'beets', + label: 'beets import', + status: 'running', + done: 800, + total: 900, + unit: 'tracks', + heartbeatAt: 2_000, + startedAt: 1_000, + updatedAt: 2_000 + }) + expect(SessionPatchSchema.safeParse({ attachedJob: job }).success).toBe(true) + expect(SessionPatchSchema.safeParse({ attachedJob: null }).success).toBe(true) + }); + + it('rejects fake percent-only attached jobs without counters', () => { + // Progress is explicit counts — no bare percent field on the wire. + expect(AttachedJobSchema.safeParse({ + key: 'x', + label: 'x', + status: 'running', + percent: 91, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + }).success).toBe(false) + }); }); diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 167f95d6f4..8ea6736c9b 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -376,6 +376,53 @@ const VersionedTeamStatePatchSchema = z.object({ value: TeamStateSchema.nullable() }) +/** Opt-in long-running work owned by a session (tiann/hapi#1404). */ +export const AttachedJobStatusSchema = z.enum(['running', 'completed', 'failed']) + +export const AttachedJobSchema = z.object({ + key: z.string().min(1).max(128), + label: z.string().min(1).max(200), + status: AttachedJobStatusSchema, + done: z.number().nonnegative().optional(), + total: z.number().positive().optional(), + remaining: z.number().nonnegative().optional(), + unit: z.string().min(1).max(64).optional(), + detail: z.string().max(500).optional(), + heartbeatAt: z.number(), + startedAt: z.number(), + updatedAt: z.number() +}).strict() + +export type AttachedJob = z.infer +export type AttachedJobStatus = z.infer + +export const AttachedJobUpsertSchema = z.object({ + label: z.string().min(1).max(200), + status: AttachedJobStatusSchema.optional().default('running'), + done: z.number().nonnegative().optional(), + total: z.number().positive().optional(), + remaining: z.number().nonnegative().optional(), + unit: z.string().min(1).max(64).optional(), + detail: z.string().max(500).optional(), + heartbeatAt: z.number().optional(), + startedAt: z.number().optional() +}).strict() + +export type AttachedJobUpsert = z.infer + +export const AttachedJobPatchSchema = z.object({ + label: z.string().min(1).max(200).optional(), + status: AttachedJobStatusSchema.optional(), + done: z.number().nonnegative().nullable().optional(), + total: z.number().positive().nullable().optional(), + remaining: z.number().nonnegative().nullable().optional(), + unit: z.string().min(1).max(64).nullable().optional(), + detail: z.string().max(500).nullable().optional(), + heartbeatAt: z.number().optional() +}).strict() + +export type AttachedJobPatch = z.infer + export const SessionPatchSchema = z.object({ active: z.boolean().optional(), thinking: z.boolean().optional(), @@ -408,7 +455,12 @@ export const SessionPatchSchema = z.object({ // signal, not the payload. Keep this minimal: per the operator's 80/20 // ruling, scratchlist mutations are rare relative to keep-alive // patches, so a fresh event type would be overkill. - scratchlistUpdatedAt: z.number().optional() + scratchlistUpdatedAt: z.number().optional(), + // tiann/hapi#1404 — session-attached long-running jobs. Unlike + // scratchlist (watermark → refetch), the list row needs the progress + // payload inline, so patches carry the primary running job (or null + // when cleared / none remain). + attachedJob: AttachedJobSchema.nullable().optional() }).strict() export type SessionPatch = z.infer diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 379b79ddcb..56ed069932 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -317,6 +317,25 @@ describe('summary derivation helpers', () => { expect(computePendingRequestsCount(undefined)).toBe(0) }) + it('includes attachedJob when provided via extras', () => { + const job = { + key: 'beets', + label: 'beets import', + status: 'running' as const, + remaining: 120, + unit: 'tracks', + heartbeatAt: 9_000, + startedAt: 1_000, + updatedAt: 9_000 + } + const summary = toSessionSummary(makeSession(), { attachedJob: job }) + expect(summary.attachedJob).toEqual(job) + }) + + it('defaults attachedJob to null', () => { + expect(toSessionSummary(makeSession()).attachedJob).toBeNull() + }) + it('toSessionSummaryMetadata returns null for null metadata', () => { expect(toSessionSummaryMetadata(null)).toBeNull() expect(toSessionSummaryMetadata(undefined)).toBeNull() diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index bf8bc48561..232458e455 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,4 @@ -import type { AgentState, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas' +import type { AgentState, AttachedJob, Metadata, Session, TodoItem, WorktreeMetadata } from './schemas' import { isKnownFlavor } from './flavors' import type { AgentFlavor } from './modes' @@ -71,6 +71,11 @@ export type SessionSummary = { futureScheduledMessageCount: number /** Epoch ms of the soonest uninvoked future scheduled message, or null. */ nextScheduledAt: number | null + /** + * Primary running session-attached job (tiann/hapi#1404), or null. + * Independent of agent `active` / thinking — work that outlives the agent. + */ + attachedJob: AttachedJob | null model: string | null modelReasoningEffort?: string | null effort: string | null @@ -201,7 +206,10 @@ export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): } } -export function toSessionSummary(session: Session): SessionSummary { +export function toSessionSummary( + session: Session, + extras?: { attachedJob?: AttachedJob | null } +): SessionSummary { return { id: session.id, active: session.active, @@ -221,6 +229,7 @@ export function toSessionSummary(session: Session): SessionSummary { backgroundTaskCount: session.backgroundTaskCount ?? 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: extras?.attachedJob ?? null, model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort diff --git a/shared/src/types.ts b/shared/src/types.ts index df693464ca..2bd8d96765 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -2,6 +2,10 @@ export type { AgentState, AgentStateCompletedRequest, AgentStateRequest, + AttachedJob, + AttachedJobPatch, + AttachedJobStatus, + AttachedJobUpsert, AttachmentMetadata, DecryptedMessage, Metadata, diff --git a/web/src/components/SessionAttentionIndicator.test.tsx b/web/src/components/SessionAttentionIndicator.test.tsx index d69f293873..bbf540153c 100644 --- a/web/src/components/SessionAttentionIndicator.test.tsx +++ b/web/src/components/SessionAttentionIndicator.test.tsx @@ -29,6 +29,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 7e399cbb15..fb49c666fa 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -33,6 +33,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index f786202fd9..4fc1477d44 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -29,6 +29,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index c516981b04..0b0404d4bf 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -41,6 +41,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index 0602f7ae45..4abc7fa584 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -12,6 +12,11 @@ import { getCodexImportedAt } from '@/lib/codexImportedSessions' import { getSessionTitle } from '@/lib/sessionTitle' import { useTranslation } from '@/lib/use-translation' import { getWorktreeSessionLabel } from '@/lib/sessionWorktreeLabel' +import { + attachedJobFraction, + formatAttachedJobProgress, + isAttachedJobStale +} from '@/lib/attachedJob' function LoaderIcon(props: { className?: string }) { return ( @@ -162,14 +167,18 @@ export function SessionRowSummary(props: { const attentionId = attentionTooltipIdProp ?? ownedIds.attentionId const scheduleId = scheduleTooltipIdProp ?? ownedIds.scheduleId const timeLabel = getSessionTimeLabel(s, t) + const attachedJob = s.attachedJob?.status === 'running' ? s.attachedJob : null + const jobStale = attachedJob ? isAttachedJobStale(attachedJob) : false + const jobFraction = attachedJob ? attachedJobFraction(attachedJob) : null + const jobProgressLabel = attachedJob ? formatAttachedJobProgress(attachedJob) : null return (
-
+
{sessionName} @@ -288,6 +297,43 @@ export function SessionRowSummary(props: { ) : null}
+ {attachedJob && jobProgressLabel ? ( +
+
+ ) : null} {projectLabel || machineLabel ? (
{[projectLabel, machineLabel].filter(Boolean).join(' · ')} diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index c2259ecf2c..7917422acb 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -179,6 +179,7 @@ function makeSummary(overrides: Partial = {}): SessionSummary { backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index d7494fc0ed..96d8964777 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -158,6 +158,16 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu && current.thinking === next.thinking && current.updatedAt === next.updatedAt && current.backgroundTaskCount === next.backgroundTaskCount + && current.attachedJob?.key === next.attachedJob?.key + && current.attachedJob?.label === next.attachedJob?.label + && current.attachedJob?.status === next.attachedJob?.status + && current.attachedJob?.done === next.attachedJob?.done + && current.attachedJob?.total === next.attachedJob?.total + && current.attachedJob?.remaining === next.attachedJob?.remaining + && current.attachedJob?.unit === next.attachedJob?.unit + && current.attachedJob?.detail === next.attachedJob?.detail + && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt + && (current.attachedJob == null) === (next.attachedJob == null) && current.model === next.model && current.modelReasoningEffort === next.modelReasoningEffort && current.effort === next.effort @@ -486,6 +496,7 @@ export function useSSE(options: { const existing = existingIndex >= 0 ? previous.sessions[existingIndex] : undefined const summary = { ...toSessionSummary(session), + attachedJob: existing?.attachedJob ?? null, futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0, nextScheduledAt: existing?.nextScheduledAt ?? null } @@ -531,6 +542,9 @@ export function useSSE(options: { backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount') ? patch.backgroundTaskCount ?? 0 : current.backgroundTaskCount, + attachedJob: Object.prototype.hasOwnProperty.call(patch, 'attachedJob') + ? patch.attachedJob ?? null + : current.attachedJob ?? null, model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model, modelReasoningEffort: Object.prototype.hasOwnProperty.call(patch, 'modelReasoningEffort') ? patch.modelReasoningEffort ?? null diff --git a/web/src/lib/attachedJob.test.ts b/web/src/lib/attachedJob.test.ts new file mode 100644 index 0000000000..fe3946b192 --- /dev/null +++ b/web/src/lib/attachedJob.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { AttachedJob } from '@hapi/protocol' +import { + ATTACHED_JOB_STALE_MS, + attachedJobFraction, + formatAttachedJobProgress, + isAttachedJobStale +} from './attachedJob' + +function job(overrides: Partial = {}): AttachedJob { + return { + key: 'beets', + label: 'beets import', + status: 'running', + heartbeatAt: 1_000, + startedAt: 1_000, + updatedAt: 1_000, + ...overrides + } +} + +describe('attachedJob helpers', () => { + it('formats remaining count without inventing percent', () => { + expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }))).toBe('120 tracks left') + }) + + it('formats done/total with derived percent', () => { + expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }))).toBe( + '89% · 800/900 tracks' + ) + }) + + it('falls back to running when only heartbeat', () => { + expect(formatAttachedJobProgress(job())).toBe('running') + }) + + it('computes fraction from remaining+total', () => { + expect(attachedJobFraction(job({ remaining: 100, total: 1000 }))).toBe(0.9) + }) + + it('marks stale after heartbeat window', () => { + const now = 1_000 + ATTACHED_JOB_STALE_MS + 1 + expect(isAttachedJobStale(job({ heartbeatAt: 1_000 }), now)).toBe(true) + expect(isAttachedJobStale(job({ heartbeatAt: now - 60_000 }), now)).toBe(false) + }) +}) diff --git a/web/src/lib/attachedJob.ts b/web/src/lib/attachedJob.ts new file mode 100644 index 0000000000..58c869c1c9 --- /dev/null +++ b/web/src/lib/attachedJob.ts @@ -0,0 +1,31 @@ +import type { AttachedJob } from '@hapi/protocol' + +/** Stale if no heartbeat for 15 minutes — UI amber, still shows progress. */ +export const ATTACHED_JOB_STALE_MS = 15 * 60 * 1000 + +export function formatAttachedJobProgress(job: AttachedJob): string { + if (job.remaining !== undefined) { + const unit = job.unit ? ` ${job.unit}` : '' + return `${job.remaining}${unit} left` + } + if (job.done !== undefined && job.total !== undefined && job.total > 0) { + const pct = Math.min(100, Math.round((job.done / job.total) * 100)) + return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}` + } + return 'running' +} + +export function attachedJobFraction(job: AttachedJob): number | null { + if (job.done !== undefined && job.total !== undefined && job.total > 0) { + return Math.max(0, Math.min(1, job.done / job.total)) + } + if (job.remaining !== undefined && job.total !== undefined && job.total > 0) { + const done = Math.max(0, job.total - job.remaining) + return Math.max(0, Math.min(1, done / job.total)) + } + return null +} + +export function isAttachedJobStale(job: AttachedJob, now: number = Date.now()): boolean { + return now - job.heartbeatAt > ATTACHED_JOB_STALE_MS +} diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index acf95094b7..a503ac98a9 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -22,6 +22,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index c8660d4c34..d09ec24750 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -27,6 +27,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi backgroundTaskCount: 0, futureScheduledMessageCount: 0, nextScheduledAt: null, + attachedJob: null, model: null, effort: null, ...overrides, From dd5b8bd9896b664cdc7fed13b87d89436029d31f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:53:38 +0000 Subject: [PATCH 090/168] feat(jobs): agent guidance + wall-clock elapsed on list chrome Document the session-job contract for agents and always show startedAt elapsed next to remaining/fraction/running so indeterminate drains still read as wall time without inventing an ETA. Co-authored-by: Cursor --- AGENTS.md | 14 +++ cli/README.md | 1 + cli/src/claude/utils/systemPrompt.ts | 3 +- cli/src/codex/utils/systemPrompt.ts | 3 +- cli/src/commands/job.ts | 36 +++++++- cli/src/grok/utils/systemPrompt.ts | 3 +- .../common/sessionJobInstruction.test.ts | 20 ++++ .../modules/common/sessionJobInstruction.ts | 33 +++++++ cli/src/opencode/utils/systemPrompt.test.ts | 6 +- cli/src/opencode/utils/systemPrompt.ts | 5 +- docs/.vitepress/config.ts | 1 + docs/guide/faq.md | 4 + docs/guide/session-jobs.md | 92 +++++++++++++++++++ web/src/components/SessionRowSummary.tsx | 15 ++- web/src/lib/attachedJob.test.ts | 33 +++++-- web/src/lib/attachedJob.ts | 38 +++++++- 16 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 cli/src/modules/common/sessionJobInstruction.test.ts create mode 100644 cli/src/modules/common/sessionJobInstruction.ts create mode 100644 docs/guide/session-jobs.md diff --git a/AGENTS.md b/AGENTS.md index 5fc80cf39f..e77e982f38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,7 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr | Modify message handling | `hub/src/sync/messageService.ts` | | Add notification type | `hub/src/notifications/` | | Add shared type | `shared/src/types.ts`, `shared/src/schemas.ts` | +| Attach outliving job | `cli/src/commands/job.ts`, `docs/guide/session-jobs.md` | ## Important patterns @@ -171,6 +172,19 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr - **Permission modes**: `default`, `acceptEdits`, `auto`, `bypassPermissions`, `plan` - **Namespaces**: Multi-user isolation via `CLI_API_TOKEN:` suffix +## Session-attached jobs (outliving work) + +When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it with `hapi job` so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. + +Agent contract (HAPI does not write the batch script for you): + +1. `hapi job set "$HAPI_SESSION_ID" --label …` when the process starts +2. `hapi job update` heartbeat at least every ~10 minutes (UI amber after ~15m quiet) +3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown (`running` + indeterminate bar) — never invent a percent +4. `--status completed|failed` or `hapi job clear` when finished + +Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. + ## Adding new web features — consider an FUE When you ship a non-essential feature (the 20% of sessions, not the 80%), consider wrapping its affordance in the generic First-User-Experience primitive so existing users discover it without a giant always-visible UI block. diff --git a/cli/README.md b/cli/README.md index 5cc146be5f..e73f4b2343 100644 --- a/cli/README.md +++ b/cli/README.md @@ -36,6 +36,7 @@ Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`. - `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`. +- `hapi job set|update|clear|list` - Attach long-running outliving work to a session so the list UI shows progress while the agent is idle (`tiann/hapi#1404`). Prefer `"$HAPI_SESSION_ID"`. Heartbeat at least every ~10m; honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). See `docs/guide/session-jobs.md` and `hapi job --help`. ### Resume a remote session locally diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 3174ac6edd..03cfd1c7bc 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -2,6 +2,7 @@ import { trimIdent } from "@/utils/trimIdent"; import { buildSessionCitationSteerInstruction } from "@hapi/protocol/sessionCitation"; import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; import { DISPLAY_IMAGE_PROMPT_CLAUDE, DISPLAY_MEDIA_PROMPT_CLAUDE, DISPLAY_VIDEO_PROMPT_CLAUDE } from "@/modules/common/displayImagePrompt"; +import { withSessionJobInstruction } from "@/modules/common/sessionJobInstruction"; import { withSessionSummaryInstruction } from "@/modules/common/sessionSummaryInstruction"; /** @@ -42,5 +43,5 @@ export function getSystemPrompt(): string { const base = includeCoAuthored ? BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS : BASE_SYSTEM_PROMPT; - return withSessionSummaryInstruction(base); + return withSessionSummaryInstruction(withSessionJobInstruction(base)); } diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index bd85efc36c..7ad815856e 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -8,6 +8,7 @@ import { trimIdent } from '@/utils/trimIdent'; import { buildSessionCitationSteerInstruction } from '@hapi/protocol/sessionCitation'; import { DISPLAY_IMAGE_PROMPT_CODEX, DISPLAY_MEDIA_PROMPT_CODEX, DISPLAY_VIDEO_PROMPT_CODEX } from '@/modules/common/displayImagePrompt'; +import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -36,7 +37,7 @@ export const TITLE_INSTRUCTION = trimIdent(` * Session-summary contract is resolved at call time (hub toggle / env). */ export function getCodexSystemPrompt(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(TITLE_INSTRUCTION), env) } /** Alias kept for existing call sites / tests that expect a string constant name. */ diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index cbd2fa55fc..1579621130 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -29,16 +29,33 @@ function showHelp(): void { console.log(` ${chalk.bold('hapi job')} - Attach long-running work to a HAPI session (tiann/hapi#1404) +${chalk.bold('When to use:')} + Work that outlives the agent (nohup / batch / long scripts / external daemons) + while the session may be idle. Not thinking progress or in-agent background tools. + +${chalk.bold('Agent contract:')} + 1. set before (or as) the process starts + 2. update / heartbeat at least every ~10 minutes while running + 3. prefer honest --remaining or --done/--total; omit counts if unknown + 4. never invent a fake percent + 5. clear or --status completed|failed when finished + ${chalk.bold('Usage:')} hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] hapi job clear hapi job list +${chalk.bold('Progress UI:')} + remaining → "N units left · 2h" + done + total → "P% · done/total · 2h" + label/detail only → "running · 2h" + indeterminate bar + elapsed always from startedAt (wall clock) — never an ETA / time-remaining field + ${chalk.bold('Notes:')} - Hub-persisted. Works while the agent is idle/offline — not thinking progress. - Prefer honest remaining/done+total; never invent a fake percent. + Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat. Job key: 1-128 chars, alnum / . _ - + Docs: docs/guide/session-jobs.md ${chalk.bold('Env:')} HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) @@ -166,6 +183,7 @@ function formatJobLine(job: { unit?: string detail?: string heartbeatAt: number + startedAt: number }): string { const parts = [`${job.key}`, job.label, job.status] if (job.remaining !== undefined) { @@ -173,6 +191,20 @@ function formatJobLine(job: { } else if (job.done !== undefined && job.total !== undefined) { parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`) } + const elapsedSec = Math.max(0, Math.round((Date.now() - job.startedAt) / 1000)) + if (elapsedSec < 60) { + parts.push(`elapsed ${elapsedSec}s`) + } else if (elapsedSec < 3600) { + parts.push(`elapsed ${Math.floor(elapsedSec / 60)}m`) + } else if (elapsedSec < 86400) { + const h = Math.floor(elapsedSec / 3600) + const m = Math.floor((elapsedSec % 3600) / 60) + parts.push(m > 0 ? `elapsed ${h}h ${m}m` : `elapsed ${h}h`) + } else { + const d = Math.floor(elapsedSec / 86400) + const h = Math.floor((elapsedSec % 86400) / 3600) + parts.push(h > 0 ? `elapsed ${d}d ${h}h` : `elapsed ${d}d`) + } if (job.detail) parts.push(job.detail) const ageSec = Math.max(0, Math.round((Date.now() - job.heartbeatAt) / 1000)) parts.push(`heartbeat ${ageSec}s ago`) diff --git a/cli/src/grok/utils/systemPrompt.ts b/cli/src/grok/utils/systemPrompt.ts index 695bc9ea42..07af91a8fd 100644 --- a/cli/src/grok/utils/systemPrompt.ts +++ b/cli/src/grok/utils/systemPrompt.ts @@ -1,9 +1,10 @@ import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction' +import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction' import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction' export const GROK_TITLE_INSTRUCTION = `Use the tool "hapi_change_title" once after the initial request is clear to set a concise session title. Do not rename for routine progress or substeps.\n${SKILL_LOOKUP_INSTRUCTION}` export function getGrokTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(GROK_TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(GROK_TITLE_INSTRUCTION), env) } diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts new file mode 100644 index 0000000000..2cdf00bedb --- /dev/null +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + SESSION_JOB_INSTRUCTION, + withSessionJobInstruction +} from './sessionJobInstruction' + +describe('sessionJobInstruction', () => { + it('mentions set, update, heartbeat, and no fake percent', () => { + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job set') + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job update') + expect(SESSION_JOB_INSTRUCTION).toContain('~10 minutes') + expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') + expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') + }) + + it('appends after an existing prompt block', () => { + expect(withSessionJobInstruction('Base.')).toBe(`Base.\n\n${SESSION_JOB_INSTRUCTION}`) + expect(withSessionJobInstruction('')).toBe(SESSION_JOB_INSTRUCTION) + }) +}) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts new file mode 100644 index 0000000000..c6ba00c3d2 --- /dev/null +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -0,0 +1,33 @@ +/** + * Always-on steer for session-attached long-running jobs (tiann/hapi#1404). + * + * Unlike the session-summary contract (opt-in), this is short and triggers only + * when the agent spawns outliving work — so it rides every supported flavor's + * system / developer instructions by default. + * + * Cursor ACP has no system-prompt seam today; Cursor agents rely on the estate + * skill `hapi-session-jobs` (and `hapi job --help`) instead. + */ + +/** Canonical one-block contract. Keep short — every session's prompt budget. */ +export const SESSION_JOB_INSTRUCTION = [ + 'Session-attached jobs (outliving work):', + 'When you start work that will keep running after this agent goes idle', + '(nohup, batch imports, long scripts, external daemons), attach it to this', + 'HAPI session so the session list can show progress while you are idle.', + 'Use: hapi job set "$HAPI_SESSION_ID" --label ', + '[--remaining N] [--done N --total N] [--unit ] [--detail ].', + 'Heartbeat with hapi job update at least every ~10 minutes (UI goes amber', + 'after ~15m without a heartbeat). Prefer honest remaining or done+total;', + 'omit counts when unknown (UI shows "running" + indeterminate bar).', + 'Never invent a fake percent. On finish: hapi job update … --status', + 'completed|failed, or hapi job clear. Full contract: hapi job --help.' +].join(' ') + +/** Append instruction to an existing prompt block (blank line separator). */ +export function withSessionJobInstruction(base: string): string { + const trimmed = base.trimEnd() + return trimmed.length > 0 + ? `${trimmed}\n\n${SESSION_JOB_INSTRUCTION}` + : SESSION_JOB_INSTRUCTION +} diff --git a/cli/src/opencode/utils/systemPrompt.test.ts b/cli/src/opencode/utils/systemPrompt.test.ts index b22d3f9e2a..483c901075 100644 --- a/cli/src/opencode/utils/systemPrompt.test.ts +++ b/cli/src/opencode/utils/systemPrompt.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { ensureOpencodeConfig } from './opencodeConfig' -import { TITLE_INSTRUCTION } from './systemPrompt' +import { TITLE_INSTRUCTION, getTitleInstruction } from './systemPrompt' describe('OpenCode local HAPI instructions', () => { let configDirectory: string | null = null @@ -20,11 +20,13 @@ describe('OpenCode local HAPI instructions', () => { const { instructionsPath } = ensureOpencodeConfig( configDirectory, { command: 'hapi', args: ['mcp'] }, - TITLE_INSTRUCTION + getTitleInstruction({}) ) const instructions = await readFile(instructionsPath, 'utf8') expect(instructions).toContain('$name') expect(instructions).toContain('skill_lookup') + expect(instructions).toContain('hapi job set') + expect(instructions).toContain(TITLE_INSTRUCTION.trim()) }) }) diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index b1838d33e7..9844625196 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -14,6 +14,7 @@ import { DISPLAY_VIDEO_PROMPT_HAPI_MCP, } from '@/modules/common/displayImagePrompt'; import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstruction'; +import { withSessionJobInstruction } from '@/modules/common/sessionJobInstruction'; import { withSessionSummaryInstruction } from '@/modules/common/sessionSummaryInstruction'; /** @@ -30,7 +31,7 @@ export const TITLE_INSTRUCTION = trimIdent(` `); export function getTitleInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(TITLE_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(TITLE_INSTRUCTION), env) } /** @@ -50,7 +51,7 @@ export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` `); export function getOpencodeNativeToolInstruction(env: NodeJS.ProcessEnv = process.env): string { - return withSessionSummaryInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION, env) + return withSessionSummaryInstruction(withSessionJobInstruction(OPENCODE_NATIVE_TOOL_INSTRUCTION), env) } /** diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c0afdb7f0b..cd63a564b0 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -30,6 +30,7 @@ export default defineConfig({ text: 'Guide', items: [ { text: 'How it Works', link: '/guide/how-it-works' }, + { text: 'Session-attached jobs', link: '/guide/session-jobs' }, { text: 'Voice Assistant', link: '/guide/voice-assistant' }, { text: 'Why HAPI', link: '/guide/why-hapi' }, { text: 'FAQ', link: '/guide/faq' } diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 48356f37e3..6bd478079a 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -97,6 +97,10 @@ Yes. Open any session and use the chat interface to send messages directly to th Some agents (especially Cursor) can resume after idle from harness signals such as background Shell `notify_on_output` or `/loop`, without you sending a new HAPI message. HAPI treats real ACP agent activity (and permission requests) as thinking again so the session list matches the agent - same keepalive path as a normal turn. This is different from session-attached jobs (`hapi job`), which show progress while the agent stays idle on purpose. +### How do I show progress for a long batch that outlives the agent? + +Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md). + ### Can I access a terminal remotely? Yes. Open a session in the web app and tap the Terminal tab for a remote shell. diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md new file mode 100644 index 0000000000..339d9be7d4 --- /dev/null +++ b/docs/guide/session-jobs.md @@ -0,0 +1,92 @@ +# Session-attached jobs + +Hub-persisted progress for work that **outlives the agent** — batch imports, `nohup` scripts, long drains — so the session list still shows something truthful while the chat is idle (`active: false`). + +This is **not** in-agent thinking progress, todos, or `backgroundTaskCount`. Those die when the agent disconnects. Attached jobs live on the hub until you clear them. + +Upstream: [tiann/hapi#1404](https://github.com/tiann/hapi/issues/1404). + +## When to attach + +Attach a job **before** (or immediately when) you start process-shaped work that will keep running after the agent goes idle: + +| Attach | Do not attach | +|--------|----------------| +| `nohup` / `setsid` / systemd oneshot that runs for hours–days | A tool call that finishes in this turn | +| Beets / rclone / compile / migrate / download batches | Normal coding edits and tests | +| External daemon you own for this session's goal | Claude/Codex Ctrl+B-style background tools | + +If the operator would reopen the chat only to ask "how's it doing?", it belongs here. + +## Agent contract (specification) + +HAPI does **not** write your batch scripts. You (the agent) create the process **and** feed the meter. + +1. **Register** with a stable `job-key` (1–128 chars: alnum / `.` `_` `-`). +2. **Heartbeat** at least every ~10 minutes while running (UI amber after ~15 minutes quiet). +3. **Report progress honestly** — see tiers below. Never invent a bare percent. +4. **Finish cleanly** — `--status completed|failed` or `hapi job clear`. + +Session id: prefer `"$HAPI_SESSION_ID"` (exported into every HAPI-wrapped agent). Prefix match also works. + +```bash +hapi job set "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --remaining 150 --done 1637 --total 1787 --unit units \ + --detail 'album: Some Artist - Some Album' + +hapi job update "$HAPI_SESSION_ID" beets --remaining 149 --done 1638 --detail '…' + +hapi job update "$HAPI_SESSION_ID" beets --status completed +# or +hapi job clear "$HAPI_SESSION_ID" beets +``` + +Same auth as `hapi ping-peer` (`HAPI_API_URL` / `CLI_API_TOKEN` or `hapi auth login`). + +## Progress honesty (tiers) + +| What you know | What to send | What the list shows | +|---------------|--------------|---------------------| +| Countable leftover | `--remaining N` (+ optional `--unit`) | `150 units left · 2d 4h` | +| Countable fraction | `--done N --total M` | `91% · 1637/1787 units · 2d 4h` | +| Stage only / unknown size | `--label` + `--detail` + heartbeats | `running · 2d 4h` + indeterminate bar | + +**Elapsed** is always derived from hub `startedAt` (wall clock since register). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate. + +Rules: + +- Prefer **remaining** when the operator cares about "how much left". +- Prefer **done+total** when both ends of a fraction exist (UI may derive %). +- If you only know a stage name, put it in `--detail` and keep heartbeating — do **not** fake `total=100`. +- There is **no** `--percent` flag and **no** ETA / time-remaining field. Inventing either would train agents to lie. + +## Heartbeat recipe + +Wrap the long process so something calls `hapi job update` on a timer (or on each unit completed). Minimum viable indeterminate job: + +```bash +hapi job set "$HAPI_SESSION_ID" rsync-backup --label 'rsync backup' --detail 'phase: copy' +# in a loop / cron / companion script: +hapi job update "$HAPI_SESSION_ID" rsync-backup --detail "phase: copy · $(date -u +%H:%M)Z" +``` + +When the process exits, mark completed/failed or clear. A stuck green/amber chip with a dead PID is worse than no chip. + +## CLI reference + +```bash +hapi job set --label [options] +hapi job update [options] +hapi job clear +hapi job list +hapi job --help +``` + +Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches. + +## Related + +- [Supported Agents](./agents.md) — flavors and resume +- [How it Works](./how-it-works.md) — CLI ↔ hub ↔ web +- CLI: `hapi job --help`, `cli/README.md` diff --git a/web/src/components/SessionRowSummary.tsx b/web/src/components/SessionRowSummary.tsx index 4abc7fa584..625f7836c4 100644 --- a/web/src/components/SessionRowSummary.tsx +++ b/web/src/components/SessionRowSummary.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import type { SessionSummary } from '@/types/api' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { ScheduleIcon } from '@/components/icons' @@ -17,7 +17,6 @@ import { formatAttachedJobProgress, isAttachedJobStale } from '@/lib/attachedJob' - function LoaderIcon(props: { className?: string }) { return ( @@ -168,9 +167,17 @@ export function SessionRowSummary(props: { const scheduleId = scheduleTooltipIdProp ?? ownedIds.scheduleId const timeLabel = getSessionTimeLabel(s, t) const attachedJob = s.attachedJob?.status === 'running' ? s.attachedJob : null - const jobStale = attachedJob ? isAttachedJobStale(attachedJob) : false + // Tick once a minute so elapsed wall-time advances without waiting for a heartbeat SSE. + const [nowMs, setNowMs] = useState(() => Date.now()) + useEffect(() => { + if (!attachedJob) return + setNowMs(Date.now()) + const id = window.setInterval(() => setNowMs(Date.now()), 60_000) + return () => window.clearInterval(id) + }, [attachedJob?.key, attachedJob?.startedAt]) + const jobStale = attachedJob ? isAttachedJobStale(attachedJob, nowMs) : false const jobFraction = attachedJob ? attachedJobFraction(attachedJob) : null - const jobProgressLabel = attachedJob ? formatAttachedJobProgress(attachedJob) : null + const jobProgressLabel = attachedJob ? formatAttachedJobProgress(attachedJob, nowMs) : null return (
diff --git a/web/src/lib/attachedJob.test.ts b/web/src/lib/attachedJob.test.ts index fe3946b192..65dd0682ad 100644 --- a/web/src/lib/attachedJob.test.ts +++ b/web/src/lib/attachedJob.test.ts @@ -3,7 +3,9 @@ import type { AttachedJob } from '@hapi/protocol' import { ATTACHED_JOB_STALE_MS, attachedJobFraction, + formatAttachedJobElapsed, formatAttachedJobProgress, + formatCompactElapsed, isAttachedJobStale } from './attachedJob' @@ -20,18 +22,35 @@ function job(overrides: Partial = {}): AttachedJob { } describe('attachedJob helpers', () => { - it('formats remaining count without inventing percent', () => { - expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }))).toBe('120 tracks left') + it('formats compact elapsed without inventing ETA', () => { + expect(formatCompactElapsed(0)).toBe('0s') + expect(formatCompactElapsed(45_000)).toBe('45s') + expect(formatCompactElapsed(5 * 60_000)).toBe('5m') + expect(formatCompactElapsed(3 * 60 * 60_000 + 12 * 60_000)).toBe('3h 12m') + expect(formatCompactElapsed(3 * 60 * 60_000)).toBe('3h') + expect(formatCompactElapsed(2 * 24 * 60 * 60_000 + 4 * 60 * 60_000)).toBe('2d 4h') + expect(formatCompactElapsed(2 * 24 * 60 * 60_000)).toBe('2d') + expect(formatCompactElapsed(-1)).toBe('0s') }) - it('formats done/total with derived percent', () => { - expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }))).toBe( - '89% · 800/900 tracks' + it('formats remaining count with elapsed', () => { + const now = 1_000 + 2 * 60 * 60_000 + expect(formatAttachedJobProgress(job({ remaining: 120, unit: 'tracks' }), now)).toBe( + '120 tracks left · 2h' ) }) - it('falls back to running when only heartbeat', () => { - expect(formatAttachedJobProgress(job())).toBe('running') + it('formats done/total with derived percent and elapsed', () => { + const now = 1_000 + 45 * 60_000 + expect(formatAttachedJobProgress(job({ done: 800, total: 900, unit: 'tracks' }), now)).toBe( + '89% · 800/900 tracks · 45m' + ) + }) + + it('falls back to running + elapsed when only heartbeat', () => { + const now = 1_000 + 90_000 + expect(formatAttachedJobProgress(job(), now)).toBe('running · 1m') + expect(formatAttachedJobElapsed(job(), now)).toBe('1m') }) it('computes fraction from remaining+total', () => { diff --git a/web/src/lib/attachedJob.ts b/web/src/lib/attachedJob.ts index 58c869c1c9..7fc2172080 100644 --- a/web/src/lib/attachedJob.ts +++ b/web/src/lib/attachedJob.ts @@ -3,16 +3,46 @@ import type { AttachedJob } from '@hapi/protocol' /** Stale if no heartbeat for 15 minutes — UI amber, still shows progress. */ export const ATTACHED_JOB_STALE_MS = 15 * 60 * 1000 -export function formatAttachedJobProgress(job: AttachedJob): string { +/** + * Compact lettered elapsed duration for list chrome. + * Max two units; never an ETA / time-remaining estimate. + */ +export function formatCompactElapsed(elapsedMs: number): string { + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return '0s' + const totalSec = Math.floor(elapsedMs / 1000) + if (totalSec < 60) return `${totalSec}s` + const totalMin = Math.floor(totalSec / 60) + if (totalMin < 60) return `${totalMin}m` + const totalHr = Math.floor(totalMin / 60) + const remMin = totalMin % 60 + if (totalHr < 24) { + return remMin > 0 ? `${totalHr}h ${remMin}m` : `${totalHr}h` + } + const days = Math.floor(totalHr / 24) + const remHr = totalHr % 24 + return remHr > 0 ? `${days}d ${remHr}h` : `${days}d` +} + +/** Elapsed since job.startedAt (hub clock). */ +export function formatAttachedJobElapsed(job: AttachedJob, now: number = Date.now()): string { + return formatCompactElapsed(now - job.startedAt) +} + +/** + * Progress label for the session row. + * Always appends elapsed from startedAt — honest wall time, not an ETA. + */ +export function formatAttachedJobProgress(job: AttachedJob, now: number = Date.now()): string { + const elapsed = formatAttachedJobElapsed(job, now) if (job.remaining !== undefined) { const unit = job.unit ? ` ${job.unit}` : '' - return `${job.remaining}${unit} left` + return `${job.remaining}${unit} left · ${elapsed}` } if (job.done !== undefined && job.total !== undefined && job.total > 0) { const pct = Math.min(100, Math.round((job.done / job.total) * 100)) - return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}` + return `${pct}% · ${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''} · ${elapsed}` } - return 'running' + return `running · ${elapsed}` } export function attachedJobFraction(job: AttachedJob): number | null { From a9d3d6ccbc7c5a48bc60b9d5355006ab8c4f0540 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:26:49 +0000 Subject: [PATCH 091/168] fix(jobs): address Opus cold-review Majors for #1404 Add hapi job run supervisor (auto-heartbeat + exit status), follow post-merge job-owner redirects so $HAPI_SESSION_ID heartbeats keep working, cover the CLI parser/resolve/run paths with tests, and steer agents toward the supervisor instead of an idle-agent heartbeat myth. Co-authored-by: Cursor --- AGENTS.md | 10 +- cli/src/commands/job.test.ts | 84 ++++++++++ cli/src/commands/job.ts | 99 +++++++++--- .../common/sessionJobInstruction.test.ts | 6 +- .../modules/common/sessionJobInstruction.ts | 29 ++-- .../modules/sessionJob/runSessionJob.test.ts | 129 ++++++++++++++++ cli/src/modules/sessionJob/runSessionJob.ts | 144 ++++++++++++++++++ cli/src/modules/sessionJob/sessionJob.ts | 2 +- docs/guide/session-jobs.md | 17 ++- hub/src/sync/sessionCache.ts | 116 ++++++++++++++ hub/src/sync/syncEngine.ts | 5 + hub/src/web/routes/sessions-jobs.test.ts | 1 + hub/src/web/routes/sessions.ts | 38 ++++- 13 files changed, 622 insertions(+), 58 deletions(-) create mode 100644 cli/src/commands/job.test.ts create mode 100644 cli/src/modules/sessionJob/runSessionJob.test.ts create mode 100644 cli/src/modules/sessionJob/runSessionJob.ts diff --git a/AGENTS.md b/AGENTS.md index e77e982f38..e24754226d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,12 +176,12 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it with `hapi job` so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. -Agent contract (HAPI does not write the batch script for you): +Agent contract (prefer the supervisor — idle agents cannot heartbeat): -1. `hapi job set "$HAPI_SESSION_ID" --label …` when the process starts -2. `hapi job update` heartbeat at least every ~10 minutes (UI amber after ~15m quiet) -3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown (`running` + indeterminate bar) — never invent a percent -4. `--status completed|failed` or `hapi job clear` when finished +1. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` (auto-heartbeat + exit status) +2. Manual path only with a self-heartbeating wrapper: `set` / `update` ≥~10m / clear +3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent +4. Elapsed wall clock is always shown from `startedAt` (not an ETA) Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts new file mode 100644 index 0000000000..29d18ea5c2 --- /dev/null +++ b/cli/src/commands/job.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { parseJobArgs } from '@/commands/job' +import { + SessionJobError, + exitCodeForSessionJobError, + resolveSessionByPrefix +} from '@/modules/sessionJob/sessionJob' + +describe('parseJobArgs', () => { + it('parses set with long flags', () => { + const parsed = parseJobArgs([ + 'set', + 'abcd1234', + 'beets', + '--label', + 'beets import', + '--remaining', + '12', + '--unit=tracks' + ]) + expect(parsed.action).toBe('set') + expect(parsed.sessionIdPrefix).toBe('abcd1234') + expect(parsed.jobKey).toBe('beets') + expect(parsed.label).toBe('beets import') + expect(parsed.remaining).toBe(12) + expect(parsed.unit).toBe('tracks') + }) + + it('parses run with command after --', () => { + const parsed = parseJobArgs([ + 'run', + 'sid', + 'drain', + '--label=rsync', + '--heartbeat-sec=60', + '--', + 'bash', + '-c', + 'echo hi' + ]) + expect(parsed.action).toBe('run') + expect(parsed.label).toBe('rsync') + expect(parsed.heartbeatSec).toBe(60) + expect(parsed.command).toEqual(['bash', '-c', 'echo hi']) + }) + + it('rejects bad status', () => { + expect(() => parseJobArgs(['update', 's', 'k', '--status', 'nope'])).toThrow(SessionJobError) + }) +}) + +describe('resolveSessionByPrefix', () => { + const sessions = [ + { id: 'aaaaaaaa-1111-1111-1111-111111111111' }, + { id: 'bbbbbbbb-2222-2222-2222-222222222222' }, + { id: 'bbbbcccc-3333-3333-3333-333333333333' } + ] + + it('matches exact id', () => { + expect(resolveSessionByPrefix(sessions, sessions[0]!.id).id).toBe(sessions[0]!.id) + }) + + it('matches unique prefix', () => { + expect(resolveSessionByPrefix(sessions, 'aaaa').id).toBe(sessions[0]!.id) + }) + + it('errors on ambiguous prefix', () => { + expect(() => resolveSessionByPrefix(sessions, 'bbbb')).toThrow(/matches 2 sessions/) + }) + + it('errors on no match', () => { + expect(() => resolveSessionByPrefix(sessions, 'zzzz')).toThrow(/no session matching/) + }) +}) + +describe('exitCodeForSessionJobError', () => { + it('maps codes', () => { + expect(exitCodeForSessionJobError(new SessionJobError('bad_args', 'x'))).toBe(2) + expect(exitCodeForSessionJobError(new SessionJobError('auth_failed', 'x'))).toBe(3) + expect(exitCodeForSessionJobError(new SessionJobError('not_found', 'x'))).toBe(4) + expect(exitCodeForSessionJobError(new SessionJobError('ambiguous', 'x'))).toBe(5) + expect(exitCodeForSessionJobError(new SessionJobError('request_failed', 'x'))).toBe(1) + }) +}) diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index 1579621130..f9be88ce6b 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -9,11 +9,12 @@ import { setSessionJob, updateSessionJob } from '@/modules/sessionJob/sessionJob' +import { runSessionJob } from '@/modules/sessionJob/runSessionJob' import type { CommandDefinition } from './types' -type ParsedJobArgs = { +export type ParsedJobArgs = { help: boolean - action?: 'set' | 'update' | 'clear' | 'list' + action?: 'set' | 'update' | 'clear' | 'list' | 'run' sessionIdPrefix?: string jobKey?: string label?: string @@ -23,6 +24,8 @@ type ParsedJobArgs = { remaining?: number unit?: string detail?: string + heartbeatSec?: number + command?: string[] } function showHelp(): void { @@ -30,21 +33,21 @@ function showHelp(): void { ${chalk.bold('hapi job')} - Attach long-running work to a HAPI session (tiann/hapi#1404) ${chalk.bold('When to use:')} - Work that outlives the agent (nohup / batch / long scripts / external daemons) + Work that outlives the agent (batch / long scripts / external daemons) while the session may be idle. Not thinking progress or in-agent background tools. ${chalk.bold('Agent contract:')} - 1. set before (or as) the process starts - 2. update / heartbeat at least every ~10 minutes while running - 3. prefer honest --remaining or --done/--total; omit counts if unknown - 4. never invent a fake percent - 5. clear or --status completed|failed when finished + Prefer ${chalk.bold('hapi job run')} — it heartbeats for you and marks completed/failed on exit. + An idle agent cannot heartbeat; set-once jobs go amber after ~15m. + Prefer honest --remaining or --done/--total; omit counts if unknown. + Never invent a fake percent. ${chalk.bold('Usage:')} - hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] - hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] - hapi job clear - hapi job list + hapi job run --label [--heartbeat-sec 300] [progress flags] -- [args...] + hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] + hapi job clear + hapi job list ${chalk.bold('Progress UI:')} remaining → "N units left · 2h" @@ -55,6 +58,7 @@ ${chalk.bold('Progress UI:')} ${chalk.bold('Notes:')} Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat. Job key: 1-128 chars, alnum / . _ - + Session lookup prefers exact id; prefix scan is the 500 most-recently-updated sessions. Docs: docs/guide/session-jobs.md ${chalk.bold('Env:')} @@ -75,15 +79,20 @@ function parseOptionalNumber(flag: string, value: string | undefined): number { export function parseJobArgs(args: string[]): ParsedJobArgs { const result: ParsedJobArgs = { help: false } + const dashDash = args.indexOf('--') + const flagArgs = dashDash >= 0 ? args.slice(0, dashDash) : args + if (dashDash >= 0) { + result.command = args.slice(dashDash + 1) + } - for (let i = 0; i < args.length; i++) { - const arg = args[i]! + for (let i = 0; i < flagArgs.length; i++) { + const arg = flagArgs[i]! if (arg === '--help' || arg === '-h') { result.help = true continue } if (arg === '--label') { - result.label = args[++i] + result.label = flagArgs[++i] if (!result.label) throw new SessionJobError('bad_args', '--label requires a value') continue } @@ -92,7 +101,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--status') { - const value = args[++i] + const value = flagArgs[++i] if (value !== 'running' && value !== 'completed' && value !== 'failed') { throw new SessionJobError('bad_args', '--status must be running|completed|failed') } @@ -108,7 +117,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--done') { - result.done = parseOptionalNumber('--done', args[++i]) + result.done = parseOptionalNumber('--done', flagArgs[++i]) continue } if (arg.startsWith('--done=')) { @@ -116,7 +125,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--total') { - result.total = parseOptionalNumber('--total', args[++i]) + result.total = parseOptionalNumber('--total', flagArgs[++i]) continue } if (arg.startsWith('--total=')) { @@ -124,7 +133,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--remaining') { - result.remaining = parseOptionalNumber('--remaining', args[++i]) + result.remaining = parseOptionalNumber('--remaining', flagArgs[++i]) continue } if (arg.startsWith('--remaining=')) { @@ -132,7 +141,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--unit') { - result.unit = args[++i] + result.unit = flagArgs[++i] if (!result.unit) throw new SessionJobError('bad_args', '--unit requires a value') continue } @@ -141,7 +150,7 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { continue } if (arg === '--detail') { - result.detail = args[++i] + result.detail = flagArgs[++i] if (result.detail === undefined) throw new SessionJobError('bad_args', '--detail requires a value') continue } @@ -149,12 +158,26 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { result.detail = arg.slice('--detail='.length) continue } + if (arg === '--heartbeat-sec') { + result.heartbeatSec = parseOptionalNumber('--heartbeat-sec', flagArgs[++i]) + continue + } + if (arg.startsWith('--heartbeat-sec=')) { + result.heartbeatSec = parseOptionalNumber('--heartbeat-sec', arg.slice('--heartbeat-sec='.length)) + continue + } if (arg.startsWith('-')) { throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) } if (!result.action) { - if (arg !== 'set' && arg !== 'update' && arg !== 'clear' && arg !== 'list') { - throw new SessionJobError('bad_args', `unknown action '${arg}' (set|update|clear|list)`) + if ( + arg !== 'set' + && arg !== 'update' + && arg !== 'clear' + && arg !== 'list' + && arg !== 'run' + ) { + throw new SessionJobError('bad_args', `unknown action '${arg}' (set|update|clear|list|run)`) } result.action = arg continue @@ -216,7 +239,7 @@ export async function handleJobCommand(args: string[]): Promise { if (parsed.help || !parsed.action) { showHelp() if (!parsed.action && !parsed.help) { - throw new SessionJobError('bad_args', 'missing action; usage: hapi job set|update|clear|list ...') + throw new SessionJobError('bad_args', 'missing action; usage: hapi job set|update|clear|list|run ...') } return } @@ -277,6 +300,34 @@ export async function handleJobCommand(args: string[]): Promise { return } + if (parsed.action === 'run') { + if (!parsed.label) { + throw new SessionJobError('bad_args', 'run requires --label') + } + if (!parsed.command || parsed.command.length === 0) { + throw new SessionJobError('bad_args', 'run requires a command after --') + } + const exitCode = await runSessionJob({ + sessionIdPrefix: parsed.sessionIdPrefix, + jobKey: parsed.jobKey, + label: parsed.label, + command: parsed.command, + ...(parsed.heartbeatSec !== undefined + ? { heartbeatMs: Math.max(5, parsed.heartbeatSec) * 1000 } + : {}), + ...(parsed.done !== undefined ? { done: parsed.done } : {}), + ...(parsed.total !== undefined ? { total: parsed.total } : {}), + ...(parsed.remaining !== undefined ? { remaining: parsed.remaining } : {}), + ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), + ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}) + }) + if (exitCode !== 0) { + process.exitCode = exitCode + } + console.log(`run finished exit=${exitCode} job=${parsed.jobKey}`) + return + } + // update const body: AttachedJobPatch = { ...(parsed.label !== undefined ? { label: parsed.label } : {}), diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts index 2cdf00bedb..c6d919a329 100644 --- a/cli/src/modules/common/sessionJobInstruction.test.ts +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -5,10 +5,10 @@ import { } from './sessionJobInstruction' describe('sessionJobInstruction', () => { - it('mentions set, update, heartbeat, and no fake percent', () => { - expect(SESSION_JOB_INSTRUCTION).toContain('hapi job set') + it('prefers job run supervisor and forbids fake percent', () => { + expect(SESSION_JOB_INSTRUCTION).toContain('hapi job run') expect(SESSION_JOB_INSTRUCTION).toContain('hapi job update') - expect(SESSION_JOB_INSTRUCTION).toContain('~10 minutes') + expect(SESSION_JOB_INSTRUCTION).toContain('idle agent cannot') expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') }) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index c6ba00c3d2..ee3043bf76 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -1,27 +1,26 @@ /** * Always-on steer for session-attached long-running jobs (tiann/hapi#1404). * - * Unlike the session-summary contract (opt-in), this is short and triggers only - * when the agent spawns outliving work — so it rides every supported flavor's - * system / developer instructions by default. - * - * Cursor ACP has no system-prompt seam today; Cursor agents rely on the estate - * skill `hapi-session-jobs` (and `hapi job --help`) instead. + * Injected into flavors that have a HAPI system / developer-instructions seam + * today: Claude, Codex, OpenCode, Grok. Cursor ACP has no such seam (estate + * skill `hapi-session-jobs` + `hapi job --help` instead). Other ACP flavors + * (Kimi, Copilot, Pi, …) do not receive this block until an MCP job tool or + * per-flavor seam lands — do not claim "every flavor." */ /** Canonical one-block contract. Keep short — every session's prompt budget. */ export const SESSION_JOB_INSTRUCTION = [ 'Session-attached jobs (outliving work):', 'When you start work that will keep running after this agent goes idle', - '(nohup, batch imports, long scripts, external daemons), attach it to this', - 'HAPI session so the session list can show progress while you are idle.', - 'Use: hapi job set "$HAPI_SESSION_ID" --label ', - '[--remaining N] [--done N --total N] [--unit ] [--detail ].', - 'Heartbeat with hapi job update at least every ~10 minutes (UI goes amber', - 'after ~15m without a heartbeat). Prefer honest remaining or done+total;', - 'omit counts when unknown (UI shows "running" + indeterminate bar).', - 'Never invent a fake percent. On finish: hapi job update … --status', - 'completed|failed, or hapi job clear. Full contract: hapi job --help.' + '(batch imports, long scripts, external daemons), attach it so the session', + 'list can show progress while you are idle.', + 'Prefer: hapi job run "$HAPI_SESSION_ID" --label -- …', + '(auto-heartbeats + marks completed/failed on exit).', + 'Manual path: hapi job set … then a wrapper must heartbeat via', + 'hapi job update at least every ~10 minutes — an idle agent cannot.', + 'Prefer honest remaining or done+total; omit counts when unknown', + '(UI shows "running" + elapsed). Never invent a fake percent.', + 'Full contract: hapi job --help.' ].join(' ') /** Append instruction to an existing prompt block (blank line separator). */ diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts new file mode 100644 index 0000000000..66e444a09d --- /dev/null +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -0,0 +1,129 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { runSessionJob } from './runSessionJob' + +function fakeChild(exitCode: number) { + const child = new EventEmitter() as EventEmitter & { + pid: number + killed: boolean + } + child.pid = 4242 + child.killed = false + queueMicrotask(() => child.emit('exit', exitCode, null)) + return child +} + +describe('runSessionJob', () => { + it('sets running, heartbeats, then marks completed on exit 0', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + })) + } + + const timers: Array<() => void> = [] + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + heartbeatMs: 10, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(0)) as never, + setIntervalImpl: ((fn: () => void) => { + timers.push(fn) + return 1 as unknown as NodeJS.Timeout + }) as never, + clearIntervalImpl: (() => undefined) as never + }) + + expect(exitCode).toBe(0) + expect(http.put).toHaveBeenCalled() + expect(http.patch).toHaveBeenCalled() + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('completed') + }) + + it('marks failed on non-zero exit', async () => { + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + })) + } + + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['false'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(7)) as never, + setIntervalImpl: ((() => 1) as never), + clearIntervalImpl: (() => undefined) as never + }) + + expect(exitCode).toBe(7) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('failed') + }) +}) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts new file mode 100644 index 0000000000..f654b1fcd8 --- /dev/null +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -0,0 +1,144 @@ +/** + * Supervise a child command while heartbeating a session-attached job. + * Fixes the idle-agent heartbeat gap (cold review #1404). + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import type { AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + setSessionJob, + updateSessionJob, + type SessionJobClientOptions +} from './sessionJob' + +export type RunSessionJobOptions = SessionJobClientOptions & { + jobKey: string + label: string + command: string[] + heartbeatMs?: number + remaining?: number + done?: number + total?: number + unit?: string + detail?: string + /** Injected for tests. */ + spawnImpl?: typeof spawn + setIntervalImpl?: typeof setInterval + clearIntervalImpl?: typeof clearInterval +} + +const DEFAULT_HEARTBEAT_MS = 5 * 60 * 1000 + +export async function runSessionJob(options: RunSessionJobOptions): Promise { + if (options.command.length === 0) { + throw new SessionJobError('bad_args', 'run requires a command after --') + } + + const body: AttachedJobUpsert = { + label: options.label, + status: 'running', + ...(options.done !== undefined ? { done: options.done } : {}), + ...(options.total !== undefined ? { total: options.total } : {}), + ...(options.remaining !== undefined ? { remaining: options.remaining } : {}), + ...(options.unit !== undefined ? { unit: options.unit } : {}), + ...(options.detail !== undefined ? { detail: options.detail } : {}) + } + + await setSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }) + + const spawnFn = options.spawnImpl ?? spawn + const setIntervalFn = options.setIntervalImpl ?? setInterval + const clearIntervalFn = options.clearIntervalImpl ?? clearInterval + const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS + + const child: ChildProcess = spawnFn(options.command[0]!, options.command.slice(1), { + stdio: 'inherit', + env: process.env + }) + + const heartbeat = setIntervalFn(() => { + void updateSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body: { + detail: options.detail, + status: 'running' + }, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }).catch(() => { + // Best-effort — exit path still marks terminal status. + }) + }, heartbeatMs) + // Don't keep the event loop alive solely for heartbeats if child already exited. + heartbeat.unref?.() + + const forward = (signal: NodeJS.Signals) => { + if (child.pid && !child.killed) { + try { + process.kill(child.pid, signal) + } catch { + // Child may have already exited. + } + } + } + const onSigInt = () => forward('SIGINT') + const onSigTerm = () => forward('SIGTERM') + process.on('SIGINT', onSigInt) + process.on('SIGTERM', onSigTerm) + + const exitCode = await new Promise((resolve) => { + child.on('error', async (error) => { + clearIntervalFn(heartbeat) + try { + await updateSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body: { status: 'failed', detail: error.message }, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }) + } catch { + // ignore + } + resolve(127) + }) + child.on('exit', (code, signal) => { + clearIntervalFn(heartbeat) + if (signal) { + resolve(128 + (signal === 'SIGINT' ? 2 : signal === 'SIGTERM' ? 15 : 1)) + return + } + resolve(code ?? 1) + }) + }) + + process.off('SIGINT', onSigInt) + process.off('SIGTERM', onSigTerm) + + const terminalStatus = exitCode === 0 ? 'completed' : 'failed' + try { + await updateSessionJob({ + sessionIdPrefix: options.sessionIdPrefix, + jobKey: options.jobKey, + body: { status: terminalStatus }, + apiUrl: options.apiUrl, + accessToken: options.accessToken, + http: options.http + }) + } catch { + // Job may already be cleared; still return child exit code. + } + + return exitCode +} diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts index 554f403243..2f404fce9f 100644 --- a/cli/src/modules/sessionJob/sessionJob.ts +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -98,7 +98,7 @@ function authHeaders(jwt: string): Record { type SessionListItem = { id: string } -function resolveSessionByPrefix(sessions: SessionListItem[], prefix: string): SessionListItem { +export function resolveSessionByPrefix(sessions: SessionListItem[], prefix: string): SessionListItem { const trimmed = prefix.trim() if (!trimmed) { throw new SessionJobError('bad_args', 'session id prefix is required') diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 339d9be7d4..18b78e23c2 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -20,14 +20,19 @@ If the operator would reopen the chat only to ask "how's it doing?", it belongs ## Agent contract (specification) -HAPI does **not** write your batch scripts. You (the agent) create the process **and** feed the meter. +HAPI does **not** write your batch scripts for you - but prefer the supervisor so heartbeats are not your problem: -1. **Register** with a stable `job-key` (1–128 chars: alnum / `.` `_` `-`). -2. **Heartbeat** at least every ~10 minutes while running (UI amber after ~15 minutes quiet). -3. **Report progress honestly** — see tiers below. Never invent a bare percent. -4. **Finish cleanly** — `--status completed|failed` or `hapi job clear`. +```bash +hapi job run "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --remaining 150 --done 1637 --total 1787 --unit units \ + --detail 'album: …' \ + -- ./beets-import.sh +``` + +`hapi job run` registers the job, heartbeats on a timer while the child runs, then marks `completed`/`failed` from the exit code. An idle agent **cannot** heartbeat - set-once + manual update decays to amber. -Session id: prefer `"$HAPI_SESSION_ID"` (exported into every HAPI-wrapped agent). Prefix match also works. +Manual path (only if you already have a self-heartbeating wrapper): ```bash hapi job set "$HAPI_SESSION_ID" beets \ diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 33c1bc4c9e..82bfb0199e 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1161,6 +1161,12 @@ export class SessionCache { const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) if (movedJobs.moved > 0 || movedJobs.collided > 0) { + // Agents keep addressing $HAPI_SESSION_ID from the pre-merge row. + // Record redirects so job REST routes can follow the live job owner. + this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) + if (!options.deleteOldSession) { + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) + } this.emitAttachedJobChanged( newSessionId, this.store.sessionJobs.getPrimaryRunning(newSessionId) @@ -1363,6 +1369,116 @@ export class SessionCache { } } + /** + * Target session remembers it absorbed jobs from `fromSessionId` so job + * REST routes can follow `$HAPI_SESSION_ID` after the source row is deleted. + */ + private recordJobsAcceptedFromSession( + toSessionId: string, + fromSessionId: string, + namespace: string + ): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(toSessionId, namespace) + if (!latest) return + const meta = (latest.metadata && typeof latest.metadata === 'object' + ? { ...(latest.metadata as Record) } + : {}) as Record + const prev = Array.isArray(meta.jobsAcceptedFromSessionIds) + ? meta.jobsAcceptedFromSessionIds.filter((id): id is string => typeof id === 'string') + : [] + if (prev.includes(fromSessionId)) return + meta.jobsAcceptedFromSessionIds = [...prev, fromSessionId] + const result = this.store.sessions.updateSessionMetadata( + toSessionId, + meta, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.refreshSession(toSessionId) + return + } + if (result.result !== 'version-mismatch') return + } + } + + /** Source session (kept alive) points job APIs at the post-merge owner. */ + private recordJobsTransferredToSession( + fromSessionId: string, + toSessionId: string, + namespace: string + ): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(fromSessionId, namespace) + if (!latest) return + const meta = (latest.metadata && typeof latest.metadata === 'object' + ? { ...(latest.metadata as Record) } + : {}) as Record + if (meta.jobsTransferredToSessionId === toSessionId) return + meta.jobsTransferredToSessionId = toSessionId + const result = this.store.sessions.updateSessionMetadata( + fromSessionId, + meta, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.refreshSession(fromSessionId) + return + } + if (result.result !== 'version-mismatch') return + } + } + + /** + * Follow job-owner redirects after session merge/dedup so agents that still + * hold the pre-merge `$HAPI_SESSION_ID` can heartbeat. + */ + resolveAttachedJobSessionId(sessionId: string, namespace: string): string { + let current = sessionId + for (let hop = 0; hop < 5; hop += 1) { + const access = this.resolveSessionAccess(current, namespace) + if (access.ok) { + const meta = access.session.metadata as Record | null | undefined + const next = + (typeof meta?.jobsTransferredToSessionId === 'string' + && meta.jobsTransferredToSessionId.trim()) + || (typeof meta?.supersededBySessionId === 'string' + && meta.supersededBySessionId.trim()) + || '' + if (next && next !== current) { + current = next + continue + } + return current + } + // Source row may already be deleted — find who accepted its jobs. + const acceptor = this.findSessionThatAcceptedJobsFrom(current, namespace) + if (acceptor && acceptor !== current) { + current = acceptor + continue + } + return current + } + return current + } + + private findSessionThatAcceptedJobsFrom(fromSessionId: string, namespace: string): string | null { + for (const session of this.getSessions()) { + if (session.namespace !== namespace) continue + const meta = session.metadata as Record | null | undefined + const accepted = meta?.jobsAcceptedFromSessionIds + if (!Array.isArray(accepted)) continue + if (accepted.some((id) => id === fromSessionId)) { + return session.id + } + } + return null + } + private mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { if (!oldMetadata || typeof oldMetadata !== 'object') { return newMetadata diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index bbd8b0c863..c1755e04f2 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -358,6 +358,11 @@ export class SyncEngine { return this.sessionCache.resolveSessionAccess(sessionId, namespace) } + /** Follow job-owner redirects after merge/dedup (tiann/hapi#1404 cold review). */ + resolveAttachedJobSessionId(sessionId: string, namespace: string): string { + return this.sessionCache.resolveAttachedJobSessionId(sessionId, namespace) + } + getActiveSessions(): Session[] { return this.sessionCache.getActiveSessions() } diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index a10c09924c..87a0161b5d 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -35,6 +35,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { const engine = { resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + resolveAttachedJobSessionId: (id: string) => id, getSessionsByNamespace: () => [session], getFutureScheduledMessageCounts: () => new Map(), getNextScheduledAtBySessionIds: () => new Map(), diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index b7f3da5954..12b4a4beb1 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1254,12 +1254,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho // tiann/hapi#1404 — session-attached long-running jobs (works while agent idle). const JOB_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ + function resolveJobOwnerSession( + c: Context, + engine: SyncEngine + ): { sessionId: string; session: Session } | Response { + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + // Session may already be deleted after merge — still try acceptor redirect. + const rawId = c.req.param('id') ?? '' + const namespace = c.get('namespace') + const redirected = engine.resolveAttachedJobSessionId(rawId, namespace) + if (redirected !== rawId) { + const access = engine.resolveSessionAccess(redirected, namespace) + if (access.ok) { + return { sessionId: access.sessionId, session: access.session } + } + } + return sessionResult + } + const namespace = c.get('namespace') + const ownerId = engine.resolveAttachedJobSessionId(sessionResult.sessionId, namespace) + if (ownerId === sessionResult.sessionId) { + return sessionResult + } + const access = engine.resolveSessionAccess(ownerId, namespace) + if (!access.ok) { + return sessionResult + } + return { sessionId: access.sessionId, session: access.session } + } + app.get('/sessions/:id/jobs', (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } @@ -1274,7 +1304,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } @@ -1299,7 +1329,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } @@ -1324,7 +1354,7 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (engine instanceof Response) { return engine } - const sessionResult = requireSessionFromParam(c, engine) + const sessionResult = resolveJobOwnerSession(c, engine) if (sessionResult instanceof Response) { return sessionResult } From bdaf1659566d77a19aa7ecaa85b8de5a8213dc14 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:33:13 +0000 Subject: [PATCH 092/168] fix(jobs): honor explicit startedAt on PUT; document late-attach clock Beets dogfood: PATCH rejects startedAt; sticky PUT clock made elapsed lie after late attach. Explicit upsert startedAt now corrects; CLI --started-at; clear+set recipe kept for older hubs. Co-authored-by: Cursor --- AGENTS.md | 4 +- cli/README.md | 2 +- cli/src/commands/job.test.ts | 11 +++++ cli/src/commands/job.ts | 24 ++++++++++- docs/guide/faq.md | 2 +- docs/guide/session-jobs.md | 67 +++++++++++++++++++++++++++-- hub/src/store/migration-v25.test.ts | 40 +++++++++++++++++ hub/src/store/sessionJobs.ts | 8 +++- hub/src/store/sessionJobsStore.ts | 18 ++++++-- shared/src/schemas.ts | 1 + 10 files changed, 161 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e24754226d..1ff7ca78c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,14 +174,14 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr ## Session-attached jobs (outliving work) -When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it with `hapi job` so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. +When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it with `hapi job` so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. It is also **not** an A2A Layer 1 `work_ad` ([#1332](https://github.com/tiann/hapi/discussions/1332)) — jobs enrich Layer 0 `SessionSummary`; leave collaboration claims / handoffs to the work-graph ledger. Agent contract (prefer the supervisor — idle agents cannot heartbeat): 1. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` (auto-heartbeat + exit status) 2. Manual path only with a self-heartbeating wrapper: `set` / `update` ≥~10m / clear 3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent -4. Elapsed wall clock is always shown from `startedAt` (not an ETA) +4. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with `set --started-at` (or clear+set) Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. diff --git a/cli/README.md b/cli/README.md index e73f4b2343..0bd2143f43 100644 --- a/cli/README.md +++ b/cli/README.md @@ -36,7 +36,7 @@ Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. - `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` / `list_peers` over reinventing JWT+curl. Also `--message-file` / `--list`. - `hapi inspect-peer ` - Read-only peer metadata + recent message text (no resume). Prefer this or MCP `inspect_peer` when a user cites `[title](/sessions/)` or Copy-reference `See session "…" (/sessions/) for context`. `/sessions/` is a hub path, not a local file. Optional `--limit`. -- `hapi job set|update|clear|list` - Attach long-running outliving work to a session so the list UI shows progress while the agent is idle (`tiann/hapi#1404`). Prefer `"$HAPI_SESSION_ID"`. Heartbeat at least every ~10m; honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). See `docs/guide/session-jobs.md` and `hapi job --help`. +- `hapi job set|update|clear|list|run` - Attach long-running outliving work to a session so the list UI shows progress while the agent is idle (`tiann/hapi#1404`). Prefer `"$HAPI_SESSION_ID"`. Heartbeat via `update` (or `run`); honest `--remaining` or `--done`/`--total` (omit counts if unknown — never invent a percent). Late-attach clock fix: `set --started-at ` or clear+set. See `docs/guide/session-jobs.md` and `hapi job --help`. ### Resume a remote session locally diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts index 29d18ea5c2..248ad3b8d9 100644 --- a/cli/src/commands/job.test.ts +++ b/cli/src/commands/job.test.ts @@ -47,6 +47,17 @@ describe('parseJobArgs', () => { it('rejects bad status', () => { expect(() => parseJobArgs(['update', 's', 'k', '--status', 'nope'])).toThrow(SessionJobError) }) + + it('parses --started-at for set', () => { + const parsed = parseJobArgs([ + 'set', + 'sid', + 'beets', + '--label=beets', + '--started-at=1785304595000' + ]) + expect(parsed.startedAt).toBe(1_785_304_595_000) + }) }) describe('resolveSessionByPrefix', () => { diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index f9be88ce6b..b91d189254 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -24,6 +24,7 @@ export type ParsedJobArgs = { remaining?: number unit?: string detail?: string + startedAt?: number heartbeatSec?: number command?: string[] } @@ -44,7 +45,7 @@ ${chalk.bold('Agent contract:')} ${chalk.bold('Usage:')} hapi job run --label [--heartbeat-sec 300] [progress flags] -- [args...] - hapi job set --label [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] + hapi job set --label [--started-at MS] [--remaining N] [--done N --total N] [--unit tracks] [--detail ...] hapi job update [--remaining N] [--done N] [--total N] [--status running|completed|failed] [--detail ...] hapi job clear hapi job list @@ -55,8 +56,15 @@ ${chalk.bold('Progress UI:')} label/detail only → "running · 2h" + indeterminate bar elapsed always from startedAt (wall clock) — never an ETA / time-remaining field +${chalk.bold('startedAt / elapsed:')} + Prefer ${chalk.bold('update')} for heartbeats/progress so the clock is never wiped. + PATCH rejects startedAt. PUT/set without --started-at keeps the existing clock. + Late attach or wrong clock: ${chalk.bold('set --started-at ')} (explicit PUT), + or clear then set with --started-at (works on older hubs that ignored PUT corrections). + ${chalk.bold('Notes:')} Hub-persisted. Prefer "$HAPI_SESSION_ID" for this chat. + Needs a hub/CLI that includes the job subcommand (soup / feat build — not every npm release). Job key: 1-128 chars, alnum / . _ - Session lookup prefers exact id; prefix scan is the 500 most-recently-updated sessions. Docs: docs/guide/session-jobs.md @@ -166,6 +174,14 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { result.heartbeatSec = parseOptionalNumber('--heartbeat-sec', arg.slice('--heartbeat-sec='.length)) continue } + if (arg === '--started-at') { + result.startedAt = parseOptionalNumber('--started-at', flagArgs[++i]) + continue + } + if (arg.startsWith('--started-at=')) { + result.startedAt = parseOptionalNumber('--started-at', arg.slice('--started-at='.length)) + continue + } if (arg.startsWith('-')) { throw new SessionJobError('bad_args', `unexpected flag: ${arg}`) } @@ -282,6 +298,9 @@ export async function handleJobCommand(args: string[]): Promise { if (!parsed.label) { throw new SessionJobError('bad_args', 'set requires --label') } + if (parsed.startedAt !== undefined && !Number.isFinite(parsed.startedAt)) { + throw new SessionJobError('bad_args', '--started-at must be epoch milliseconds') + } const body: AttachedJobUpsert = { label: parsed.label, status: parsed.status ?? 'running', @@ -289,7 +308,8 @@ export async function handleJobCommand(args: string[]): Promise { ...(parsed.total !== undefined ? { total: parsed.total } : {}), ...(parsed.remaining !== undefined ? { remaining: parsed.remaining } : {}), ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), - ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}) + ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}), + ...(parsed.startedAt !== undefined ? { startedAt: parsed.startedAt } : {}) } const result = await setSessionJob({ sessionIdPrefix: parsed.sessionIdPrefix, diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 6bd478079a..79cb3f6e08 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -99,7 +99,7 @@ Some agents (especially Cursor) can resume after idle from harness signals such ### How do I show progress for a long batch that outlives the agent? -Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md). +Use session-attached jobs (`hapi job`). The agent (or a wrapper script) registers a job on the session, heartbeats while the process runs, and clears it when done. The session list shows remaining / fraction / or an indeterminate "running" meter even when the agent is idle. See [Session-attached jobs](./session-jobs.md). This is Layer 0 list chrome - not an A2A Layer 1 work advertisement ([#1332](https://github.com/tiann/hapi/discussions/1332)). ### Can I access a terminal remotely? diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 18b78e23c2..90d85215fc 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -6,6 +6,21 @@ This is **not** in-agent thinking progress, todos, or `backgroundTaskCount`. Tho Upstream: [tiann/hapi#1404](https://github.com/tiann/hapi/issues/1404). +## Relation to A2A (not work advertisements) + +HAPI's Agent-to-Agent control plane ([discussion #1332](https://github.com/tiann/hapi/discussions/1332)) is a **different** object family. Do not merge them. + +| | Session-attached jobs (#1404) | A2A `work_ad` (Layer 1) | +|--|------------------------------|-------------------------| +| Store | `session_jobs` | `events` / work-graph ledger | +| Surface | `SessionSummary.attachedJob` (list chrome) | Durable collaboration ledger | +| Question answered | "Is a long process still running on this session, and how far?" | "What is this session claiming about turn/project work for peers/overseer?" | +| Progress | Heartbeats + honest counts / indeterminate | Status vocabulary (`in_progress`, `done`, `failed`, `stale`, …) | +| Silence | UI amber after ~15m without heartbeat; status stays `running` until explicit exit | `expires_at` → `stale` / `unknown` — silence is **not** failure | +| Self-report | Optional counts/detail; `hapi job run` exit code is machine fact | Optional `AGENT_NOTIFY_SUMMARY` elevation (stays optional forever) | + +Jobs enrich **Layer 0** session summaries (same layer as cite / inspect / ping). They are **not** Google A2A Tasks, and they are **not** a substitute for handoffs or work ads. Do not write job heartbeats into the A2A ledger. A privileged reader may *observe* `attachedJob` later; workers still must not poll the ledger as a work queue. + ## When to attach Attach a job **before** (or immediately when) you start process-shaped work that will keep running after the agent goes idle: @@ -57,7 +72,7 @@ Same auth as `hapi ping-peer` (`HAPI_API_URL` / `CLI_API_TOKEN` or `hapi auth lo | Countable fraction | `--done N --total M` | `91% · 1637/1787 units · 2d 4h` | | Stage only / unknown size | `--label` + `--detail` + heartbeats | `running · 2d 4h` + indeterminate bar | -**Elapsed** is always derived from hub `startedAt` (wall clock since register). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate. +**Elapsed** is always derived from hub `startedAt` (wall clock). It is **not** an ETA and there is no time-remaining field - operators get "how long has this been going" plus whatever honest count/detail you report, without a fake completion estimate. Rules: @@ -66,6 +81,39 @@ Rules: - If you only know a stage name, put it in `--detail` and keep heartbeating — do **not** fake `total=100`. - There is **no** `--percent` flag and **no** ETA / time-remaining field. Inventing either would train agents to lie. +## `startedAt` / elapsed (late attach) + +Elapsed is honest wall clock from hub `startedAt`. Dogfood gotcha (music drain / beets): + +| Call | `startedAt` behavior | +|------|----------------------| +| `PATCH` / `hapi job update` | **Rejected** if you send `startedAt` (`unrecognized_keys`). Progress/heartbeat only. | +| `PUT` / `hapi job set` without `--started-at` | Keeps the existing clock when the job already exists; first create stamps now. | +| `PUT` / `hapi job set --started-at ` | Sets/corrects the clock (explicit body field). | +| `DELETE` then `PUT` with `startedAt` | Always works — including older hubs that ignored PUT corrections. | + +**Prefer `update` for heartbeats** so you never wipe the clock. Only correct historical start when a late attach stamped attach-time instead of process start: + +```bash +# epoch ms for when the drain actually started (example) +START_MS=1785304595000 + +hapi job clear "$HAPI_SESSION_ID" beets +hapi job set "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --started-at "$START_MS" \ + --remaining 0 --done 1787 --total 1787 --unit units \ + --detail 'ALL_DONE' + +# or, on hubs that honor explicit PUT startedAt without delete: +hapi job set "$HAPI_SESSION_ID" beets \ + --label 'beets import' \ + --started-at "$START_MS" \ + --remaining 12 --done 1775 --total 1787 --unit units +``` + +Then keep using `hapi job update` for counts/detail/status. + ## Heartbeat recipe Wrap the long process so something calls `hapi job update` on a timer (or on each unit completed). Minimum viable indeterminate job: @@ -78,16 +126,26 @@ hapi job update "$HAPI_SESSION_ID" rsync-backup --detail "phase: copy · $(date When the process exits, mark completed/failed or clear. A stuck green/amber chip with a dead PID is worse than no chip. -## CLI reference +## CLI / API reference ```bash -hapi job set --label [options] -hapi job update [options] +hapi job set --label [--started-at MS] [progress flags] +hapi job update [progress flags] # no startedAt hapi job clear hapi job list +hapi job run --label -- … hapi job --help ``` +Needs a hub/CLI build that includes `job` (soup / feat — global npm releases may lag). + +| Method | Path | Notes | +|--------|------|-------| +| `GET` | `/api/sessions/:id/jobs` | List jobs | +| `PUT` | `/api/sessions/:id/jobs/:jobKey` | Upsert (`AttachedJobUpsert`; optional `startedAt`) | +| `PATCH` | `/api/sessions/:id/jobs/:jobKey` | Progress/heartbeat (`AttachedJobPatch`; **no** `startedAt`) | +| `DELETE` | `/api/sessions/:id/jobs/:jobKey` | Clear | + Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches. ## Related @@ -95,3 +153,4 @@ Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pu - [Supported Agents](./agents.md) — flavors and resume - [How it Works](./how-it-works.md) — CLI ↔ hub ↔ web - CLI: `hapi job --help`, `cli/README.md` +- A2A control plane: [discussion #1332](https://github.com/tiann/hapi/discussions/1332) (Layer 1 work ads / handoffs — separate from this feature) diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index 60b3807f11..890d7f7b09 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -60,6 +60,46 @@ describe('Store V25→V26 migration: session_jobs table', () => { store.close() }) + it('preserves startedAt on PUT without body.startedAt; honors explicit correction', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('test', { path: '/tmp' }, null, 'default') + const historical = 1_785_304_595_000 + + const created = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 10 + }, 2_000) + expect(created.outcome).toBe('upserted') + if (created.outcome !== 'upserted') throw new Error('unreachable') + expect(created.job.startedAt).toBe(2_000) + + const progress = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 9 + }, 3_000) + expect(progress.outcome).toBe('upserted') + if (progress.outcome !== 'upserted') throw new Error('unreachable') + expect(progress.job.startedAt).toBe(2_000) + expect(progress.job.remaining).toBe(9) + + const corrected = store.sessionJobs.upsert(session.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 9, + startedAt: historical + }, 4_000) + expect(corrected.outcome).toBe('upserted') + if (corrected.outcome !== 'upserted') throw new Error('unreachable') + expect(corrected.job.startedAt).toBe(historical) + + const patched = store.sessionJobs.patch(session.id, 'beets', { remaining: 8 }, 5_000) + expect(patched?.startedAt).toBe(historical) + expect(patched?.remaining).toBe(8) + store.close() + }) + it('transfers jobs on merge without colliding keys', () => { const store = new Store(':memory:') const oldSession = store.sessions.getOrCreateSession('old', { path: '/a' }, null, 'default') diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index c44b5831df..fb933bef19 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -134,7 +134,11 @@ export function upsertSessionJob( ): UpsertSessionJobResult { const existing = getSessionJob(db, sessionId, jobKey) const heartbeatAt = body.heartbeatAt ?? now - const startedAt = body.startedAt ?? existing?.startedAt ?? now + // Explicit startedAt wins (late-attach correction). Omitted → keep existing clock, + // else stamp now. PATCH never accepts startedAt — use PUT or clear+PUT. + const startedAt = body.startedAt !== undefined + ? body.startedAt + : (existing?.startedAt ?? now) const status = body.status ?? 'running' try { @@ -152,7 +156,7 @@ export function upsertSessionJob( unit = excluded.unit, detail = excluded.detail, heartbeat_at = excluded.heartbeat_at, - started_at = session_jobs.started_at, + started_at = excluded.started_at, updated_at = excluded.updated_at` ).run( sessionId, diff --git a/hub/src/store/sessionJobsStore.ts b/hub/src/store/sessionJobsStore.ts index cc59c5c2f6..8394730e5b 100644 --- a/hub/src/store/sessionJobsStore.ts +++ b/hub/src/store/sessionJobsStore.ts @@ -39,12 +39,22 @@ export class SessionJobsStore { return getPrimaryRunningJobsBySessionIds(this.db, sessionIds) } - upsert(sessionId: string, jobKey: string, body: AttachedJobUpsert): UpsertSessionJobResult { - return upsertSessionJob(this.db, sessionId, jobKey, body) + upsert( + sessionId: string, + jobKey: string, + body: AttachedJobUpsert, + now?: number + ): UpsertSessionJobResult { + return upsertSessionJob(this.db, sessionId, jobKey, body, now) } - patch(sessionId: string, jobKey: string, patch: AttachedJobPatch): StoredSessionJob | null { - return patchSessionJob(this.db, sessionId, jobKey, patch) + patch( + sessionId: string, + jobKey: string, + patch: AttachedJobPatch, + now?: number + ): StoredSessionJob | null { + return patchSessionJob(this.db, sessionId, jobKey, patch, now) } delete(sessionId: string, jobKey: string): boolean { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 8ea6736c9b..f47275af2b 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -410,6 +410,7 @@ export const AttachedJobUpsertSchema = z.object({ export type AttachedJobUpsert = z.infer +/** Progress/heartbeat only — no startedAt (use PUT upsert with explicit startedAt to correct). */ export const AttachedJobPatchSchema = z.object({ label: z.string().min(1).max(200).optional(), status: AttachedJobStatusSchema.optional(), From a79356a1e37f96a927b47da10fcbd65ad0fca0c8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 16:40:30 +0000 Subject: [PATCH 093/168] fix(jobs): keep merge redirect keys in MetadataSchema (#1404) SessionCache.refreshSession stripped jobsAcceptedFromSessionIds / jobsTransferredToSessionId as unknown keys, so resolveAttachedJobSessionId could not follow post-merge heartbeats. Declare the fields, add a real SessionCache integration test, and pass full UUIDs through the CLI when the merge source is missing from GET /sessions. Co-authored-by: Cursor --- cli/src/commands/job.test.ts | 14 ++- cli/src/modules/sessionJob/sessionJob.ts | 28 ++++- hub/src/sync/sessionCache-merge-jobs.test.ts | 101 +++++++++++++++++++ shared/src/schemas.clear.test.ts | 11 ++ shared/src/schemas.ts | 7 ++ 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 hub/src/sync/sessionCache-merge-jobs.test.ts diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts index 248ad3b8d9..e3785f1561 100644 --- a/cli/src/commands/job.test.ts +++ b/cli/src/commands/job.test.ts @@ -3,7 +3,8 @@ import { parseJobArgs } from '@/commands/job' import { SessionJobError, exitCodeForSessionJobError, - resolveSessionByPrefix + resolveSessionByPrefix, + resolveSessionIdForJobCli } from '@/modules/sessionJob/sessionJob' describe('parseJobArgs', () => { @@ -84,6 +85,17 @@ describe('resolveSessionByPrefix', () => { }) }) +describe('resolveSessionIdForJobCli', () => { + it('passes a full UUID through when missing from the session list', () => { + const deleted = 'cccccccc-4444-4444-4444-444444444444' + expect(resolveSessionIdForJobCli([], deleted)).toBe(deleted) + }) + + it('still errors for a non-uuid prefix with no list match', () => { + expect(() => resolveSessionIdForJobCli([], 'deadbeef')).toThrow(/no session matching/) + }) +}) + describe('exitCodeForSessionJobError', () => { it('maps codes', () => { expect(exitCodeForSessionJobError(new SessionJobError('bad_args', 'x'))).toBe(2) diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts index 2f404fce9f..0a52c661ff 100644 --- a/cli/src/modules/sessionJob/sessionJob.ts +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -119,6 +119,32 @@ export function resolveSessionByPrefix(sessions: SessionListItem[], prefix: stri return matches[0]! } +const FULL_SESSION_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Resolve a session id for job CLI calls. Prefer list match; if the prefix is + * a full UUID missing from the list (deleted merge source), pass it through so + * hub job routes can follow jobsAcceptedFromSessionIds. + */ +export function resolveSessionIdForJobCli( + sessions: SessionListItem[], + sessionIdPrefix: string +): string { + try { + return resolveSessionByPrefix(sessions, sessionIdPrefix).id + } catch (error) { + if ( + error instanceof SessionJobError + && error.code === 'not_found' + && FULL_SESSION_UUID.test(sessionIdPrefix.trim()) + ) { + return sessionIdPrefix.trim() + } + throw error + } +} + async function resolveSessionId( apiUrl: string, jwt: string, @@ -137,7 +163,7 @@ async function resolveSessionId( const sessions = Array.isArray(response.data?.sessions) ? (response.data.sessions as SessionListItem[]) : [] - return resolveSessionByPrefix(sessions, sessionIdPrefix).id + return resolveSessionIdForJobCli(sessions, sessionIdPrefix) } export type SessionJobClientOptions = { diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts new file mode 100644 index 0000000000..9171a12fbc --- /dev/null +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'bun:test' +import type { SyncEvent } from '@hapi/protocol/types' +import { MetadataSchema } from '@hapi/protocol/schemas' +import { Store } from '../store' +import type { EventPublisher } from './eventPublisher' +import { SessionCache } from './sessionCache' + +/** + * Cold-review pass 2 (#1404): job-owner redirects written by recordJobs*() + * must survive SessionCache.refreshSession. MetadataSchema used to strip + * jobsAcceptedFromSessionIds / jobsTransferredToSessionId as unknown keys, + * so resolveAttachedJobSessionId never followed the merge. + */ + +function createCapturingPublisher(events: SyncEvent[]): EventPublisher { + return { + emit: (event: SyncEvent) => { + events.push(event) + } + } as unknown as EventPublisher +} + +function setup() { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createCapturingPublisher(events)) + return { store, events, cache } +} + +function makeSessions(cache: SessionCache, ns: string = 'default') { + const oldSession = cache.getOrCreateSession( + 'agent-jobs-old-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + ns + ) + const newSession = cache.getOrCreateSession( + 'agent-jobs-new-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + ns + ) + return { oldSession, newSession } +} + +describe('mergeSessions job redirect through SessionCache (#1404)', () => { + it('keeps jobsAcceptedFromSessionIds after refresh when old session is deleted', async () => { + const { store, cache } = setup() + const { oldSession, newSession } = makeSessions(cache) + + const upserted = store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 12 + }) + expect(upserted.outcome).toBe('upserted') + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.key).toBe('beets') + expect(store.sessions.getSession(oldSession.id)).toBeNull() + + // Schema strip regression: refresh must retain the acceptor list. + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed).not.toBeNull() + const accepted = refreshed!.metadata?.jobsAcceptedFromSessionIds + expect(accepted).toContain(oldSession.id) + expect( + MetadataSchema.parse(refreshed!.metadata).jobsAcceptedFromSessionIds + ).toContain(oldSession.id) + + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) + }) + + it('keeps jobsTransferredToSessionId on a kept-alive source after mergeSessionHistory', async () => { + const { store, cache } = setup() + const { oldSession, newSession } = makeSessions(cache) + + store.sessionJobs.upsert(oldSession.id, 'drain', { + label: 'rsync drain', + status: 'running', + remaining: 3 + }) + + await cache.mergeSessionHistory(oldSession.id, newSession.id, 'default', { + mergeAgentState: false + }) + + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.key).toBe('drain') + expect(store.sessions.getSession(oldSession.id)).not.toBeNull() + + const refreshedOld = cache.refreshSession(oldSession.id) + expect(refreshedOld?.metadata?.jobsTransferredToSessionId).toBe(newSession.id) + expect( + MetadataSchema.parse(refreshedOld!.metadata).jobsTransferredToSessionId + ).toBe(newSession.id) + + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + }) +}) diff --git a/shared/src/schemas.clear.test.ts b/shared/src/schemas.clear.test.ts index e84e5673dd..b8197221c7 100644 --- a/shared/src/schemas.clear.test.ts +++ b/shared/src/schemas.clear.test.ts @@ -10,6 +10,17 @@ describe('fresh-session clear schema contract', () => { })).toMatchObject({ supersededBySessionId: 'new-session-id' }) }) + it('preserves session-job merge redirect fields (must not strip as unknown)', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp/project', + host: 'host', + jobsAcceptedFromSessionIds: ['old-session-id'], + jobsTransferredToSessionId: 'new-session-id' + }) + expect(parsed.jobsAcceptedFromSessionIds).toEqual(['old-session-id']) + expect(parsed.jobsTransferredToSessionId).toBe('new-session-id') + }) + it('accepts cleared as an additive session-end reason', () => { expect(SessionEndReasonSchema.parse('cleared')).toBe('cleared') }) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f47275af2b..13246a8336 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -118,6 +118,13 @@ export const MetadataSchema = z.object({ // Set only after a completed fresh-session clear. The source row remains // archived; web clients use this durable link to follow the replacement. supersededBySessionId: z.string().optional(), + // After session merge/dedup transfers session_jobs: the target remembers + // which source ids it absorbed (so job REST can follow a deleted + // pre-merge $HAPI_SESSION_ID), and a kept-alive source points at the + // post-merge owner. Must be declared here — SessionCache.refreshSession + // parses via MetadataSchema and strips unknown keys (tiann/hapi#1404). + jobsAcceptedFromSessionIds: z.array(z.string()).optional(), + jobsTransferredToSessionId: z.string().optional(), // Durable in-progress state for runner-backed OpenCode /clear. opencodeClearOperation: OpencodeClearOperationSchema.optional(), preferredPermissionMode: PermissionModeSchema.optional(), From c386852fb9658677d936654849cb4c9d73d0dcc0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 17:14:51 +0000 Subject: [PATCH 094/168] feat(jobs): MCP session_job tool for catalog-level discoverability Expose session-attached jobs in the same HAPI MCP catalog as ping_peer / inspect_peer so Cursor/ACP and steered flavors see outliving-work meters as first-class tooling, not docs-only. Auto-approve own-session calls; steer prefers MCP set/update or CLI job run for supervised children. Co-authored-by: Cursor --- AGENTS.md | 13 +- cli/src/agent/runners/runAgentSession.test.ts | 4 +- cli/src/claude/utils/startHappyServer.test.ts | 28 ++- cli/src/claude/utils/startHappyServer.ts | 24 ++- cli/src/codex/happyMcpStdioBridge.test.ts | 32 +++- cli/src/codex/happyMcpStdioBridge.ts | 44 ++++- .../codex/utils/buildHapiMcpBridge.test.ts | 18 +- cli/src/codex/utils/buildHapiMcpBridge.ts | 4 + .../permission/BasePermissionHandler.test.ts | 11 ++ .../permission/BasePermissionHandler.ts | 10 +- .../common/sessionJobInstruction.test.ts | 7 +- .../modules/common/sessionJobInstruction.ts | 21 ++- .../modules/sessionJob/sessionJobMcp.test.ts | 64 +++++++ cli/src/modules/sessionJob/sessionJobMcp.ts | 172 ++++++++++++++++++ docs/guide/session-jobs.md | 17 +- 15 files changed, 422 insertions(+), 47 deletions(-) create mode 100644 cli/src/modules/sessionJob/sessionJobMcp.test.ts create mode 100644 cli/src/modules/sessionJob/sessionJobMcp.ts diff --git a/AGENTS.md b/AGENTS.md index 1ff7ca78c9..3b9d142433 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,14 +174,15 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr ## Session-attached jobs (outliving work) -When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it with `hapi job` so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. It is also **not** an A2A Layer 1 `work_ad` ([#1332](https://github.com/tiann/hapi/discussions/1332)) — jobs enrich Layer 0 `SessionSummary`; leave collaboration claims / handoffs to the work-graph ledger. +When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it so the session list stays truthful while `active: false`. Same tooling class as `ping_peer` / `inspect_peer` (MCP `session_job`). This is **not** thinking progress / todos / in-agent background tools. It is also **not** an A2A Layer 1 `work_ad` ([#1332](https://github.com/tiann/hapi/discussions/1332)) — jobs enrich Layer 0 `SessionSummary`; leave collaboration claims / handoffs to the work-graph ledger. -Agent contract (prefer the supervisor — idle agents cannot heartbeat): +Agent contract (idle agents cannot heartbeat): -1. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` (auto-heartbeat + exit status) -2. Manual path only with a self-heartbeating wrapper: `set` / `update` ≥~10m / clear -3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent -4. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with `set --started-at` (or clear+set) +1. Prefer MCP `session_job` (`action=set` then `update` ≥~10m) when the HAPI MCP server is attached +2. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` for supervised shell children +3. Manual CLI only with a self-heartbeating wrapper: `set` / `update` / clear +4. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent +5. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with `set --started-at` (or clear+set) Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index fe1886578a..2eb74df6b7 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -74,7 +74,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ harness.startHappyServerOptions = options return { url: 'http://127.0.0.1:1234', - toolNames: ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'list_peers', 'ping_peer', 'inspect_peer', 'session_job', 'skill_lookup'], stop: harness.stopServer } }) @@ -167,7 +167,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,list_peers,ping_peer,inspect_peer,session_job,skill_lookup' ]) expect(harness.newSessionOptions).toMatchObject({ cwd: '/tmp/project', diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index b507c1c4e0..8f9fff9240 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -44,6 +44,7 @@ describe('startHappyServer skill_lookup', () => { async function connect(enableSkillLookup = true): Promise { sendAgentMessage = vi.fn() const sessionClient = { + sessionId: 'test-session-id', updateMetadata: vi.fn(), sendAgentMessage, sendClaudeSessionMessage: vi.fn() @@ -107,15 +108,16 @@ describe('startHappyServer skill_lookup', () => { const mcp = await connect(false) const tools = await mcp.listTools() - expect(tools.tools.map((tool) => tool.name)).toEqual([ + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ 'change_title', 'display_image', 'display_video', 'display_media', - 'ping_peer', 'inspect_peer', - 'list_peers' - ]) + 'list_peers', + 'ping_peer', + 'session_job', + ].sort()) }) it('describes display_image as user output rather than image input', async () => { @@ -170,6 +172,7 @@ describe('startHappyServer skill_lookup', () => { it('does not expose change_title when native ACP titles are enabled', async () => { const sessionClient = { + sessionId: 'test-session-id', updateMetadata: vi.fn(), sendAgentMessage: vi.fn(), sendClaudeSessionMessage: vi.fn() @@ -182,15 +185,24 @@ describe('startHappyServer skill_lookup', () => { await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url))) const tools = await mcp.listTools() - expect(server.toolNames).toEqual(['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']) - expect(tools.tools.map((tool) => tool.name)).toEqual([ + expect(server.toolNames).toEqual([ 'display_image', 'display_video', 'display_media', + 'list_peers', 'ping_peer', 'inspect_peer', - 'list_peers' + 'session_job', ]) + expect(tools.tools.map((tool) => tool.name).sort()).toEqual([ + 'display_image', + 'display_media', + 'display_video', + 'inspect_peer', + 'list_peers', + 'ping_peer', + 'session_job', + ].sort()) }) }) @@ -205,11 +217,13 @@ describe('toClaudeAllowedHapiMcpTools', () => { 'list_peers', 'ping_peer', 'inspect_peer', + 'session_job', 'skill_lookup' ])).toEqual([ 'mcp__hapi__change_title', 'mcp__hapi__display_image', 'mcp__hapi__list_peers', + 'mcp__hapi__session_job', 'mcp__hapi__skill_lookup' ]) expect(toClaudeAllowedHapiMcpTools(['display_video'])).not.toContain('mcp__hapi__display_video') diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index f14af6c024..229fdd831f 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -27,6 +27,13 @@ import { SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation' import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspectPeer, listPeerSessions, peerListFetchLimit, pingPeer } from "@/modules/pingPeer/pingPeer"; +import { + SESSION_JOB_TOOL_DESCRIPTION, + SESSION_JOB_TOOL_NAME, + handleSessionJobTool, + sessionJobInputSchema, + type SessionJobToolArgs, +} from "@/modules/sessionJob/sessionJobMcp"; type StartHappyServerOptions = { emitTitleSummary?: boolean; @@ -369,6 +376,19 @@ function createHapiMcpServer( } }); + mcp.registerTool(SESSION_JOB_TOOL_NAME, { + description: SESSION_JOB_TOOL_DESCRIPTION, + title: 'Session-Attached Job', + inputSchema: sessionJobInputSchema, + }, async (args: SessionJobToolArgs) => { + logger.debug('[hapiMCP] session_job:', args.action, args.jobKey); + const result = await handleSessionJobTool(args, client.sessionId); + return { + content: [{ type: 'text' as const, text: result.text }], + isError: result.isError, + }; + }); + mcp.registerTool('list_peers', { description: 'List peer HAPI sessions on the same hub/namespace (id prefix, active, flavor, name). Uses this session\'s hub credentials - works from runner-spawned agents without being on the hub host. Prefer this over shelling `hapi ping-peer --list`. Then call inspect_peer / ping_peer with a listed id.', title: 'List Peer Sessions', @@ -534,8 +554,8 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH })); const toolNames = enableChangeTitle - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'] - : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; + ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', SESSION_JOB_TOOL_NAME] + : ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', SESSION_JOB_TOOL_NAME]; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index c940d0bc50..5ae2d27064 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -70,16 +70,20 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { '--url', 'http://127.0.0.1:43006', '--tools', - 'change_title,display_image,display_video,display_media,skill_lookup' + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,session_job,skill_lookup' ]) - expect([...harness.tools.keys()]).toEqual([ + expect([...harness.tools.keys()].sort()).toEqual([ 'change_title', 'display_image', - 'display_video', 'display_media', - 'skill_lookup' - ]) + 'display_video', + 'inspect_peer', + 'list_peers', + 'ping_peer', + 'session_job', + 'skill_lookup', + ].sort()) const handler = harness.tools.get('skill_lookup') expect(handler).toBeDefined() @@ -140,6 +144,24 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { ]) }) + it('registers session_job when included in --tools', async () => { + await runHappyMcpStdioBridge([ + '--url', + 'http://127.0.0.1:43006', + '--tools', + 'change_title,display_image,list_peers,ping_peer,inspect_peer,session_job' + ]) + + expect([...harness.tools.keys()].sort()).toEqual([ + 'change_title', + 'display_image', + 'inspect_peer', + 'list_peers', + 'ping_peer', + 'session_job', + ].sort()) + }) + it('registers inspect_peer when included in --tools', async () => { await runHappyMcpStdioBridge([ '--url', diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index be0d03d0bb..177549a43b 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -22,8 +22,22 @@ import { PING_PEER_TOOL_DESCRIPTION, SESSION_ID_PREFIX_PARAM_DESCRIPTION, } from '@hapi/protocol/sessionCitation'; +import { + SESSION_JOB_TOOL_DESCRIPTION, + SESSION_JOB_TOOL_NAME, + sessionJobInputSchema, +} from '@/modules/sessionJob/sessionJobMcp'; -const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']; +const DEFAULT_TOOL_NAMES = [ + 'change_title', + 'display_image', + 'display_video', + 'display_media', + 'list_peers', + 'ping_peer', + 'inspect_peer', + SESSION_JOB_TOOL_NAME, +]; function parseArgs(argv: string[]): { url: string | null; toolNames: Set } { let url: string | null = null; @@ -289,6 +303,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { ); } + if (toolNames.has(SESSION_JOB_TOOL_NAME)) { + server.registerTool( + SESSION_JOB_TOOL_NAME, + { + description: SESSION_JOB_TOOL_DESCRIPTION, + title: 'Session-Attached Job', + inputSchema: sessionJobInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: SESSION_JOB_TOOL_NAME, arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to run session_job: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } + ); + } + const skillLookupInputSchema: z.ZodTypeAny = z.object({ name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'), }); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index 8af0d57768..8a2ba68c05 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ return { url: 'http://127.0.0.1:43006/', toolNames: options.skillLookup - ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'skill_lookup'] - : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'], + ? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'session_job', 'skill_lookup'] + : ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer', 'session_job'], stop: vi.fn() } }) @@ -71,14 +71,15 @@ describe('buildHapiMcpBridge skill lookup config', () => { '--url', 'http://127.0.0.1:43006/', '--tools', - 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,skill_lookup' + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,session_job,skill_lookup' ]) expect(bridge.mcpServers.hapi.tools).toEqual({ - change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, + change_title: { approval_mode: 'approve' }, list_peers: { approval_mode: 'approve' }, + session_job: { approval_mode: 'approve' }, skill_lookup: { approval_mode: 'approve' } }) }) @@ -86,13 +87,16 @@ describe('buildHapiMcpBridge skill lookup config', () => { it('does not expose skill_lookup for native-skill bridge callers', async () => { const bridge = await buildHapiMcpBridge(createClient()) - expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer') + expect(harness.cliArgs.at(-1)).toBe( + 'change_title,display_image,display_video,display_media,list_peers,ping_peer,inspect_peer,session_job' + ) expect(bridge.mcpServers.hapi.tools).toEqual({ - change_title: { approval_mode: 'approve' }, display_image: { approval_mode: 'prompt' }, display_video: { approval_mode: 'prompt' }, display_media: { approval_mode: 'prompt' }, - list_peers: { approval_mode: 'approve' } + change_title: { approval_mode: 'approve' }, + list_peers: { approval_mode: 'approve' }, + session_job: { approval_mode: 'approve' } }) }) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index f92621f7a5..778d45383c 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -109,6 +109,10 @@ export async function buildHapiMcpBridge( tools.list_peers = { approval_mode: 'approve' }; + // Own-session progress meter (tiann/hapi#1404) — hub REST, not peer inject. + tools.session_job = { + approval_mode: 'approve' + }; // ping_peer / inspect_peer are registered on the HTTP MCP server / stdio // bridge, but are not auto-approved: they target another session (resume + // inject, or read peer histories). diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 6febd9a4c2..3e8ea63108 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -101,3 +101,14 @@ describe('resolveToolAutoApprovalDecision list_peers', () => { )).toBeNull() }) }) + +describe('resolveToolAutoApprovalDecision session_job', () => { + it.each([ + 'session_job', + 'hapi_session_job', + 'mcp__hapi__session_job', + 'Session-Attached Job' + ])('auto-approves own-session job meter %s', (toolName) => { + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBe('approved') + }) +}) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 860c542624..27924f0388 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -37,13 +37,19 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'happy__list_peers', 'mcp__hapi__list_peers', // ACP permission requests often surface MCP tool title, not the snake_case name. - 'list peer sessions' + 'list peer sessions', + // Own-session progress meter (tiann/hapi#1404) — hub REST only, not peer inject. + 'session_job', + 'hapi_session_job', + 'happy__session_job', + 'mcp__hapi__session_job', + 'session-attached job' ]); // ping_peer / inspect_peer intentionally omitted from always-approve: they can // resume+inject into another session or read peer histories, so permission // modes must still gate them. Treat both as write-like in read-only so ACP // titles such as "Ping Peer Session" / "Inspect Peer Session" also require -// approval. list_peers is discovery-only and is auto-approved above. +// approval. list_peers / session_job are auto-approved above. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; const SENSITIVE_TOOL_NAME_HINTS = [ 'ping_peer', diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts index c6d919a329..45ebbd72cd 100644 --- a/cli/src/modules/common/sessionJobInstruction.test.ts +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -5,10 +5,11 @@ import { } from './sessionJobInstruction' describe('sessionJobInstruction', () => { - it('prefers job run supervisor and forbids fake percent', () => { + it('prefers MCP session_job + job run supervisor and forbids fake percent', () => { + expect(SESSION_JOB_INSTRUCTION).toContain('session_job') + expect(SESSION_JOB_INSTRUCTION).toContain('ping_peer') expect(SESSION_JOB_INSTRUCTION).toContain('hapi job run') - expect(SESSION_JOB_INSTRUCTION).toContain('hapi job update') - expect(SESSION_JOB_INSTRUCTION).toContain('idle agent cannot') + expect(SESSION_JOB_INSTRUCTION).toContain('idle agents cannot') expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') }) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index ee3043bf76..c400615455 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -2,22 +2,23 @@ * Always-on steer for session-attached long-running jobs (tiann/hapi#1404). * * Injected into flavors that have a HAPI system / developer-instructions seam - * today: Claude, Codex, OpenCode, Grok. Cursor ACP has no such seam (estate - * skill `hapi-session-jobs` + `hapi job --help` instead). Other ACP flavors - * (Kimi, Copilot, Pi, …) do not receive this block until an MCP job tool or - * per-flavor seam lands — do not claim "every flavor." + * today: Claude, Codex, OpenCode, Grok. Cursor/Kimi/Copilot/Pi get the same + * MCP tool (`session_job`) via the HAPI MCP bridge — catalog discovery, not + * this prompt block. Estate skill remains a Cursor backup. */ /** Canonical one-block contract. Keep short — every session's prompt budget. */ export const SESSION_JOB_INSTRUCTION = [ 'Session-attached jobs (outliving work):', 'When you start work that will keep running after this agent goes idle', - '(batch imports, long scripts, external daemons), attach it so the session', - 'list can show progress while you are idle.', - 'Prefer: hapi job run "$HAPI_SESSION_ID" --label -- …', - '(auto-heartbeats + marks completed/failed on exit).', - 'Manual path: hapi job set … then a wrapper must heartbeat via', - 'hapi job update at least every ~10 minutes — an idle agent cannot.', + '(batch imports, long scripts, external daemons), attach a session job so', + 'the session list shows progress while you are idle — same class of HAPI', + 'tooling as ping_peer / inspect_peer.', + 'Prefer MCP tool session_job (aliases: mcp__hapi__session_job, hapi_session_job,', + 'functions.hapi__session_job) with action=set before the process starts, then', + 'action=update every ~10m (idle agents cannot heartbeat).', + 'For a supervised shell child: hapi job run "$HAPI_SESSION_ID" ', + '--label -- … (auto-heartbeats + completed/failed on exit).', 'Prefer honest remaining or done+total; omit counts when unknown', '(UI shows "running" + elapsed). Never invent a fake percent.', 'Full contract: hapi job --help.' diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts new file mode 100644 index 0000000000..87ac653dc6 --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { handleSessionJobTool, SESSION_JOB_TOOL_DESCRIPTION } from './sessionJobMcp' + +vi.mock('./sessionJob', () => ({ + SessionJobError: class SessionJobError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, + setSessionJob: vi.fn(async () => ({ + sessionId: 'sid-1', + job: { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 12, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + })), + updateSessionJob: vi.fn(async () => ({ + sessionId: 'sid-1', + job: { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 11, + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + })), + clearSessionJob: vi.fn(async () => ({ sessionId: 'sid-1' })), + listSessionJobs: vi.fn(async () => ({ sessionId: 'sid-1', jobs: [], primary: null })) +})) + +describe('sessionJobMcp', () => { + it('description steers outliving batch work and honest progress', () => { + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/OUTLIVES/i) + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Never invent a percent/i) + expect(SESSION_JOB_TOOL_DESCRIPTION).toContain('hapi job run') + }) + + it('set requires label and defaults session to caller id', async () => { + const result = await handleSessionJobTool( + { action: 'set', jobKey: 'beets', label: 'beets import', remaining: 12 }, + 'sid-1' + ) + expect(result.isError).toBe(false) + expect(result.text).toContain('set beets') + }) + + it('rejects update with empty patch', async () => { + const result = await handleSessionJobTool( + { action: 'update', jobKey: 'beets' }, + 'sid-1' + ) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/at least one/i) + }) +}) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts new file mode 100644 index 0000000000..519f064a9b --- /dev/null +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -0,0 +1,172 @@ +/** + * MCP surface for session-attached jobs (tiann/hapi#1404). + * Same discovery class as ping_peer / inspect_peer — tool catalog, not docs-only. + */ + +import { z } from 'zod' +import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import { + SessionJobError, + clearSessionJob, + listSessionJobs, + setSessionJob, + updateSessionJob +} from './sessionJob' + +export const SESSION_JOB_TOOL_NAME = 'session_job' + +/** + * Self-contained tool description — agents select by matching intent to this text. + * Write for selection, not for humans browsing a README. + */ +export const SESSION_JOB_TOOL_DESCRIPTION = [ + 'Attach or update a hub-persisted progress meter on a HAPI session for work that', + 'OUTLIVES this agent turn (nohup / batch import / rclone / compile / long drain /', + 'external daemon). The session list shows the meter while the agent is idle', + '(active:false). Call action=set BEFORE starting that process (or immediately when', + 'you start it). Heartbeat with action=update at least every ~10 minutes while it', + 'runs — an idle agent cannot. Prefer honest remaining or done+total; omit counts', + 'when unknown (UI shows "running" + elapsed). Never invent a percent or ETA.', + 'Finish with action=update status=completed|failed or action=clear.', + 'Default sessionId is this chat ($HAPI_SESSION_ID). Not for in-agent todos,', + 'thinking progress, or short tool calls. For a supervised shell child that', + 'auto-heartbeats, prefer CLI: hapi job run "$HAPI_SESSION_ID" --label … -- .', +].join(' ') + +export const sessionJobInputSchema: z.ZodTypeAny = z.object({ + action: z.enum(['set', 'update', 'clear', 'list']).describe( + 'set=register/upsert running job; update=heartbeat/progress/status; clear=remove; list=show jobs' + ), + sessionId: z.string().trim().min(1).optional().describe( + 'Target session id or prefix. Omit to use this chat ($HAPI_SESSION_ID).' + ), + jobKey: z.string().trim().min(1).max(128).optional().describe( + 'Stable job key (alnum . _ -). Required for set/update/clear.' + ), + label: z.string().trim().min(1).max(200).optional().describe( + 'Short human label for the list chrome. Required for set.' + ), + status: z.enum(['running', 'completed', 'failed']).optional().describe( + 'Job status. Default running on set.' + ), + done: z.number().nonnegative().optional().describe('Units completed (pair with total when known).'), + total: z.number().positive().optional().describe('Total units when both ends of a fraction exist.'), + remaining: z.number().nonnegative().optional().describe('Units left — prefer when operator cares about leftover.'), + unit: z.string().trim().min(1).max(64).optional().describe('Unit label (tracks, folders, files, …).'), + detail: z.string().max(500).optional().describe('Stage / current item text (not an ETA).'), + startedAt: z.number().optional().describe( + 'Epoch ms process start. Only on set/upsert; omit on heartbeats. Correct late attach with explicit value.' + ) +}) + +export type SessionJobToolArgs = { + action: 'set' | 'update' | 'clear' | 'list' + sessionId?: string + jobKey?: string + label?: string + status?: 'running' | 'completed' | 'failed' + done?: number + total?: number + remaining?: number + unit?: string + detail?: string + startedAt?: number +} + +function formatJobLine(job: AttachedJob): string { + const parts = [`${job.key}`, job.label, job.status] + if (job.remaining !== undefined) { + parts.push(`${job.remaining}${job.unit ? ` ${job.unit}` : ''} left`) + } else if (job.done !== undefined && job.total !== undefined) { + parts.push(`${job.done}/${job.total}${job.unit ? ` ${job.unit}` : ''}`) + } + if (job.detail) parts.push(job.detail) + return parts.join(' · ') +} + +export async function handleSessionJobTool( + args: SessionJobToolArgs, + defaultSessionId: string +): Promise<{ text: string; isError: boolean }> { + const sessionIdPrefix = (args.sessionId?.trim() || defaultSessionId || process.env.HAPI_SESSION_ID || '').trim() + if (!sessionIdPrefix) { + return { + text: 'sessionId required (or set HAPI_SESSION_ID / call from a HAPI-wrapped session)', + isError: true + } + } + + try { + if (args.action === 'list') { + const result = await listSessionJobs({ sessionIdPrefix }) + if (result.jobs.length === 0) { + return { text: `session ${result.sessionId}\n(no jobs)`, isError: false } + } + const lines = result.jobs.map((job) => { + const mark = result.primary?.key === job.key ? '*' : ' ' + return `${mark} ${formatJobLine(job)}` + }) + return { text: `session ${result.sessionId}\n${lines.join('\n')}`, isError: false } + } + + if (!args.jobKey?.trim()) { + return { text: 'jobKey is required for set/update/clear', isError: true } + } + const jobKey = args.jobKey.trim() + + if (args.action === 'clear') { + const result = await clearSessionJob({ sessionIdPrefix, jobKey }) + return { text: `cleared ${jobKey} on ${result.sessionId}`, isError: false } + } + + if (args.action === 'set') { + if (!args.label?.trim()) { + return { text: 'label is required for action=set', isError: true } + } + const body: AttachedJobUpsert = { + label: args.label.trim(), + status: args.status ?? 'running', + ...(args.done !== undefined ? { done: args.done } : {}), + ...(args.total !== undefined ? { total: args.total } : {}), + ...(args.remaining !== undefined ? { remaining: args.remaining } : {}), + ...(args.unit !== undefined ? { unit: args.unit } : {}), + ...(args.detail !== undefined ? { detail: args.detail } : {}), + ...(args.startedAt !== undefined ? { startedAt: args.startedAt } : {}) + } + const result = await setSessionJob({ sessionIdPrefix, jobKey, body }) + return { + text: `set ${formatJobLine(result.job)} on ${result.sessionId}`, + isError: false + } + } + + // update + const body: AttachedJobPatch = { + ...(args.label !== undefined ? { label: args.label } : {}), + ...(args.status !== undefined ? { status: args.status } : {}), + ...(args.done !== undefined ? { done: args.done } : {}), + ...(args.total !== undefined ? { total: args.total } : {}), + ...(args.remaining !== undefined ? { remaining: args.remaining } : {}), + ...(args.unit !== undefined ? { unit: args.unit } : {}), + ...(args.detail !== undefined ? { detail: args.detail } : {}) + } + if (Object.keys(body).length === 0) { + return { + text: 'update requires at least one of label/status/done/total/remaining/unit/detail', + isError: true + } + } + const result = await updateSessionJob({ sessionIdPrefix, jobKey, body }) + return { + text: `updated ${formatJobLine(result.job)} on ${result.sessionId}`, + isError: false + } + } catch (error) { + const message = error instanceof SessionJobError + ? error.message + : error instanceof Error + ? error.message + : String(error) + return { text: `session_job failed: ${message}`, isError: true } + } +} diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 90d85215fc..47048b1951 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -35,7 +35,20 @@ If the operator would reopen the chat only to ask "how's it doing?", it belongs ## Agent contract (specification) -HAPI does **not** write your batch scripts for you - but prefer the supervisor so heartbeats are not your problem: +Treat this like `ping_peer` / `inspect_peer`: it is first-class HAPI tooling, not a docs footnote. + +### MCP (preferred for agents) + +Tool name: `session_job` (Claude: `mcp__hapi__session_job`; Codex: `functions.hapi__session_job`; OpenCode/ACP: `hapi_session_job`). + +```json +{ "action": "set", "jobKey": "beets", "label": "beets import", + "remaining": 150, "done": 1637, "total": 1787, "unit": "units" } +``` + +Then `action=update` every ~10 minutes; finish with `status=completed|failed` or `action=clear`. Omit `sessionId` to target this chat. + +### CLI supervisor (preferred for shell children) ```bash hapi job run "$HAPI_SESSION_ID" beets \ @@ -47,7 +60,7 @@ hapi job run "$HAPI_SESSION_ID" beets \ `hapi job run` registers the job, heartbeats on a timer while the child runs, then marks `completed`/`failed` from the exit code. An idle agent **cannot** heartbeat - set-once + manual update decays to amber. -Manual path (only if you already have a self-heartbeating wrapper): +### CLI manual path ```bash hapi job set "$HAPI_SESSION_ID" beets \ From eb44551a4e0cebc1b92ebcb7a59ae55d66bda90e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:10:31 +0000 Subject: [PATCH 095/168] =?UTF-8?q?feat(web):=20tri-state=20pin=20?= =?UTF-8?q?=E2=80=94=20default=20long-running=20jobs=20with=20#1404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace boolean pin-in-progress with off / jobs / all. Unset preference defaults to jobs so outliving attached-job sessions float to In progress by default; legacy true→all, false→off. Settings Display gets a 3-way control. Co-authored-by: Cursor --- docs/guide/session-jobs.md | 12 ++ .../SessionList.directory-action.test.tsx | 70 ++++++++---- web/src/components/SessionList.tsx | 67 ++++++++--- .../settings/SettingsPrimitives.tsx | 10 +- .../hooks/usePinInProgressSessions.test.ts | 28 +++++ web/src/hooks/usePinInProgressSessions.ts | 105 +++++++++++++----- web/src/lib/locales/en.ts | 5 +- web/src/lib/locales/zh-CN.ts | 5 +- web/src/routes/settings/display.tsx | 15 ++- web/src/routes/settings/index.test.tsx | 7 +- 10 files changed, 254 insertions(+), 70 deletions(-) create mode 100644 web/src/hooks/usePinInProgressSessions.test.ts diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 47048b1951..5516ae5a6e 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -161,6 +161,18 @@ Needs a hub/CLI build that includes `job` (soup / feat — global npm releases m Primary running job is enriched onto `GET /api/sessions` as `attachedJob` and pushed on `session-updated` SSE patches. +## Sidebar pin (Settings → Display) + +Attached jobs ship with a **tri-state** "Pin in-progress sessions" control (not a yes/no): + +| Mode | Floats to In progress | +|------|------------------------| +| Off | Nothing | +| Long-running jobs (default) | Sessions with a running attached job (even when the agent is idle) | +| All activity | Jobs **plus** thinking / pending / in-agent background tasks | + +Unset preference defaults to **Long-running jobs** — that is the product stand for this capability. Legacy `true` maps to All activity; legacy `false` maps to Off. + ## Related - [Supported Agents](./agents.md) — flavors and resume diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index fb49c666fa..9aaa00ce79 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -13,7 +13,8 @@ const SEARCH_PLACEHOLDER = 'Search title/path/Agent/machine/ID…' afterEach(() => { cleanup() localStorage.removeItem('hapi-session-preview-limit') - localStorage.removeItem('hapi-pin-in-progress-sessions') + // Explicit off — unset now defaults to `jobs` (session-attached jobs stand). + localStorage.setItem('hapi-pin-in-progress-sessions', 'off') }) function makeSession(overrides: Partial & { id: string }): SessionSummary { @@ -392,7 +393,7 @@ describe('SessionList collapse behavior', () => { } it('keeps a selected running path collapsed across live session-list refreshes', async () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const baseSessions = [ makeSession({ id: 'session-running', @@ -430,6 +431,7 @@ describe('SessionList collapse behavior', () => { }) it('leaves active sessions in directory groups when pin-in-progress is off', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'off') const sessions = [ makeSession({ id: 'session-running', @@ -453,8 +455,8 @@ describe('SessionList collapse behavior', () => { expect(screen.getByTitle('/work/hapi').nextElementSibling?.getAttribute('data-open')).toBe('true') }) - it('pins active sessions into In progress when the preference is on', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + it('pins active sessions into In progress when mode is all activity', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', @@ -478,7 +480,7 @@ describe('SessionList collapse behavior', () => { }) it('keeps project-pinned active sessions in their project group when the preference is on', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-pinned-running', @@ -502,6 +504,7 @@ describe('SessionList collapse behavior', () => { }) it('keeps In progress above project-pin groups; project pin stays first inside its group', () => { + // Legacy true → all (tri-state); floater under In progress; project folders stay below. localStorage.setItem('hapi-pin-in-progress-sessions', 'true') const sessions = [ makeSession({ @@ -553,6 +556,41 @@ describe('SessionList collapse behavior', () => { expect(screen.getByRole('button', { name: /Unpinned floater/ })).toBeInTheDocument() }) + it('pins idle sessions with a running attachedJob when mode is jobs (default)', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'jobs') + const sessions = [ + makeSession({ + id: 'session-beets', + active: false, + updatedAt: 100, + metadata: { path: '/music', name: 'Music drain', flavor: 'claude' }, + attachedJob: { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 12, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1, + }, + }), + makeSession({ + id: 'session-thinking', + active: true, + thinking: true, + updatedAt: 90, + metadata: { path: '/work/hapi', name: 'Thinking agent', flavor: 'codex' }, + }), + ] + render(renderSessionList(sessions, null)) + + expect(screen.getByTitle('In progress')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Music drain/ })).toBeInTheDocument() + // Thinking agent is not a long-running job — stays in directory under jobs mode. + expect(screen.getByTitle('/work/hapi')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Thinking agent/ })).toBeInTheDocument() + }) + it('does not label quiet active sessions as Idle', () => { const sessions = [ makeSession({ @@ -569,8 +607,8 @@ describe('SessionList collapse behavior', () => { expect(screen.queryByTitle('Idle')).toBeNull() }) - it('keeps quiet active sessions in the Active section when pin-in-progress is on', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + it('keeps quiet active sessions in directory groups when pin-in-progress is on', () => { + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', @@ -598,17 +636,11 @@ describe('SessionList collapse behavior', () => { expect(screen.getByTitle('In progress')).toBeInTheDocument() expect(screen.getByText(/Running \(1\)/)).toBeInTheDocument() expect(screen.getByText(/pending \(1\)/)).toBeInTheDocument() - // Quiet active sessions float into their own Active section (finished - // executing, still connected) instead of falling into directory groups. - expect(screen.getByTitle('Active sessions')).toBeInTheDocument() - expect(screen.getByText(/Active \(1\)/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /Quiet task/ })).toBeInTheDocument() - // The directory header survives as an action-only header (copy-path / - // new-session-in-directory) even though every row floated. + expect(screen.queryByText(/Idle \(/)).toBeNull() + // Quiet active stays under its project directory, not an Idle pin bucket. expect(screen.getByTitle('/work/hapi')).toBeInTheDocument() - expect(screen.getByTitle('/work/hapi').nextElementSibling).toBeNull() - expect(screen.getByTitle('/work/other')).toBeInTheDocument() - expect(screen.getByTitle('/work/other').nextElementSibling).toBeNull() + expect(screen.getByRole('button', { name: /Quiet task/ })).toBeInTheDocument() + expect(getProjectPanel().getAttribute('data-open')).toBe('true') }) it('keeps new-session-in-directory actions for projects whose rows all floated', () => { @@ -743,7 +775,7 @@ describe('SessionList collapse behavior', () => { }) it('keeps the running section open while searching even when collapsed', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', @@ -781,7 +813,7 @@ describe('SessionList collapse behavior', () => { }) it('toggles the running section with the keyboard', () => { - localStorage.setItem('hapi-pin-in-progress-sessions', 'true') + localStorage.setItem('hapi-pin-in-progress-sessions', 'all') const sessions = [ makeSession({ id: 'session-running', diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 7829de0fd6..157eee9c81 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -25,7 +25,10 @@ import { useTranslation } from '@/lib/use-translation' import { DEFAULT_SESSION_PREVIEW_LIMIT, useSessionPreviewLimit } from '@/hooks/useSessionPreviewLimit' import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' import { useShowActiveSessionsOnly } from '@/hooks/useShowActiveSessionsOnly' -import { usePinInProgressSessions } from '@/hooks/usePinInProgressSessions' +import { + usePinInProgressSessions, + type PinInProgressMode +} from '@/hooks/usePinInProgressSessions' import { classifySessionAttention, sessionIsUnread } from '@/lib/sessionAttention' import { getSessionLastSeenAt, @@ -72,14 +75,34 @@ const RUNNING_BUCKETS = [ type RunningBucketKey = (typeof RUNNING_BUCKETS)[number]['key'] +function hasRunningAttachedJob(session: SessionSummary): boolean { + return session.attachedJob?.status === 'running' +} + +function hasAgentInProgressActivity(session: SessionSummary): boolean { + if (!session.active) { + return false + } + return session.thinking + || (session.backgroundTaskCount ?? 0) > 0 + || (session.pendingRequestsCount ?? 0) > 0 +} + /** - * Sessions that warrant the optional pinned top sections. - * Any connected session floats — a session that just finished executing stays - * visible at the top (Active tier) because the operator usually continues the - * conversation; only disconnected sessions fall into directory groups. + * Sessions that float into the pinned In progress section. + * Mode is a degree: off → jobs (outliving attachedJob) → all (jobs + agent activity). */ -function isPinnedInProgressSession(session: SessionSummary): boolean { - return session.active +export function isPinnedInProgressSession( + session: SessionSummary, + mode: PinInProgressMode +): boolean { + if (mode === 'off') { + return false + } + if (mode === 'jobs') { + return hasRunningAttachedJob(session) + } + return hasRunningAttachedJob(session) || hasAgentInProgressActivity(session) } export type SessionTimeRange = { @@ -1207,7 +1230,7 @@ export function SessionList(props: { const lastSeenVersion = useSessionLastSeenVersion() // Transient unread lens — not a Settings preference. Cleared on reload; rows drop as they're seen. const [showUnreadOnly, setShowUnreadOnly] = useState(false) - const { pinInProgressSessions } = usePinInProgressSessions() + const { pinInProgressMode } = usePinInProgressSessions() const { machineFilter, setMachineFilter } = useSessionListMachineFilter() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') @@ -1323,19 +1346,25 @@ export function SessionList(props: { pending: [], active: [], } - if (!pinInProgressSessions) { + if (pinInProgressMode === 'off') { return buckets } for (const session of machineFilteredSessions) { + // Durable pins stay in their own sections / project groups (#1115). if (session.globalPinned || session.pinned) { continue } - if (!session.active) { + if (!isPinnedInProgressSession(session, pinInProgressMode)) { continue } - if (session.thinking || (session.backgroundTaskCount ?? 0) > 0) { + const agentWorking = session.active + && (session.thinking || (session.backgroundTaskCount ?? 0) > 0) + const agentPending = session.active + && (session.pendingRequestsCount ?? 0) > 0 + && !agentWorking + if (agentWorking || hasRunningAttachedJob(session)) { buckets.working.push(session) - } else if ((session.pendingRequestsCount ?? 0) > 0) { + } else if (agentPending) { buckets.pending.push(session) } else { // Quiet but connected: finished executing, operator will continue. @@ -1347,7 +1376,7 @@ export function SessionList(props: { buckets[key].sort(byRecent) } return buckets - }, [machineFilteredSessions, pinInProgressSessions]) + }, [machineFilteredSessions, pinInProgressMode]) const runningSessionTotal = runningSessions.working.length + runningSessions.pending.length const activeSessionTotal = runningSessions.active.length @@ -1355,11 +1384,19 @@ export function SessionList(props: { () => groupSessionsByDirectory( machineFilteredSessions.filter((session) => { if (session.globalPinned) return false - if (pinInProgressSessions && !session.pinned && isPinnedInProgressSession(session)) return false + // Project-pinned stay in the project group; only unpinned + // "in progress" sessions float to the In progress section. + if ( + pinInProgressMode !== 'off' + && !session.pinned + && isPinnedInProgressSession(session, pinInProgressMode) + ) { + return false + } return true }) ), - [machineFilteredSessions, pinInProgressSessions] + [machineFilteredSessions, pinInProgressMode] ) // Directory groups whose rows all floated to the pinned sections still // render an action-only header so copy-path / new-session-in-directory diff --git a/web/src/components/settings/SettingsPrimitives.tsx b/web/src/components/settings/SettingsPrimitives.tsx index 4bc32f91c4..e60655720d 100644 --- a/web/src/components/settings/SettingsPrimitives.tsx +++ b/web/src/components/settings/SettingsPrimitives.tsx @@ -82,9 +82,15 @@ export function SettingsChoiceGroup(props: { value: T options: ReadonlyArray<{ value: T; label: string; description?: string }> onChange: (value: T) => void - columns?: 2 | 4 | 5 + columns?: 2 | 3 | 4 | 5 }) { - const columns = props.columns === 5 ? 'grid-cols-5' : props.columns === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2' + const columns = props.columns === 5 + ? 'grid-cols-5' + : props.columns === 4 + ? 'grid-cols-2 sm:grid-cols-4' + : props.columns === 3 + ? 'grid-cols-3' + : 'grid-cols-2' return (
diff --git a/web/src/hooks/usePinInProgressSessions.test.ts b/web/src/hooks/usePinInProgressSessions.test.ts new file mode 100644 index 0000000000..d008ee0a43 --- /dev/null +++ b/web/src/hooks/usePinInProgressSessions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_PIN_IN_PROGRESS_MODE, + parsePinInProgressMode +} from './usePinInProgressSessions' + +describe('parsePinInProgressMode', () => { + it('defaults unset to jobs (capability stand)', () => { + expect(parsePinInProgressMode(null)).toBe('jobs') + expect(parsePinInProgressMode('')).toBe('jobs') + expect(DEFAULT_PIN_IN_PROGRESS_MODE).toBe('jobs') + }) + + it('migrates legacy boolean strings', () => { + expect(parsePinInProgressMode('true')).toBe('all') + expect(parsePinInProgressMode('false')).toBe('off') + }) + + it('accepts explicit tri-state values', () => { + expect(parsePinInProgressMode('off')).toBe('off') + expect(parsePinInProgressMode('jobs')).toBe('jobs') + expect(parsePinInProgressMode('all')).toBe('all') + }) + + it('falls back to jobs on garbage', () => { + expect(parsePinInProgressMode('maybe')).toBe('jobs') + }) +}) diff --git a/web/src/hooks/usePinInProgressSessions.ts b/web/src/hooks/usePinInProgressSessions.ts index 8f3bfc225a..ec1886dc7b 100644 --- a/web/src/hooks/usePinInProgressSessions.ts +++ b/web/src/hooks/usePinInProgressSessions.ts @@ -1,10 +1,24 @@ import { useCallback, useEffect, useState } from 'react' -export const DEFAULT_PIN_IN_PROGRESS_SESSIONS = false +/** + * Sidebar "In progress" pin policy (ships with session-attached jobs #1404). + * + * Degree of float, not a yes/no: + * - off — everything stays in project directories + * - jobs — only sessions with a running attachedJob (outliving work) + * - all — jobs + agent working/pending (legacy maximalist pin) + * + * Unset / never configured defaults to `jobs` — the product stand for this capability. + */ -function getPinInProgressSessionsStorageKey(): string { - return 'hapi-pin-in-progress-sessions' -} +export type PinInProgressMode = 'off' | 'jobs' | 'all' + +export const PIN_IN_PROGRESS_MODES: readonly PinInProgressMode[] = ['off', 'jobs', 'all'] as const + +/** New default when the preference has never been set. */ +export const DEFAULT_PIN_IN_PROGRESS_MODE: PinInProgressMode = 'jobs' + +export const PIN_IN_PROGRESS_STORAGE_KEY = 'hapi-pin-in-progress-sessions' function isBrowser(): boolean { return typeof window !== 'undefined' && typeof document !== 'undefined' @@ -32,33 +46,60 @@ function safeSetItem(key: string, value: string): void { } } -function safeRemoveItem(key: string): void { - if (!isBrowser()) { - return +/** + * Parse stored value. + * - absent / null → `jobs` (capability default) + * - legacy `true` → `all` + * - legacy `false` → `off` + * - `off` | `jobs` | `all` → as written + */ +export function parsePinInProgressMode(raw: string | null): PinInProgressMode { + if (raw === null || raw === '') { + return DEFAULT_PIN_IN_PROGRESS_MODE } - try { - localStorage.removeItem(key) - } catch { - // Ignore storage errors + if (raw === 'true') { + return 'all' + } + if (raw === 'false') { + return 'off' } + if (raw === 'off' || raw === 'jobs' || raw === 'all') { + return raw + } + return DEFAULT_PIN_IN_PROGRESS_MODE } -function parsePinInProgressSessions(raw: string | null): boolean { - if (raw === 'true') { - return true - } - return DEFAULT_PIN_IN_PROGRESS_SESSIONS +export function getInitialPinInProgressMode(): PinInProgressMode { + return parsePinInProgressMode(safeGetItem(PIN_IN_PROGRESS_STORAGE_KEY)) } +/** @deprecated Use getInitialPinInProgressMode — boolean form treated `all` as true. */ export function getInitialPinInProgressSessions(): boolean { - return parsePinInProgressSessions(safeGetItem(getPinInProgressSessionsStorageKey())) + return getInitialPinInProgressMode() !== 'off' +} + +export function getPinInProgressModeOptions(): ReadonlyArray<{ + value: PinInProgressMode + labelKey: string +}> { + return [ + { value: 'off', labelKey: 'settings.display.pinInProgressMode.off' }, + { value: 'jobs', labelKey: 'settings.display.pinInProgressMode.jobs' }, + { value: 'all', labelKey: 'settings.display.pinInProgressMode.all' }, + ] } export function usePinInProgressSessions(): { + pinInProgressMode: PinInProgressMode + setPinInProgressMode: (value: PinInProgressMode) => void + /** True when mode is not off (any pin bucket may show). */ pinInProgressSessions: boolean + /** @deprecated Prefer setPinInProgressMode. `true`→all, `false`→off. */ setPinInProgressSessions: (value: boolean) => void } { - const [pinInProgressSessions, setPinInProgressSessionsState] = useState(getInitialPinInProgressSessions) + const [pinInProgressMode, setPinInProgressModeState] = useState( + getInitialPinInProgressMode + ) useEffect(() => { if (!isBrowser()) { @@ -66,25 +107,31 @@ export function usePinInProgressSessions(): { } const onStorage = (event: StorageEvent) => { - if (event.key !== getPinInProgressSessionsStorageKey()) { + if (event.key !== PIN_IN_PROGRESS_STORAGE_KEY) { return } - setPinInProgressSessionsState(parsePinInProgressSessions(event.newValue)) + setPinInProgressModeState(parsePinInProgressMode(event.newValue)) } window.addEventListener('storage', onStorage) return () => window.removeEventListener('storage', onStorage) }, []) - const setPinInProgressSessions = useCallback((value: boolean) => { - setPinInProgressSessionsState(value) - - if (value === DEFAULT_PIN_IN_PROGRESS_SESSIONS) { - safeRemoveItem(getPinInProgressSessionsStorageKey()) - } else { - safeSetItem(getPinInProgressSessionsStorageKey(), String(value)) - } + const setPinInProgressMode = useCallback((value: PinInProgressMode) => { + setPinInProgressModeState(value) + // Always persist so an explicit Off is distinct from never-set→jobs default + // after the user has opened Settings and chosen. + safeSetItem(PIN_IN_PROGRESS_STORAGE_KEY, value) }, []) - return { pinInProgressSessions, setPinInProgressSessions } + const setPinInProgressSessions = useCallback((value: boolean) => { + setPinInProgressMode(value ? 'all' : 'off') + }, [setPinInProgressMode]) + + return { + pinInProgressMode, + setPinInProgressMode, + pinInProgressSessions: pinInProgressMode !== 'off', + setPinInProgressSessions + } } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index e1e51a96fc..cf78a5aac2 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -844,7 +844,10 @@ export default { 'settings.display.activeSessionsOnly': 'Active sessions only', 'settings.display.activeSessionsOnly.desc': 'Hide inactive sessions in the sidebar. The session you have open stays visible.', 'settings.display.pinInProgressSessions': 'Pin in-progress sessions', - 'settings.display.pinInProgressSessions.desc': 'Move unpinned connected sessions into sections above project folders: running and pending work first, then quiet active sessions (finished executing, still connected). Global pins remain above them. Off keeps everything in directory groups.', + 'settings.display.pinInProgressSessions.desc': 'How loudly sessions float to the top In progress section above project folders. Default is long-running jobs (outliving batch work with a session job meter). Global pins remain above it; quiet active agents stay in project folders unless you choose All activity. Off keeps everything in directory groups.', + 'settings.display.pinInProgressMode.off': 'Off', + 'settings.display.pinInProgressMode.jobs': 'Long-running jobs', + 'settings.display.pinInProgressMode.all': 'All activity', 'settings.display.sessionListStatus': 'Session list status hints', 'settings.display.sessionListStatus.standard': 'Basic', 'settings.display.sessionListStatus.detailed': 'Extended', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 94ffbad9dc..3289ed24fe 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -843,7 +843,10 @@ export default { 'settings.display.activeSessionsOnly': '仅显示活跃会话', 'settings.display.activeSessionsOnly.desc': '在侧边栏隐藏非活跃会话;当前打开的会话仍会保留显示。', 'settings.display.pinInProgressSessions': '置顶进行中会话', - 'settings.display.pinInProgressSessions.desc': '将未置顶的已连接会话移到项目文件夹上方的分区:先运行中和待处理,再是安静的活跃会话(已执行完但仍在连接)。全局置顶仍在其上。关闭后全部保留在目录分组中。', + 'settings.display.pinInProgressSessions.desc': '侧边栏顶部「进行中」分区的置顶程度(位于项目目录分组之上)。默认为长时间任务(带会话 job 进度的后台批处理)。全局置顶仍在该分区之上;安静的活跃智能体仍留在项目目录中,除非选择「全部活动」。关闭后全部保留在目录分组中。', + 'settings.display.pinInProgressMode.off': '关闭', + 'settings.display.pinInProgressMode.jobs': '长时间任务', + 'settings.display.pinInProgressMode.all': '全部活动', 'settings.display.sessionListStatus': '会话列表状态提示', 'settings.display.sessionListStatus.standard': '基础', 'settings.display.sessionListStatus.detailed': '扩展', diff --git a/web/src/routes/settings/display.tsx b/web/src/routes/settings/display.tsx index 11682b9ba6..a0f136beaf 100644 --- a/web/src/routes/settings/display.tsx +++ b/web/src/routes/settings/display.tsx @@ -137,7 +137,7 @@ export default function SettingsDisplayPage() { const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize() const { sessionListStatusMode, setSessionListStatusMode } = useSessionListStatusMode() const { showActiveSessionsOnly, setShowActiveSessionsOnly } = useShowActiveSessionsOnly() - const { pinInProgressSessions, setPinInProgressSessions } = usePinInProgressSessions() + const { pinInProgressMode, setPinInProgressMode } = usePinInProgressSessions() const { preferences: sessionHeaderMetadata, setPreference: setSessionHeaderMetadata } = useSessionHeaderMetadata() const sessionHeaderOptions: ReadonlyArray<{ key: SessionHeaderMetadataKey; labelKey: string }> = [ { key: 'showLabels', labelKey: 'settings.display.sessionHeader.showLabels' }, @@ -174,7 +174,18 @@ export default function SettingsDisplayPage() { - + ({ })) vi.mock('@/hooks/usePinInProgressSessions', () => ({ - usePinInProgressSessions: () => ({ pinInProgressSessions: false, setPinInProgressSessions: vi.fn() }), + usePinInProgressSessions: () => ({ + pinInProgressMode: 'off' as const, + setPinInProgressMode: vi.fn(), + pinInProgressSessions: false, + setPinInProgressSessions: vi.fn(), + }), })) vi.mock('@/hooks/useSessionHeaderMetadata', () => ({ From b3505aed27297c07114c5113e9d0d2a28a0df80f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:37:05 +0000 Subject: [PATCH 096/168] fix(jobs): cold-review pass 3 Blocker + Majors (#1404) CI: bump migration tip expects to SCHEMA 22; stub list attachedJobs mocks; align Codex/OpenCode/ACP fixtures with job steer + session_job tool list. Merge: write jobsAccepted/Transferred redirects after metadata merge so a name/summary copy cannot clobber the post-merge job owner map. MCP: session_job is own-session only (drop sessionId from schema) so auto-approve cannot silently write peer meters. Prefer hapi job run in steer. Co-authored-by: Cursor --- cli/src/codex/utils/appServerConfig.test.ts | 20 ++++++------- .../permission/BasePermissionHandler.ts | 5 ++-- .../modules/common/sessionJobInstruction.ts | 11 +++---- .../modules/sessionJob/sessionJobMcp.test.ts | 10 ++++++- cli/src/modules/sessionJob/sessionJobMcp.ts | 30 +++++++++---------- cli/src/opencode/utils/systemPrompt.test.ts | 2 +- hub/src/sync/sessionCache-merge-jobs.test.ts | 29 ++++++++++++++++++ hub/src/sync/sessionCache.ts | 17 +++++++---- hub/src/web/routes/sessions.test.ts | 5 +++- web/src/components/SessionList.tsx | 9 ++++-- .../hooks/usePinInProgressSessions.test.ts | 22 +++++++++++++- web/src/hooks/usePinInProgressSessions.ts | 10 ++++++- 12 files changed, 124 insertions(+), 46 deletions(-) diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index a2f794e7f5..c43cf43709 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -7,7 +7,7 @@ import { codexCollaborationSpawnAgentInstructions, supportsReasoningSummary } from './appServerConfig'; -import { codexSystemPrompt } from './systemPrompt'; +import { getCodexSystemPrompt } from './systemPrompt'; describe('appServerConfig', () => { const mcpServers = { hapi: { command: 'node', args: ['mcp'] } }; @@ -56,7 +56,7 @@ describe('appServerConfig', () => { command: 'node', args: ['mcp'] }, - developer_instructions: codexSystemPrompt + developer_instructions: getCodexSystemPrompt() }); }); @@ -98,7 +98,7 @@ describe('appServerConfig', () => { } } }, - developer_instructions: codexSystemPrompt + developer_instructions: getCodexSystemPrompt() }); }); @@ -167,7 +167,7 @@ describe('appServerConfig', () => { command: 'node', args: ['mcp'] }, - developer_instructions: `${codexSystemPrompt}\n\nOnly respond in Chinese.` + developer_instructions: `${getCodexSystemPrompt()}\n\nOnly respond in Chinese.` }); }); @@ -183,7 +183,7 @@ describe('appServerConfig', () => { command: 'node', args: ['mcp'] }, - developer_instructions: codexSystemPrompt, + developer_instructions: getCodexSystemPrompt(), model_reasoning_effort: 'ultra' }); }); @@ -332,7 +332,7 @@ describe('appServerConfig', () => { settings: { model: 'o3', reasoning_effort: 'high', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); expect(params.model).toBeUndefined(); @@ -358,7 +358,7 @@ describe('appServerConfig', () => { settings: { model: 'gpt-5.3-codex-spark', reasoning_effort: 'high', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); }); @@ -522,7 +522,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'o3', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); }); @@ -542,7 +542,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'o3', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); }); @@ -561,7 +561,7 @@ describe('appServerConfig', () => { mode: 'default', settings: { model: 'gpt-5', - developer_instructions: withCollaborationInstructions(codexSystemPrompt) + developer_instructions: withCollaborationInstructions(getCodexSystemPrompt()) } }); expect(params.model).toBeUndefined(); diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 27924f0388..62bb54e75e 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -38,7 +38,8 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'mcp__hapi__list_peers', // ACP permission requests often surface MCP tool title, not the snake_case name. 'list peer sessions', - // Own-session progress meter (tiann/hapi#1404) — hub REST only, not peer inject. + // Own-session progress meter (tiann/hapi#1404) — MCP schema has no sessionId; + // tool always targets this chat. Cross-session writes use CLI hapi job (not auto). 'session_job', 'hapi_session_job', 'happy__session_job', @@ -49,7 +50,7 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ // resume+inject into another session or read peer histories, so permission // modes must still gate them. Treat both as write-like in read-only so ACP // titles such as "Ping Peer Session" / "Inspect Peer Session" also require -// approval. list_peers / session_job are auto-approved above. +// approval. list_peers / own-session session_job are auto-approved above. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; const SENSITIVE_TOOL_NAME_HINTS = [ 'ping_peer', diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index c400615455..aaec5f96d7 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -14,11 +14,12 @@ export const SESSION_JOB_INSTRUCTION = [ '(batch imports, long scripts, external daemons), attach a session job so', 'the session list shows progress while you are idle — same class of HAPI', 'tooling as ping_peer / inspect_peer.', - 'Prefer MCP tool session_job (aliases: mcp__hapi__session_job, hapi_session_job,', - 'functions.hapi__session_job) with action=set before the process starts, then', - 'action=update every ~10m (idle agents cannot heartbeat).', - 'For a supervised shell child: hapi job run "$HAPI_SESSION_ID" ', - '--label -- … (auto-heartbeats + completed/failed on exit).', + 'Prefer supervised CLI for process-shaped work (idle agents cannot heartbeat):', + 'hapi job run "$HAPI_SESSION_ID" --label -- …', + '(auto-heartbeats + completed/failed on exit).', + 'Manual path: MCP tool session_job (aliases: mcp__hapi__session_job,', + 'hapi_session_job, functions.hapi__session_job) action=set, then action=update', + 'every ~10m from a self-heartbeating wrapper — never set-once and walk away.', 'Prefer honest remaining or done+total; omit counts when unknown', '(UI shows "running" + elapsed). Never invent a fake percent.', 'Full contract: hapi job --help.' diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts index 87ac653dc6..b9a4fe95de 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.test.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -44,13 +44,21 @@ describe('sessionJobMcp', () => { expect(SESSION_JOB_TOOL_DESCRIPTION).toContain('hapi job run') }) - it('set requires label and defaults session to caller id', async () => { + it('set requires label and always targets the caller session id', async () => { + const { setSessionJob } = await import('./sessionJob') const result = await handleSessionJobTool( { action: 'set', jobKey: 'beets', label: 'beets import', remaining: 12 }, 'sid-1' ) expect(result.isError).toBe(false) expect(result.text).toContain('set beets') + expect(setSessionJob).toHaveBeenCalledWith( + expect.objectContaining({ sessionIdPrefix: 'sid-1' }) + ) + }) + + it('description claims own-session only', () => { + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) }) it('rejects update with empty patch', async () => { diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index 519f064a9b..7f689f89bd 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -20,26 +20,23 @@ export const SESSION_JOB_TOOL_NAME = 'session_job' * Write for selection, not for humans browsing a README. */ export const SESSION_JOB_TOOL_DESCRIPTION = [ - 'Attach or update a hub-persisted progress meter on a HAPI session for work that', + 'Attach or update a hub-persisted progress meter on THIS HAPI session for work that', 'OUTLIVES this agent turn (nohup / batch import / rclone / compile / long drain /', - 'external daemon). The session list shows the meter while the agent is idle', - '(active:false). Call action=set BEFORE starting that process (or immediately when', - 'you start it). Heartbeat with action=update at least every ~10 minutes while it', - 'runs — an idle agent cannot. Prefer honest remaining or done+total; omit counts', - 'when unknown (UI shows "running" + elapsed). Never invent a percent or ETA.', - 'Finish with action=update status=completed|failed or action=clear.', - 'Default sessionId is this chat ($HAPI_SESSION_ID). Not for in-agent todos,', - 'thinking progress, or short tool calls. For a supervised shell child that', - 'auto-heartbeats, prefer CLI: hapi job run "$HAPI_SESSION_ID" --label … -- .', + 'external daemon). Own-session only (auto-approved) — not for injecting meters onto', + 'peer sessions (use CLI hapi job for that). The session list shows the meter while', + 'the agent is idle (active:false). Prefer CLI for process-shaped work:', + 'hapi job run "$HAPI_SESSION_ID" --label … -- (auto-heartbeats).', + 'Manual: action=set BEFORE starting the process, then action=update at least every', + '~10 minutes from a self-heartbeating wrapper — an idle agent cannot. Prefer honest', + 'remaining or done+total; omit counts when unknown (UI shows "running" + elapsed).', + 'Never invent a percent or ETA. Finish with action=update status=completed|failed', + 'or action=clear. Not for in-agent todos, thinking progress, or short tool calls.', ].join(' ') export const sessionJobInputSchema: z.ZodTypeAny = z.object({ action: z.enum(['set', 'update', 'clear', 'list']).describe( 'set=register/upsert running job; update=heartbeat/progress/status; clear=remove; list=show jobs' ), - sessionId: z.string().trim().min(1).optional().describe( - 'Target session id or prefix. Omit to use this chat ($HAPI_SESSION_ID).' - ), jobKey: z.string().trim().min(1).max(128).optional().describe( 'Stable job key (alnum . _ -). Required for set/update/clear.' ), @@ -61,7 +58,6 @@ export const sessionJobInputSchema: z.ZodTypeAny = z.object({ export type SessionJobToolArgs = { action: 'set' | 'update' | 'clear' | 'list' - sessionId?: string jobKey?: string label?: string status?: 'running' | 'completed' | 'failed' @@ -88,10 +84,12 @@ export async function handleSessionJobTool( args: SessionJobToolArgs, defaultSessionId: string ): Promise<{ text: string; isError: boolean }> { - const sessionIdPrefix = (args.sessionId?.trim() || defaultSessionId || process.env.HAPI_SESSION_ID || '').trim() + // Own-session only — sessionId is not in the schema so auto-approve cannot + // become a silent cross-session write (cold-review pass 3 Major). + const sessionIdPrefix = (defaultSessionId || process.env.HAPI_SESSION_ID || '').trim() if (!sessionIdPrefix) { return { - text: 'sessionId required (or set HAPI_SESSION_ID / call from a HAPI-wrapped session)', + text: 'own session id required (set HAPI_SESSION_ID / call from a HAPI-wrapped session)', isError: true } } diff --git a/cli/src/opencode/utils/systemPrompt.test.ts b/cli/src/opencode/utils/systemPrompt.test.ts index 483c901075..0adc0654f7 100644 --- a/cli/src/opencode/utils/systemPrompt.test.ts +++ b/cli/src/opencode/utils/systemPrompt.test.ts @@ -26,7 +26,7 @@ describe('OpenCode local HAPI instructions', () => { const instructions = await readFile(instructionsPath, 'utf8') expect(instructions).toContain('$name') expect(instructions).toContain('skill_lookup') - expect(instructions).toContain('hapi job set') + expect(instructions).toContain('hapi job run') expect(instructions).toContain(TITLE_INSTRUCTION.trim()) }) }) diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 9171a12fbc..5b814cb418 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -73,6 +73,35 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) }) + it('keeps jobsAcceptedFromSessionIds when metadata merge also copies name from old', async () => { + const { store, cache } = setup() + const oldSession = cache.getOrCreateSession( + 'agent-jobs-named-old-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex', name: 'Lidarr drain' }, + null, + 'default' + ) + const newSession = cache.getOrCreateSession( + 'agent-jobs-named-new-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 4 + }) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed?.metadata?.name).toBe('Lidarr drain') + expect(refreshed?.metadata?.jobsAcceptedFromSessionIds).toContain(oldSession.id) + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + }) + it('keeps jobsTransferredToSessionId on a kept-alive source after mergeSessionHistory', async () => { const { store, cache } = setup() const { oldSession, newSession } = makeSessions(cache) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 82bfb0199e..924e908b5f 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1161,12 +1161,6 @@ export class SessionCache { const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) if (movedJobs.moved > 0 || movedJobs.collided > 0) { - // Agents keep addressing $HAPI_SESSION_ID from the pre-merge row. - // Record redirects so job REST routes can follow the live job owner. - this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) - if (!options.deleteOldSession) { - this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) - } this.emitAttachedJobChanged( newSessionId, this.store.sessionJobs.getPrimaryRunning(newSessionId) @@ -1242,6 +1236,17 @@ export class SessionCache { } } + // Job-owner redirects AFTER metadata merge. Writing them before the + // merge clobbers jobsAcceptedFromSessionIds when mergeSessionMetadata + // rebuilds from the stale pre-merge newStored.metadata snapshot + // (cold-review pass 3 Major — agents heartbeating $HAPI_SESSION_ID 404). + if (movedJobs.moved > 0 || movedJobs.collided > 0) { + this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) + if (!options.deleteOldSession) { + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) + } + } + if (newStored.model === null && oldStored.model !== null) { const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { touchUpdatedAt: false diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index d690e0521c..056a23676c 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -164,7 +164,8 @@ function createApp(session: Session, opts?: { forkConversation: opts?.forkConversation ?? (async () => ({ type: 'success', sessionId: 'child-1' })), rewindConversation: opts?.rewindConversation ?? (async () => ({ type: 'success' })), suggestSessionTitle: opts?.suggestSessionTitle ?? (async () => 'Generated title'), - updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}) + updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}), + getPrimaryAttachedJobsBySessionIds: opts?.getPrimaryAttachedJobsBySessionIds ?? (() => new Map()) } as Partial const app = new Hono() @@ -1516,6 +1517,7 @@ describe('sessions routes', () => { return new Map(ids.map((id) => [id, 0])) }, getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), + getPrimaryAttachedJobsBySessionIds: () => new Map(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial @@ -1548,6 +1550,7 @@ describe('sessions routes', () => { getSessionsByNamespace: () => sessions, getFutureScheduledMessageCounts: (ids: string[]) => new Map(ids.map((id) => [id, 0])), getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), + getPrimaryAttachedJobsBySessionIds: () => new Map(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 157eee9c81..d8bddf9b1b 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -275,9 +275,14 @@ export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessi // "Active sessions only" view: hide inactive sessions, but never hide the one the // operator currently has open — otherwise toggling the filter would yank the -// selected session out from under them. +// selected session out from under them. Idle sessions with a running attached +// job stay visible too — that is the headline use case for session jobs. export function filterActiveSessionsOnly(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { - return sessions.filter(session => session.active || session.id === selectedSessionId) + return sessions.filter(session => + session.active + || session.id === selectedSessionId + || hasRunningAttachedJob(session) + ) } // Transient unread lens: hide sessions the operator has already seen. diff --git a/web/src/hooks/usePinInProgressSessions.test.ts b/web/src/hooks/usePinInProgressSessions.test.ts index d008ee0a43..e6acff9ca4 100644 --- a/web/src/hooks/usePinInProgressSessions.test.ts +++ b/web/src/hooks/usePinInProgressSessions.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { DEFAULT_PIN_IN_PROGRESS_MODE, + PIN_IN_PROGRESS_STORAGE_KEY, + getInitialPinInProgressMode, parsePinInProgressMode } from './usePinInProgressSessions' @@ -26,3 +28,21 @@ describe('parsePinInProgressMode', () => { expect(parsePinInProgressMode('maybe')).toBe('jobs') }) }) + +describe('getInitialPinInProgressMode', () => { + afterEach(() => { + localStorage.removeItem(PIN_IN_PROGRESS_STORAGE_KEY) + }) + + it('persists jobs default so later Off is distinct from unset', () => { + localStorage.removeItem(PIN_IN_PROGRESS_STORAGE_KEY) + expect(getInitialPinInProgressMode()).toBe('jobs') + expect(localStorage.getItem(PIN_IN_PROGRESS_STORAGE_KEY)).toBe('jobs') + }) + + it('rewrites legacy false to persisted off', () => { + localStorage.setItem(PIN_IN_PROGRESS_STORAGE_KEY, 'false') + expect(getInitialPinInProgressMode()).toBe('off') + expect(localStorage.getItem(PIN_IN_PROGRESS_STORAGE_KEY)).toBe('off') + }) +}) diff --git a/web/src/hooks/usePinInProgressSessions.ts b/web/src/hooks/usePinInProgressSessions.ts index ec1886dc7b..1c6c1dc356 100644 --- a/web/src/hooks/usePinInProgressSessions.ts +++ b/web/src/hooks/usePinInProgressSessions.ts @@ -70,7 +70,15 @@ export function parsePinInProgressMode(raw: string | null): PinInProgressMode { } export function getInitialPinInProgressMode(): PinInProgressMode { - return parsePinInProgressMode(safeGetItem(PIN_IN_PROGRESS_STORAGE_KEY)) + const raw = safeGetItem(PIN_IN_PROGRESS_STORAGE_KEY) + const mode = parsePinInProgressMode(raw) + // Persist so explicit Off is distinguishable from never-set→jobs. + // Legacy true/false also rewrite to all/off (upstream removed the key on false, + // so those users already look like unset — product stand maps them to jobs). + if (raw === null || raw === '' || raw === 'true' || raw === 'false') { + safeSetItem(PIN_IN_PROGRESS_STORAGE_KEY, mode) + } + return mode } /** @deprecated Use getInitialPinInProgressMode — boolean form treated `all` as true. */ From 17ffa2cf8cfb7fe81df6f4e76109bc009ed092fc Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:52:17 +0000 Subject: [PATCH 097/168] =?UTF-8?q?fix(jobs):=20close=20pass-3=20minors=20?= =?UTF-8?q?=E2=80=94=20stable=20primary,=20own-client=20heartbeats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve JWT/session once for job run heartbeats; order primary job by started_at ASC; cover job redirect 404 + invalid key/body; idle jobs get a Jobs bucket (not Running); treat attachedJob.startedAt as render-relevant. Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 30 +++++-- cli/src/modules/sessionJob/runSessionJob.ts | 40 +++++---- cli/src/modules/sessionJob/sessionJob.ts | 33 ++++++-- hub/src/store/migration-v25.test.ts | 11 +++ hub/src/store/sessionJobs.ts | 7 +- hub/src/web/routes/sessions-jobs.test.ts | 82 +++++++++++++++++++ .../SessionList.directory-action.test.tsx | 1 + web/src/components/SessionList.tsx | 10 ++- web/src/hooks/useSSE.test.ts | 17 ++++ web/src/hooks/useSSE.ts | 1 + web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + 12 files changed, 197 insertions(+), 37 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 66e444a09d..c1b3fc4240 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -2,14 +2,18 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import { runSessionJob } from './runSessionJob' -function fakeChild(exitCode: number) { +function fakeChild(exitCode: number, deferExit = false) { const child = new EventEmitter() as EventEmitter & { pid: number killed: boolean + exit: () => void } child.pid = 4242 child.killed = false - queueMicrotask(() => child.emit('exit', exitCode, null)) + child.exit = () => child.emit('exit', exitCode, null) + if (!deferExit) { + queueMicrotask(() => child.exit()) + } return child } @@ -50,7 +54,8 @@ describe('runSessionJob', () => { } const timers: Array<() => void> = [] - const exitCode = await runSessionJob({ + const child = fakeChild(0, true) + const running = runSessionJob({ sessionIdPrefix: 'aaaa', jobKey: 'drain', label: 'drain', @@ -59,7 +64,7 @@ describe('runSessionJob', () => { accessToken: 'token', apiUrl: 'http://127.0.0.1:3006', http: http as never, - spawnImpl: (() => fakeChild(0)) as never, + spawnImpl: (() => child) as never, setIntervalImpl: ((fn: () => void) => { timers.push(fn) return 1 as unknown as NodeJS.Timeout @@ -67,11 +72,24 @@ describe('runSessionJob', () => { clearIntervalImpl: (() => undefined) as never }) + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + // Heartbeat ticks reuse resolved client (no extra auth). + expect(timers.length).toBe(1) + timers[0]!() + await vi.waitFor(() => expect(http.patch).toHaveBeenCalled()) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + child.exit() + const exitCode = await running expect(exitCode).toBe(0) - expect(http.put).toHaveBeenCalled() - expect(http.patch).toHaveBeenCalled() const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } expect(lastPatch.status).toBe('completed') + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) }) it('marks failed on non-zero exit', async () => { diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index f654b1fcd8..232cdd8163 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -7,9 +7,11 @@ import { spawn, type ChildProcess } from 'node:child_process' import type { AttachedJobUpsert } from '@hapi/protocol' import { SessionJobError, + resolveSessionJobClient, setSessionJob, updateSessionJob, - type SessionJobClientOptions + type SessionJobClientOptions, + type SessionJobResolvedClient } from './sessionJob' export type RunSessionJobOptions = SessionJobClientOptions & { @@ -45,13 +47,18 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { void updateSessionJob({ - sessionIdPrefix: options.sessionIdPrefix, + ...clientOpts, jobKey: options.jobKey, body: { detail: options.detail, status: 'running' - }, - apiUrl: options.apiUrl, - accessToken: options.accessToken, - http: options.http + } }).catch(() => { // Best-effort — exit path still marks terminal status. }) @@ -101,12 +105,9 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise( - options: SessionJobClientOptions, - fn: (ctx: { apiUrl: string; jwt: string; sessionId: string; http: AxiosInstance }) => Promise -): Promise { +/** One JWT + session id for the life of a supervised job. */ +export async function resolveSessionJobClient( + options: SessionJobClientOptions +): Promise { + if (options.resolved) { + return options.resolved + } const http = options.http ?? axios const apiUrl = resolveApiUrl(options.apiUrl) const accessToken = resolveAccessToken(options.accessToken) const jwt = await exchangeJwt(apiUrl, accessToken, http) const sessionId = await resolveSessionId(apiUrl, jwt, http, options.sessionIdPrefix) - return fn({ apiUrl, jwt, sessionId, http }) + return { apiUrl, jwt, sessionId } +} + +async function withClient( + options: SessionJobClientOptions, + fn: (ctx: SessionJobResolvedClient & { http: AxiosInstance }) => Promise +): Promise { + const http = options.http ?? axios + const resolved = await resolveSessionJobClient(options) + return fn({ ...resolved, http }) } export async function listSessionJobs( diff --git a/hub/src/store/migration-v25.test.ts b/hub/src/store/migration-v25.test.ts index 890d7f7b09..bd7ba5cfa4 100644 --- a/hub/src/store/migration-v25.test.ts +++ b/hub/src/store/migration-v25.test.ts @@ -42,6 +42,17 @@ describe('Store V25→V26 migration: session_jobs table', () => { expect(primary?.key).toBe('beets') expect(primary?.remaining).toBe(100) + // Stable primary: earliest started_at wins even after a newer job heartbeats. + store.sessionJobs.upsert(session.id, 'newer', { + label: 'sidecar', + status: 'running', + remaining: 1, + startedAt: (primary!.startedAt) + 60_000 + }) + store.sessionJobs.patch(session.id, 'newer', { remaining: 0 }) + expect(store.sessionJobs.getPrimaryRunning(session.id)?.key).toBe('beets') + expect(store.sessionJobs.delete(session.id, 'newer')).toBe(true) + const patched = store.sessionJobs.patch(session.id, 'beets', { remaining: 80 }) expect(patched?.remaining).toBe(80) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index fb933bef19..644fc9415a 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -83,13 +83,13 @@ export function getSessionJob( return row ? toStored(row) : null } -/** Newest `running` job for a session, or null. */ +/** Earliest-started `running` job for a session, or null (stable list chrome). */ export function getPrimaryRunningJob(db: Database, sessionId: string): StoredSessionJob | null { const row = db.prepare( `SELECT ${JOB_COLUMNS} FROM session_jobs WHERE session_id = ? AND status = 'running' - ORDER BY updated_at DESC, job_key ASC + ORDER BY started_at ASC, job_key ASC LIMIT 1` ).get(sessionId) as DbJobRow | undefined return row ? toStored(row) : null @@ -111,10 +111,11 @@ export function getPrimaryRunningJobsBySessionIds( `SELECT ${JOB_COLUMNS} FROM session_jobs WHERE status = 'running' AND session_id IN (${placeholders}) - ORDER BY updated_at DESC, job_key ASC` + ORDER BY started_at ASC, job_key ASC` ).all(...sessionIds) as DbJobRow[] for (const row of rows) { + // First row per session wins — earliest started_at (stable primary). if (result.has(row.session_id)) continue result.set(row.session_id, toAttachedJob(toStored(row))) } diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index 87a0161b5d..8622a34de1 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -127,4 +127,86 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { expect(listed.jobs).toEqual([]) expect(listed.primary).toBeNull() }) + + it('follows jobsAccepted redirect when the pre-merge session id 404s', async () => { + const owner = createSession({ id: '22222222-2222-2222-2222-222222222222' }) + const deletedId = '11111111-1111-1111-1111-111111111111' + const jobs = new Map() + jobs.set('beets', { + key: 'beets', + label: 'beets import', + status: 'running', + remaining: 3, + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + }) + + const engine = { + resolveSessionAccess: (id: string) => { + if (id === owner.id) { + return { ok: true as const, sessionId: owner.id, session: owner } + } + return { ok: false as const, reason: 'not-found' as const } + }, + resolveAttachedJobSessionId: (id: string) => (id === deletedId ? owner.id : id), + listSessionJobs: (sid: string) => (sid === owner.id ? [...jobs.values()] : []), + getPrimaryAttachedJob: (sid: string) => (sid === owner.id ? jobs.get('beets')! : null), + upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), + patchSessionJob: () => null, + deleteSessionJob: () => false + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + + const res = await app.request(`http://localhost/api/sessions/${deletedId}/jobs`) + expect(res.status).toBe(200) + const body = await res.json() as { primary: AttachedJob | null } + expect(body.primary?.key).toBe('beets') + }) + + it('rejects invalid jobKey and invalid upsert body with 400', async () => { + const session = createSession() + const engine = { + resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), + resolveAttachedJobSessionId: (id: string) => id, + listSessionJobs: () => [], + getPrimaryAttachedJob: () => null, + upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), + patchSessionJob: () => null, + deleteSessionJob: () => false + } as unknown as SyncEngine + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine)) + + const badKey = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/bad key!`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: 'x' }) + } + ) + expect(badKey.status).toBe(400) + + const badBody = await app.request( + `http://localhost/api/sessions/${session.id}/jobs/ok-key`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ remaining: 1 }) + } + ) + expect(badBody.status).toBe(400) + }) }) diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 9aaa00ce79..3b5cb89be1 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -585,6 +585,7 @@ describe('SessionList collapse behavior', () => { render(renderSessionList(sessions, null)) expect(screen.getByTitle('In progress')).toBeInTheDocument() + expect(screen.getByText(/Jobs \(1\)/)).toBeInTheDocument() expect(screen.getByRole('button', { name: /Music drain/ })).toBeInTheDocument() // Thinking agent is not a long-running job — stays in directory under jobs mode. expect(screen.getByTitle('/work/hapi')).toBeInTheDocument() diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index d8bddf9b1b..7a675f6ed1 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -68,6 +68,7 @@ type SessionGroup = { } const RUNNING_BUCKETS = [ + { key: 'jobs', labelKey: 'session.item.attachedJob', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true }, { key: 'working', labelKey: 'session.item.running', colorClass: 'text-[var(--app-badge-success-text)]', pulse: true }, { key: 'pending', labelKey: 'session.item.pending', colorClass: 'text-[var(--app-badge-warning-text)]', pulse: true }, { key: 'active', labelKey: 'session.item.active', colorClass: 'text-[var(--app-hint)]', pulse: false }, @@ -1347,6 +1348,7 @@ export function SessionList(props: { }, [machineFilteredSessions]) const runningSessions = useMemo(() => { const buckets: Record = { + jobs: [], working: [], pending: [], active: [], @@ -1367,8 +1369,11 @@ export function SessionList(props: { const agentPending = session.active && (session.pendingRequestsCount ?? 0) > 0 && !agentWorking - if (agentWorking || hasRunningAttachedJob(session)) { + if (agentWorking) { buckets.working.push(session) + } else if (hasRunningAttachedJob(session)) { + // Idle outliving work — not "Running" agent activity. + buckets.jobs.push(session) } else if (agentPending) { buckets.pending.push(session) } else { @@ -1382,7 +1387,8 @@ export function SessionList(props: { } return buckets }, [machineFilteredSessions, pinInProgressMode]) - const runningSessionTotal = runningSessions.working.length + const runningSessionTotal = runningSessions.jobs.length + + runningSessions.working.length + runningSessions.pending.length const activeSessionTotal = runningSessions.active.length const groups = useMemo( diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index 7917422acb..3a6592e631 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -326,6 +326,23 @@ describe('isRenderIrrelevantPatch', () => { expect(isRenderIrrelevantPatch(current, next)).toBe(false) }) + + it('reports attachedJob.startedAt changes as relevant', () => { + const job = { + key: 'beets', + label: 'beets', + status: 'running' as const, + heartbeatAt: 100, + startedAt: 100, + updatedAt: 100 + } + const current = makeSummary({ attachedJob: job }) + const next = makeSummary({ + attachedJob: { ...job, startedAt: 50 }, + activeAt: 11_000 + }) + expect(isRenderIrrelevantPatch(current, next)).toBe(false) + }) }) describe('isRenderIrrelevantSessionPatch', () => { diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 96d8964777..ba0390f053 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -167,6 +167,7 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu && current.attachedJob?.unit === next.attachedJob?.unit && current.attachedJob?.detail === next.attachedJob?.detail && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt + && current.attachedJob?.startedAt === next.attachedJob?.startedAt && (current.attachedJob == null) === (next.attachedJob == null) && current.model === next.model && current.modelReasoningEffort === next.modelReasoningEffort diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index cf78a5aac2..b0d8cb2ddf 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -178,6 +178,7 @@ export default { 'session.item.thinking': 'thinking', 'session.item.running': 'Running', 'session.item.active': 'Active', + 'session.item.attachedJob': 'Jobs', 'session.item.permission': 'Permission required', 'session.item.needsInput': 'Needs input', 'session.item.background': 'Background tasks running', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 3289ed24fe..6260be3563 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -178,6 +178,7 @@ export default { 'session.item.thinking': '思考中', 'session.item.running': '运行中', 'session.item.active': '活跃', + 'session.item.attachedJob': '长时间任务', 'session.item.permission': '需要权限', 'session.item.needsInput': '需要输入', 'session.item.background': '后台任务运行中', From d4e8e53e801d980fc9fb6e3bb5e947db02ba5c99 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:03:31 +0000 Subject: [PATCH 098/168] fix(jobs): refresh JWT for long job-run heartbeats (#1404) Cache sessionId once but re-exchange JWT before hub's 4h expiry and on 401 retry so days-long supervised jobs keep heartbeating and can still mark completed/failed. Log the first heartbeat failure to stderr. Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 86 +++++++++ cli/src/modules/sessionJob/runSessionJob.ts | 21 ++- cli/src/modules/sessionJob/sessionJob.ts | 176 ++++++++++++------ 3 files changed, 224 insertions(+), 59 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index c1b3fc4240..5ff45b2658 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -144,4 +144,90 @@ describe('runSessionJob', () => { const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } expect(lastPatch.status).toBe('failed') }) + + it('re-exchanges JWT on heartbeat 401 and still marks completed (hub 4h expiry)', async () => { + let jwtIssue = 0 + let patchCalls = 0 + const http = { + post: vi.fn(async () => { + jwtIssue += 1 + return { status: 200, data: { token: `jwt-${jwtIssue}` } } + }), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }, cfg?: { headers?: Record }) => { + patchCalls += 1 + const auth = cfg?.headers?.Authorization ?? '' + // First heartbeat still carries jwt-1 after hub expiry → 401. + if (patchCalls === 1 && auth.includes('jwt-1')) { + return { status: 401, data: { error: 'expired' } } + } + return { + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + } + }) + } + + const timers: Array<() => void> = [] + const child = fakeChild(0, true) + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const running = runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + heartbeatMs: 10, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => child) as never, + setIntervalImpl: ((fn: () => void) => { + timers.push(fn) + return 1 as unknown as NodeJS.Timeout + }) as never, + clearIntervalImpl: (() => undefined) as never + }) + + await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + timers[0]!() + await vi.waitFor(() => expect(http.post).toHaveBeenCalledTimes(2)) + await vi.waitFor(() => expect(http.patch.mock.calls.length).toBeGreaterThanOrEqual(2)) + // Session list not re-fetched — only JWT refresh. + expect(http.get).toHaveBeenCalledTimes(1) + + child.exit() + const exitCode = await running + expect(exitCode).toBe(0) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('completed') + errSpy.mockRestore() + }) }) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 232cdd8163..1345399f56 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -47,11 +47,14 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { void updateSessionJob({ ...clientOpts, @@ -79,8 +83,14 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { - // Best-effort — exit path still marks terminal status. + }).catch((error: unknown) => { + // Best-effort — exit path still marks terminal status. Log once so + // a broken supervisor is visible (stuck chip with dead PID is worse). + if (!loggedHeartbeatFailure) { + loggedHeartbeatFailure = true + const message = error instanceof Error ? error.message : String(error) + console.error(`[hapi job run] heartbeat failed (will keep trying): ${message}`) + } }) }, heartbeatMs) // Don't keep the event loop alive solely for heartbeats if child already exited. @@ -134,8 +144,9 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { @@ -196,43 +205,89 @@ export async function resolveSessionJobClient( const accessToken = resolveAccessToken(options.accessToken) const jwt = await exchangeJwt(apiUrl, accessToken, http) const sessionId = await resolveSessionId(apiUrl, jwt, http, options.sessionIdPrefix) - return { apiUrl, jwt, sessionId } + return { apiUrl, jwt, sessionId, jwtIssuedAtMs: Date.now() } +} + +async function refreshSessionJobJwt( + resolved: SessionJobResolvedClient, + options: SessionJobClientOptions +): Promise { + const http = options.http ?? axios + const accessToken = resolveAccessToken(options.accessToken) + resolved.jwt = await exchangeJwt(resolved.apiUrl, accessToken, http) + resolved.jwtIssuedAtMs = Date.now() +} + +async function ensureFreshJwt( + resolved: SessionJobResolvedClient, + options: SessionJobClientOptions, + force = false +): Promise { + const age = Date.now() - resolved.jwtIssuedAtMs + if (force || age >= SESSION_JOB_JWT_REFRESH_AFTER_MS) { + await refreshSessionJobJwt(resolved, options) + } } -async function withClient( +type AuthedResponse = { status: number; data?: unknown } + +/** + * Run an authed request; on 401 re-exchange JWT (keep cached sessionId) and retry once. + */ +async function withAuthedRequest( options: SessionJobClientOptions, - fn: (ctx: SessionJobResolvedClient & { http: AxiosInstance }) => Promise + request: (ctx: { apiUrl: string; jwt: string; sessionId: string; http: AxiosInstance }) => Promise, + handle: (response: AuthedResponse, sessionId: string) => T ): Promise { const http = options.http ?? axios const resolved = await resolveSessionJobClient(options) - return fn({ ...resolved, http }) + await ensureFreshJwt(resolved, options) + + const run = async () => request({ + apiUrl: resolved.apiUrl, + jwt: resolved.jwt, + sessionId: resolved.sessionId, + http + }) + + let response = await run() + if (response.status === 401) { + await ensureFreshJwt(resolved, options, true) + response = await run() + } + return handle(response, resolved.sessionId) } export async function listSessionJobs( options: SessionJobClientOptions ): Promise<{ sessionId: string; jobs: AttachedJob[]; primary: AttachedJob | null }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.get(`${apiUrl}/api/sessions/${sessionId}/jobs`, { + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.get(`${apiUrl}/api/sessions/${sessionId}/jobs`, { headers: authHeaders(jwt), timeout: 15_000, validateStatus: () => true - }) - if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) - } - return { - sessionId, - jobs: Array.isArray(response.data?.jobs) ? response.data.jobs : [], - primary: response.data?.primary ?? null + }), + (response, sessionId) => { + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) + } + const data = response.data as { jobs?: AttachedJob[]; primary?: AttachedJob | null } | undefined + return { + sessionId, + jobs: Array.isArray(data?.jobs) ? data.jobs : [], + primary: data?.primary ?? null + } } - }) + ) } export async function setSessionJob( options: SessionJobClientOptions & { jobKey: string; body: AttachedJobUpsert } ): Promise<{ sessionId: string; job: AttachedJob }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.put( + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.put( `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, options.body, { @@ -240,25 +295,29 @@ export async function setSessionJob( timeout: 15_000, validateStatus: () => true } - ) - if (response.status === 404) { - throw new SessionJobError('not_found', 'session or job not found') - } - if (response.status < 200 || response.status >= 300 || !response.data?.job) { - const detail = typeof response.data?.error === 'string' - ? response.data.error - : `HTTP ${response.status}` - throw new SessionJobError('request_failed', `set job failed: ${detail}`) + ), + (response, sessionId) => { + if (response.status === 404) { + throw new SessionJobError('not_found', 'session or job not found') + } + const data = response.data as { job?: AttachedJob; error?: string } | undefined + if (response.status < 200 || response.status >= 300 || !data?.job) { + const detail = typeof data?.error === 'string' + ? data.error + : `HTTP ${response.status}` + throw new SessionJobError('request_failed', `set job failed: ${detail}`) + } + return { sessionId, job: data.job } } - return { sessionId, job: response.data.job as AttachedJob } - }) + ) } export async function updateSessionJob( options: SessionJobClientOptions & { jobKey: string; body: AttachedJobPatch } ): Promise<{ sessionId: string; job: AttachedJob }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.patch( + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.patch( `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, options.body, { @@ -266,40 +325,49 @@ export async function updateSessionJob( timeout: 15_000, validateStatus: () => true } - ) - if (response.status === 404) { - throw new SessionJobError('not_found', 'job not found') - } - if (response.status < 200 || response.status >= 300 || !response.data?.job) { - const detail = typeof response.data?.error === 'string' - ? response.data.error - : `HTTP ${response.status}` - throw new SessionJobError('request_failed', `update job failed: ${detail}`) + ), + (response, sessionId) => { + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + const data = response.data as { job?: AttachedJob; error?: string } | undefined + if (response.status < 200 || response.status >= 300 || !data?.job) { + const detail = typeof data?.error === 'string' + ? data.error + : `HTTP ${response.status}` + if (response.status === 401) { + throw new SessionJobError('auth_failed', `update job failed: ${detail}`) + } + throw new SessionJobError('request_failed', `update job failed: ${detail}`) + } + return { sessionId, job: data.job } } - return { sessionId, job: response.data.job as AttachedJob } - }) + ) } export async function clearSessionJob( options: SessionJobClientOptions & { jobKey: string } ): Promise<{ sessionId: string }> { - return withClient(options, async ({ apiUrl, jwt, sessionId, http }) => { - const response = await http.delete( + return withAuthedRequest( + options, + ({ apiUrl, jwt, sessionId, http }) => http.delete( `${apiUrl}/api/sessions/${sessionId}/jobs/${encodeURIComponent(options.jobKey)}`, { headers: authHeaders(jwt), timeout: 15_000, validateStatus: () => true } - ) - if (response.status === 404) { - throw new SessionJobError('not_found', 'job not found') - } - if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + ), + (response, sessionId) => { + if (response.status === 404) { + throw new SessionJobError('not_found', 'job not found') + } + if (response.status < 200 || response.status >= 300) { + throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + } + return { sessionId } } - return { sessionId } - }) + ) } export function exitCodeForSessionJobError(error: SessionJobError): number { From 61b656166d1615d81d337fb56ea76dc346e91ac1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:09:34 +0000 Subject: [PATCH 099/168] test(jobs): cover proactive JWT refresh; map 401 to auth_failed Co-authored-by: Cursor --- .../modules/sessionJob/runSessionJob.test.ts | 81 +++++++++++++++++++ cli/src/modules/sessionJob/sessionJob.ts | 31 ++++--- 2 files changed, 99 insertions(+), 13 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 5ff45b2658..15d5dbee2f 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' +import { SESSION_JOB_JWT_REFRESH_AFTER_MS, resolveSessionJobClient, updateSessionJob } from './sessionJob' import { runSessionJob } from './runSessionJob' function fakeChild(exitCode: number, deferExit = false) { @@ -230,4 +231,84 @@ describe('runSessionJob', () => { expect(lastPatch.status).toBe('completed') errSpy.mockRestore() }) + + it('proactively re-exchanges JWT after 3h without re-listing sessions', async () => { + let jwtIssue = 0 + const http = { + post: vi.fn(async () => { + jwtIssue += 1 + return { status: 200, data: { token: `jwt-${jwtIssue}` } } + }), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + patch: vi.fn(async (_url: string, _body: unknown, cfg?: { headers?: Record }) => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + }, + _auth: cfg?.headers?.Authorization + } + })) + } + + const resolved = await resolveSessionJobClient({ + sessionIdPrefix: 'aaaa', + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.get).toHaveBeenCalledTimes(1) + + // Inside the window: no second exchange. + await updateSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + body: { remaining: 9 }, + resolved, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(1) + + // Past proactive refresh threshold: exchange once, keep session id. + resolved.jwtIssuedAtMs = Date.now() - SESSION_JOB_JWT_REFRESH_AFTER_MS - 1 + await updateSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + body: { remaining: 8 }, + resolved, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(2) + expect(http.get).toHaveBeenCalledTimes(1) + expect(resolved.jwt).toBe('jwt-2') + const auth = (http.patch.mock.calls.at(-1)?.[2] as { headers?: Record } | undefined) + ?.headers?.Authorization + expect(auth).toContain('jwt-2') + + // Next tick inside the new window: no exchange storm. + await updateSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + body: { remaining: 7 }, + resolved, + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + expect(http.post).toHaveBeenCalledTimes(2) + expect(http.get).toHaveBeenCalledTimes(1) + }) }) diff --git a/cli/src/modules/sessionJob/sessionJob.ts b/cli/src/modules/sessionJob/sessionJob.ts index bedda3bcf7..54cce77646 100644 --- a/cli/src/modules/sessionJob/sessionJob.ts +++ b/cli/src/modules/sessionJob/sessionJob.ts @@ -231,6 +231,20 @@ async function ensureFreshJwt( type AuthedResponse = { status: number; data?: unknown } +function httpStatusError( + action: string, + response: AuthedResponse, + errorDetail?: string +): SessionJobError { + const detail = typeof errorDetail === 'string' && errorDetail.length > 0 + ? errorDetail + : `HTTP ${response.status}` + if (response.status === 401) { + return new SessionJobError('auth_failed', `${action} failed: ${detail}`) + } + return new SessionJobError('request_failed', `${action} failed: ${detail}`) +} + /** * Run an authed request; on 401 re-exchange JWT (keep cached sessionId) and retry once. */ @@ -270,7 +284,7 @@ export async function listSessionJobs( }), (response, sessionId) => { if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `list jobs failed: HTTP ${response.status}`) + throw httpStatusError('list jobs', response) } const data = response.data as { jobs?: AttachedJob[]; primary?: AttachedJob | null } | undefined return { @@ -302,10 +316,7 @@ export async function setSessionJob( } const data = response.data as { job?: AttachedJob; error?: string } | undefined if (response.status < 200 || response.status >= 300 || !data?.job) { - const detail = typeof data?.error === 'string' - ? data.error - : `HTTP ${response.status}` - throw new SessionJobError('request_failed', `set job failed: ${detail}`) + throw httpStatusError('set job', response, data?.error) } return { sessionId, job: data.job } } @@ -332,13 +343,7 @@ export async function updateSessionJob( } const data = response.data as { job?: AttachedJob; error?: string } | undefined if (response.status < 200 || response.status >= 300 || !data?.job) { - const detail = typeof data?.error === 'string' - ? data.error - : `HTTP ${response.status}` - if (response.status === 401) { - throw new SessionJobError('auth_failed', `update job failed: ${detail}`) - } - throw new SessionJobError('request_failed', `update job failed: ${detail}`) + throw httpStatusError('update job', response, data?.error) } return { sessionId, job: data.job } } @@ -363,7 +368,7 @@ export async function clearSessionJob( throw new SessionJobError('not_found', 'job not found') } if (response.status < 200 || response.status >= 300) { - throw new SessionJobError('request_failed', `clear job failed: HTTP ${response.status}`) + throw httpStatusError('clear job', response) } return { sessionId } } From 71d022088880943e7c46850181750dc6cc8396f8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:46:03 +0000 Subject: [PATCH 100/168] fix(jobs): stamp fresh startedAt on job run; prefer supervisor Re-running the same job key was sticky-reusing a prior row's startedAt, so elapsed lied. Drain in-flight heartbeats before terminal status. Align AGENTS/guide: CLI supervisor first, MCP as manual path. Co-authored-by: Cursor --- AGENTS.md | 9 ++++--- .../modules/sessionJob/runSessionJob.test.ts | 4 ++++ cli/src/modules/sessionJob/runSessionJob.ts | 11 ++++++++- docs/guide/session-jobs.md | 24 +++++++++---------- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b9d142433..90435935a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,11 +178,10 @@ When an agent starts process-shaped work that will keep running after the agent Agent contract (idle agents cannot heartbeat): -1. Prefer MCP `session_job` (`action=set` then `update` ≥~10m) when the HAPI MCP server is attached -2. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` for supervised shell children -3. Manual CLI only with a self-heartbeating wrapper: `set` / `update` / clear -4. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent -5. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with `set --started-at` (or clear+set) +1. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` for supervised shell children (auto-heartbeat + exit status) +2. Manual path only with a self-heartbeating wrapper: MCP `session_job` or CLI `set` / `update` ≥~10m / clear +3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent +4. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with `set --started-at` (or clear+set) Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 15d5dbee2f..9b70ebc6a2 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -74,6 +74,10 @@ describe('runSessionJob', () => { }) await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) + const putBody = http.put.mock.calls[0]?.[1] as { startedAt?: number; status?: string } + expect(putBody.status).toBe('running') + expect(typeof putBody.startedAt).toBe('number') + expect(putBody.startedAt).toBeGreaterThan(0) expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 1345399f56..ceb7202b34 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -40,6 +40,9 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise = Promise.resolve() const heartbeat = setIntervalFn(() => { - void updateSessionJob({ + inflightHeartbeat = updateSessionJob({ ...clientOpts, jobKey: options.jobKey, body: { @@ -113,6 +117,7 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise((resolve) => { child.on('error', async (error) => { clearIntervalFn(heartbeat) + await inflightHeartbeat.catch(() => undefined) try { await updateSessionJob({ ...clientOpts, @@ -137,6 +142,10 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise undefined) + const terminalStatus = exitCode === 0 ? 'completed' : 'failed' try { await updateSessionJob({ diff --git a/docs/guide/session-jobs.md b/docs/guide/session-jobs.md index 5516ae5a6e..7dbbdf28b9 100644 --- a/docs/guide/session-jobs.md +++ b/docs/guide/session-jobs.md @@ -37,18 +37,7 @@ If the operator would reopen the chat only to ask "how's it doing?", it belongs Treat this like `ping_peer` / `inspect_peer`: it is first-class HAPI tooling, not a docs footnote. -### MCP (preferred for agents) - -Tool name: `session_job` (Claude: `mcp__hapi__session_job`; Codex: `functions.hapi__session_job`; OpenCode/ACP: `hapi_session_job`). - -```json -{ "action": "set", "jobKey": "beets", "label": "beets import", - "remaining": 150, "done": 1637, "total": 1787, "unit": "units" } -``` - -Then `action=update` every ~10 minutes; finish with `status=completed|failed` or `action=clear`. Omit `sessionId` to target this chat. - -### CLI supervisor (preferred for shell children) +### CLI supervisor (preferred for process-shaped work) ```bash hapi job run "$HAPI_SESSION_ID" beets \ @@ -60,6 +49,17 @@ hapi job run "$HAPI_SESSION_ID" beets \ `hapi job run` registers the job, heartbeats on a timer while the child runs, then marks `completed`/`failed` from the exit code. An idle agent **cannot** heartbeat - set-once + manual update decays to amber. +### MCP (manual path when the child is not CLI-supervised) + +Tool name: `session_job` (Claude: `mcp__hapi__session_job`; Codex: `functions.hapi__session_job`; OpenCode/ACP: `hapi_session_job`). + +```json +{ "action": "set", "jobKey": "beets", "label": "beets import", + "remaining": 150, "done": 1637, "total": 1787, "unit": "units" } +``` + +Then `action=update` every ~10 minutes; finish with `status=completed|failed` or `action=clear`. Omit `sessionId` to target this chat. + ### CLI manual path ```bash From 2df6c0f2e5bab670fa236ee3d5659358642b25ad Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:47:16 +0000 Subject: [PATCH 101/168] fix(jobs): typecheck-safe assertion for run startedAt Co-authored-by: Cursor --- cli/src/modules/sessionJob/runSessionJob.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 9b70ebc6a2..4f78dceb3c 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -74,10 +74,10 @@ describe('runSessionJob', () => { }) await vi.waitFor(() => expect(http.put).toHaveBeenCalled()) - const putBody = http.put.mock.calls[0]?.[1] as { startedAt?: number; status?: string } - expect(putBody.status).toBe('running') - expect(typeof putBody.startedAt).toBe('number') - expect(putBody.startedAt).toBeGreaterThan(0) + expect(http.put.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ + status: 'running', + startedAt: expect.any(Number) + })) expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) From 535726866a192beb39b0668356cfab67770cfd60 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:48:10 +0000 Subject: [PATCH 102/168] fix(jobs): type put mock args so startedAt assertion typechecks Co-authored-by: Cursor --- cli/src/modules/sessionJob/runSessionJob.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index 4f78dceb3c..a1bcc6acd0 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -26,15 +26,15 @@ describe('runSessionJob', () => { status: 200, data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } })), - put: vi.fn(async () => ({ + put: vi.fn(async (_url: string, body: { status?: string; startedAt?: number }) => ({ status: 200, data: { job: { key: 'drain', label: 'drain', - status: 'running', + status: body.status ?? 'running', heartbeatAt: 1, - startedAt: 1, + startedAt: body.startedAt ?? 1, updatedAt: 1 } } From 0cceeab634231bf83c67ec9844a33f1e9985e290 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:49:32 +0000 Subject: [PATCH 103/168] =?UTF-8?q?fix(jobs):=20address=20Codex=20Majors?= =?UTF-8?q?=20=E2=80=94=20redirect=20ancestry,=20heartbeat=20status,=20ACP?= =?UTF-8?q?=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherit jobsAcceptedFromSessionIds across A→B→C merges; heartbeat PATCH omits status so late ticks cannot resurrect running; drop session_job from global name-only auto-approve (bridge/--allowedTools remain the trust path). Co-authored-by: Cursor --- .../permission/BasePermissionHandler.test.ts | 5 ++- .../permission/BasePermissionHandler.ts | 14 +++---- .../modules/sessionJob/runSessionJob.test.ts | 5 ++- cli/src/modules/sessionJob/runSessionJob.ts | 7 ++-- hub/src/sync/sessionCache-merge-jobs.test.ts | 39 +++++++++++++++++++ hub/src/sync/sessionCache.ts | 19 ++++++++- 6 files changed, 71 insertions(+), 18 deletions(-) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 3e8ea63108..ebd73f8b37 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -108,7 +108,8 @@ describe('resolveToolAutoApprovalDecision session_job', () => { 'hapi_session_job', 'mcp__hapi__session_job', 'Session-Attached Job' - ])('auto-approves own-session job meter %s', (toolName) => { - expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBe('approved') + ])('does not name-only auto-approve spoofable job tool %s', (toolName) => { + // Bridge / --allowedTools own the approve path; global title allowlist must not. + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBeNull() }) }) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 62bb54e75e..639b782feb 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -37,20 +37,16 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([ 'happy__list_peers', 'mcp__hapi__list_peers', // ACP permission requests often surface MCP tool title, not the snake_case name. - 'list peer sessions', - // Own-session progress meter (tiann/hapi#1404) — MCP schema has no sessionId; - // tool always targets this chat. Cross-session writes use CLI hapi job (not auto). - 'session_job', - 'hapi_session_job', - 'happy__session_job', - 'mcp__hapi__session_job', - 'session-attached job' + 'list peer sessions' ]); +// session_job is intentionally NOT in this name-only allowlist: ACP titles are +// spoofable (tool-name derivation prefers backend title). Auto-approve only via +// trusted HAPI bridge config (Codex tools map / Claude --allowedTools). // ping_peer / inspect_peer intentionally omitted from always-approve: they can // resume+inject into another session or read peer histories, so permission // modes must still gate them. Treat both as write-like in read-only so ACP // titles such as "Ping Peer Session" / "Inspect Peer Session" also require -// approval. list_peers / own-session session_job are auto-approved above. +// approval. list_peers stays auto-approved above. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; const SENSITIVE_TOOL_NAME_HINTS = [ 'ping_peer', diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index a1bcc6acd0..fe52cdda90 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -81,10 +81,13 @@ describe('runSessionJob', () => { expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) - // Heartbeat ticks reuse resolved client (no extra auth). + // Heartbeat ticks reuse resolved client (no extra auth) and must not + // send status:running (late heartbeat must not resurrect after exit). expect(timers.length).toBe(1) timers[0]!() await vi.waitFor(() => expect(http.patch).toHaveBeenCalled()) + const heartbeatBody = http.patch.mock.calls[0]?.[1] as { status?: string } + expect(heartbeatBody.status).toBeUndefined() expect(http.post).toHaveBeenCalledTimes(1) expect(http.get).toHaveBeenCalledTimes(1) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index ceb7202b34..238ef301c0 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -80,13 +80,12 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise = Promise.resolve() const heartbeat = setIntervalFn(() => { + // Never PATCH status:running on the heartbeat — a late in-flight + // request must not resurrect running after the terminal write. inflightHeartbeat = updateSessionJob({ ...clientOpts, jobKey: options.jobKey, - body: { - detail: options.detail, - status: 'running' - } + body: options.detail !== undefined ? { detail: options.detail } : {} }).catch((error: unknown) => { // Best-effort — exit path still marks terminal status. Log once so // a broken supervisor is visible (stuck chip with dead PID is worse). diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 5b814cb418..83b7c2c1c6 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -73,6 +73,45 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) }) + it('preserves A→B→C jobsAccepted ancestry so deleted A still resolves on C', async () => { + const { store, cache } = setup() + const a = cache.getOrCreateSession( + 'agent-jobs-a-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + const b = cache.getOrCreateSession( + 'agent-jobs-b-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + const c = cache.getOrCreateSession( + 'agent-jobs-c-' + Math.random().toString(36).slice(2, 8), + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default' + ) + + store.sessionJobs.upsert(a.id, 'beets', { + label: 'beets import', + status: 'running', + remaining: 9 + }) + await cache.mergeSessions(a.id, b.id, 'default') + expect(cache.resolveAttachedJobSessionId(a.id, 'default')).toBe(b.id) + + await cache.mergeSessions(b.id, c.id, 'default') + const refreshed = cache.refreshSession(c.id) + expect(refreshed?.metadata?.jobsAcceptedFromSessionIds).toEqual( + expect.arrayContaining([a.id, b.id]) + ) + expect(cache.resolveAttachedJobSessionId(a.id, 'default')).toBe(c.id) + expect(cache.resolveAttachedJobSessionId(b.id, 'default')).toBe(c.id) + expect(store.sessionJobs.getPrimaryRunning(c.id)?.key).toBe('beets') + }) + it('keeps jobsAcceptedFromSessionIds when metadata merge also copies name from old', async () => { const { store, cache } = setup() const oldSession = cache.getOrCreateSession( diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 924e908b5f..7d6865b498 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1392,8 +1392,23 @@ export class SessionCache { const prev = Array.isArray(meta.jobsAcceptedFromSessionIds) ? meta.jobsAcceptedFromSessionIds.filter((id): id is string => typeof id === 'string') : [] - if (prev.includes(fromSessionId)) return - meta.jobsAcceptedFromSessionIds = [...prev, fromSessionId] + // Preserve A→B→C ancestry: when B already accepted jobs from A and + // now merges into C, clients still holding A's HAPI_SESSION_ID must + // resolve through C after B is deleted. + const inheritedRaw = this.store.sessions + .getSessionByNamespace(fromSessionId, namespace) + ?.metadata?.jobsAcceptedFromSessionIds + const inherited = Array.isArray(inheritedRaw) + ? inheritedRaw.filter((id): id is string => typeof id === 'string') + : [] + const next = [...new Set([...prev, ...inherited, fromSessionId])] + if ( + next.length === prev.length + && next.every((id) => prev.includes(id)) + ) { + return + } + meta.jobsAcceptedFromSessionIds = next const result = this.store.sessions.updateSessionMetadata( toSessionId, meta, From 6d7caf246c9d9a1fabe30994ecb76181668ba158 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:49:49 +0000 Subject: [PATCH 104/168] fix(jobs): type from-session metadata when inheriting job redirects Co-authored-by: Cursor --- hub/src/sync/sessionCache.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 7d6865b498..0b5ede3150 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1395,9 +1395,10 @@ export class SessionCache { // Preserve A→B→C ancestry: when B already accepted jobs from A and // now merges into C, clients still holding A's HAPI_SESSION_ID must // resolve through C after B is deleted. - const inheritedRaw = this.store.sessions + const fromMeta = this.store.sessions .getSessionByNamespace(fromSessionId, namespace) - ?.metadata?.jobsAcceptedFromSessionIds + ?.metadata as Record | null | undefined + const inheritedRaw = fromMeta?.jobsAcceptedFromSessionIds const inherited = Array.isArray(inheritedRaw) ? inheritedRaw.filter((id): id is string => typeof id === 'string') : [] From 6f8456959492fec87ca48d6353b33ee5ee48a8ac Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:58:42 +0000 Subject: [PATCH 105/168] fix(jobs): version attachedJob SSE; keep live job on merge collision Gate list cache on versioned attachedJob patches so dual EventSources cannot resurrect a cleared meter. On transfer key collision, prefer a running source over a terminal target (heartbeats no longer rewrite status). Co-authored-by: Cursor --- hub/src/store/migration-v23.test.ts | 24 +++++++++++++++++++ hub/src/store/sessionJobs.ts | 23 ++++++++++++++++-- hub/src/sync/sessionCache.ts | 6 ++++- shared/src/schemas.sessionPatch.test.ts | 13 +++++++--- shared/src/schemas.ts | 13 +++++++--- shared/src/sessionSummary.test.ts | 2 ++ shared/src/sessionSummary.ts | 3 +++ .../SessionAttentionIndicator.test.tsx | 1 + .../SessionList.directory-action.test.tsx | 1 + .../SessionList.machine-filter.test.tsx | 1 + web/src/components/SessionList.test.ts | 1 + web/src/hooks/useSSE.test.ts | 1 + web/src/hooks/useSSE.ts | 17 ++++++++++--- web/src/lib/sessionAttention.test.ts | 1 + web/src/lib/sessionReference.test.ts | 1 + 15 files changed, 96 insertions(+), 12 deletions(-) diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index d9027f4add..1841355a41 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -46,4 +46,28 @@ describe('schema migration v22 to v26', () => { expect(version.user_version).toBe(26) migrated.close() }) + + it('on key collision keeps a running source over a terminal target', () => { + const store = new Store(':memory:') + const from = store.sessions.getOrCreateSession('from', { path: '/a' }, null, 'default') + const to = store.sessions.getOrCreateSession('to', { path: '/b' }, null, 'default') + store.sessionJobs.upsert(to.id, 'beets', { + label: 'stale', + status: 'completed', + remaining: 0 + }, 1_000) + store.sessionJobs.upsert(from.id, 'beets', { + label: 'live', + status: 'running', + remaining: 3 + }, 2_000) + const result = store.sessionJobs.transfer(from.id, to.id) + expect(result.collided).toBe(1) + expect(result.moved).toBe(1) + const primary = store.sessionJobs.getPrimaryRunning(to.id) + expect(primary?.label).toBe('live') + expect(primary?.status).toBe('running') + expect(store.sessionJobs.list(from.id)).toHaveLength(0) + store.close() + }) }) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index 644fc9415a..098444189f 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -249,6 +249,9 @@ export function transferSessionJobs( fromSessionId: string, toSessionId: string ): { moved: number; collided: number } { + if (fromSessionId === toSessionId) { + return { moved: 0, collided: 0 } + } const rows = listSessionJobs(db, fromSessionId) let moved = 0 let collided = 0 @@ -256,8 +259,24 @@ export function transferSessionJobs( for (const job of rows) { const existing = getSessionJob(db, toSessionId, job.key) if (existing) { - db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') - .run(fromSessionId, job.key) + // Prefer a live source over a terminal target (or newer stamp). + // Redirected heartbeats omit status, so discarding a running source + // cannot be repaired by a later heartbeat. + const sourceWins = + (job.status === 'running' && existing.status !== 'running') + || (job.status === existing.status && job.updatedAt > existing.updatedAt) + if (sourceWins) { + db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') + .run(toSessionId, job.key) + db.prepare( + `UPDATE session_jobs SET session_id = ? + WHERE session_id = ? AND job_key = ?` + ).run(toSessionId, fromSessionId, job.key) + moved += 1 + } else { + db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') + .run(fromSessionId, job.key) + } collided += 1 continue } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 0b5ede3150..1fbf4e75da 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -611,11 +611,15 @@ export class SessionCache { const namespace = cached?.namespace ?? this.store.sessions.getSession(sessionId)?.namespace if (!namespace) return + // Clear uses wall clock so it outranks any in-flight heartbeat stamp. + const version = attachedJob?.updatedAt ?? Date.now() this.publisher.emit({ type: 'session-updated', sessionId, namespace, - data: { attachedJob } satisfies SessionPatch + data: { + attachedJob: { version, value: attachedJob } + } satisfies SessionPatch }) } diff --git a/shared/src/schemas.sessionPatch.test.ts b/shared/src/schemas.sessionPatch.test.ts index 6515ea5c66..4801a53038 100644 --- a/shared/src/schemas.sessionPatch.test.ts +++ b/shared/src/schemas.sessionPatch.test.ts @@ -102,7 +102,7 @@ describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => expect(SessionPatchSchema.safeParse(fullSession).success).toBe(false); }); - it('accepts attachedJob payload or null (tiann/hapi#1404)', () => { + it('accepts versioned attachedJob payload or null (tiann/hapi#1404)', () => { const job = AttachedJobSchema.parse({ key: 'beets', label: 'beets import', @@ -114,8 +114,15 @@ describe('SessionPatchSchema structured patches (closes #884 follow-up)', () => startedAt: 1_000, updatedAt: 2_000 }) - expect(SessionPatchSchema.safeParse({ attachedJob: job }).success).toBe(true) - expect(SessionPatchSchema.safeParse({ attachedJob: null }).success).toBe(true) + expect(SessionPatchSchema.safeParse({ + attachedJob: { version: job.updatedAt, value: job } + }).success).toBe(true) + expect(SessionPatchSchema.safeParse({ + attachedJob: { version: 3_000, value: null } + }).success).toBe(true) + // Bare job / bare null are rejected — dual-SSE needs a watermark. + expect(SessionPatchSchema.safeParse({ attachedJob: job }).success).toBe(false) + expect(SessionPatchSchema.safeParse({ attachedJob: null }).success).toBe(false) }); it('rejects fake percent-only attached jobs without counters', () => { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 13246a8336..2198daaded 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -431,6 +431,14 @@ export const AttachedJobPatchSchema = z.object({ export type AttachedJobPatch = z.infer +// Dual SSE (global + per-session) has no shared delivery order. Version = +// job.updatedAt for a live primary, or Date.now() when cleared to null, so a +// lagged running heartbeat cannot resurrect a finished meter. +const VersionedAttachedJobPatchSchema = z.object({ + version: z.number(), + value: AttachedJobSchema.nullable() +}) + export const SessionPatchSchema = z.object({ active: z.boolean().optional(), thinking: z.boolean().optional(), @@ -466,9 +474,8 @@ export const SessionPatchSchema = z.object({ scratchlistUpdatedAt: z.number().optional(), // tiann/hapi#1404 — session-attached long-running jobs. Unlike // scratchlist (watermark → refetch), the list row needs the progress - // payload inline, so patches carry the primary running job (or null - // when cleared / none remain). - attachedJob: AttachedJobSchema.nullable().optional() + // payload inline. Versioned like todos so dual-SSE reorder is safe. + attachedJob: VersionedAttachedJobPatchSchema.optional() }).strict() export type SessionPatch = z.infer diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index 56ed069932..c7b0ee688a 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -330,10 +330,12 @@ describe('summary derivation helpers', () => { } const summary = toSessionSummary(makeSession(), { attachedJob: job }) expect(summary.attachedJob).toEqual(job) + expect(summary.attachedJobUpdatedAt).toBe(job.updatedAt) }) it('defaults attachedJob to null', () => { expect(toSessionSummary(makeSession()).attachedJob).toBeNull() + expect(toSessionSummary(makeSession()).attachedJobUpdatedAt).toBe(0) }) it('toSessionSummaryMetadata returns null for null metadata', () => { diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 232458e455..f0313f6bb8 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -76,6 +76,8 @@ export type SessionSummary = { * Independent of agent `active` / thinking — work that outlives the agent. */ attachedJob: AttachedJob | null + /** Watermark for versioned `attachedJob` SSE patches (dual EventSource race). */ + attachedJobUpdatedAt: number model: string | null modelReasoningEffort?: string | null effort: string | null @@ -230,6 +232,7 @@ export function toSessionSummary( futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: extras?.attachedJob ?? null, + attachedJobUpdatedAt: extras?.attachedJob?.updatedAt ?? 0, model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort diff --git a/web/src/components/SessionAttentionIndicator.test.tsx b/web/src/components/SessionAttentionIndicator.test.tsx index bbf540153c..eaa0654355 100644 --- a/web/src/components/SessionAttentionIndicator.test.tsx +++ b/web/src/components/SessionAttentionIndicator.test.tsx @@ -30,6 +30,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.directory-action.test.tsx b/web/src/components/SessionList.directory-action.test.tsx index 3b5cb89be1..278374d6f6 100644 --- a/web/src/components/SessionList.directory-action.test.tsx +++ b/web/src/components/SessionList.directory-action.test.tsx @@ -35,6 +35,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.machine-filter.test.tsx b/web/src/components/SessionList.machine-filter.test.tsx index 4fc1477d44..587ede6166 100644 --- a/web/src/components/SessionList.machine-filter.test.tsx +++ b/web/src/components/SessionList.machine-filter.test.tsx @@ -30,6 +30,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 0b0404d4bf..46d40e429a 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -42,6 +42,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/hooks/useSSE.test.ts b/web/src/hooks/useSSE.test.ts index 3a6592e631..f849241eb2 100644 --- a/web/src/hooks/useSSE.test.ts +++ b/web/src/hooks/useSSE.test.ts @@ -180,6 +180,7 @@ function makeSummary(overrides: Partial = {}): SessionSummary { futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index ba0390f053..3c0ba911b6 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -169,6 +169,7 @@ export function isRenderIrrelevantPatch(current: SessionSummary, next: SessionSu && current.attachedJob?.heartbeatAt === next.attachedJob?.heartbeatAt && current.attachedJob?.startedAt === next.attachedJob?.startedAt && (current.attachedJob == null) === (next.attachedJob == null) + && (current.attachedJobUpdatedAt ?? 0) === (next.attachedJobUpdatedAt ?? 0) && current.model === next.model && current.modelReasoningEffort === next.modelReasoningEffort && current.effort === next.effort @@ -498,6 +499,7 @@ export function useSSE(options: { const summary = { ...toSessionSummary(session), attachedJob: existing?.attachedJob ?? null, + attachedJobUpdatedAt: existing?.attachedJobUpdatedAt ?? 0, futureScheduledMessageCount: existing?.futureScheduledMessageCount ?? 0, nextScheduledAt: existing?.nextScheduledAt ?? null } @@ -543,9 +545,8 @@ export function useSSE(options: { backgroundTaskCount: Object.prototype.hasOwnProperty.call(patch, 'backgroundTaskCount') ? patch.backgroundTaskCount ?? 0 : current.backgroundTaskCount, - attachedJob: Object.prototype.hasOwnProperty.call(patch, 'attachedJob') - ? patch.attachedJob ?? null - : current.attachedJob ?? null, + attachedJob: current.attachedJob ?? null, + attachedJobUpdatedAt: current.attachedJobUpdatedAt ?? 0, model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model, modelReasoningEffort: Object.prototype.hasOwnProperty.call(patch, 'modelReasoningEffort') ? patch.modelReasoningEffort ?? null @@ -571,6 +572,16 @@ export function useSSE(options: { nextSummary.metadata = toSessionSummaryMetadata(patch.metadata.value) nextSummary.metadataVersion = patch.metadata.version } + if ( + patch.attachedJob !== undefined + && isNewerVersionedPatch( + patch.attachedJob.version, + current.attachedJobUpdatedAt ?? 0 + ) + ) { + nextSummary.attachedJob = patch.attachedJob.value + nextSummary.attachedJobUpdatedAt = patch.attachedJob.version + } patched = true // The keep-alive patch repeats every field every ~10s per active diff --git a/web/src/lib/sessionAttention.test.ts b/web/src/lib/sessionAttention.test.ts index a503ac98a9..690475641b 100644 --- a/web/src/lib/sessionAttention.test.ts +++ b/web/src/lib/sessionAttention.test.ts @@ -23,6 +23,7 @@ function makeSummary(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides diff --git a/web/src/lib/sessionReference.test.ts b/web/src/lib/sessionReference.test.ts index d09ec24750..46f35f4c0d 100644 --- a/web/src/lib/sessionReference.test.ts +++ b/web/src/lib/sessionReference.test.ts @@ -28,6 +28,7 @@ function makeSession(overrides: Partial & { id: string }): Sessi futureScheduledMessageCount: 0, nextScheduledAt: null, attachedJob: null, + attachedJobUpdatedAt: 0, model: null, effort: null, ...overrides, From 55a950e8c4debbbbe387065145befd344a8e7db3 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:08:23 +0000 Subject: [PATCH 106/168] fix(jobs): monotonic attachedJob emit watermark (not primary.updatedAt) Primary switches can go backwards in updatedAt and strand the list cache. Emit versions are Math.max(Date.now(), prev+1); REST list stamps a fresh wall-clock watermark so null jobs do not reset the client gate to 0. Co-authored-by: Cursor --- hub/src/sync/sessionCache.ts | 9 +++++++-- hub/src/web/routes/sessions.ts | 6 +++++- shared/src/schemas.ts | 6 +++--- shared/src/sessionSummary.ts | 14 +++++++++++--- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 1fbf4e75da..ec0fd1bda3 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -603,6 +603,10 @@ export class SessionCache { * tiann/hapi#1404 — emit primary attached job (or null) so session-list * caches update inline without a dedicated refetch. */ + /** Monotonic emit watermark per session — never follows primary.updatedAt + * (primary switches can go backwards and would strand the web cache). */ + private attachedJobEmitVersion = new Map() + emitAttachedJobChanged( sessionId: string, attachedJob: import('@hapi/protocol').AttachedJob | null @@ -611,8 +615,9 @@ export class SessionCache { const namespace = cached?.namespace ?? this.store.sessions.getSession(sessionId)?.namespace if (!namespace) return - // Clear uses wall clock so it outranks any in-flight heartbeat stamp. - const version = attachedJob?.updatedAt ?? Date.now() + const prev = this.attachedJobEmitVersion.get(sessionId) ?? 0 + const version = Math.max(Date.now(), prev + 1) + this.attachedJobEmitVersion.set(sessionId, version) this.publisher.emit({ type: 'session-updated', sessionId, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 12b4a4beb1..74118fd3cd 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -119,9 +119,13 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) const attachedJobs = engine.getPrimaryAttachedJobsBySessionIds(sessionRecords.map((session) => session.id)) + // Fresh wall-clock watermark so a REST refetch never resets the client + // SSE gate to 0 (which would let a lagged running patch resurrect). + const listJobWatermark = Date.now() const sessions = sessionRecords.map((session) => { const summary = toSessionSummary(session, { - attachedJob: attachedJobs.get(session.id) ?? null + attachedJob: attachedJobs.get(session.id) ?? null, + attachedJobUpdatedAt: listJobWatermark }) return { ...summary, diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 2198daaded..bdd62a5820 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -431,9 +431,9 @@ export const AttachedJobPatchSchema = z.object({ export type AttachedJobPatch = z.infer -// Dual SSE (global + per-session) has no shared delivery order. Version = -// job.updatedAt for a live primary, or Date.now() when cleared to null, so a -// lagged running heartbeat cannot resurrect a finished meter. +// Dual SSE (global + per-session) has no shared delivery order. Version is a +// monotonic per-session emit watermark (not primary.updatedAt — primary +// switches can go backwards). Lagged heartbeats cannot resurrect a clear. const VersionedAttachedJobPatchSchema = z.object({ version: z.number(), value: AttachedJobSchema.nullable() diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index f0313f6bb8..252d0eaafb 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -210,8 +210,14 @@ export function toSessionSummaryMetadata(metadata: Metadata | null | undefined): export function toSessionSummary( session: Session, - extras?: { attachedJob?: AttachedJob | null } + extras?: { + attachedJob?: AttachedJob | null + /** Explicit SSE/list watermark; required when attachedJob is null so + * a REST refetch does not reset the client gate to 0. */ + attachedJobUpdatedAt?: number + } ): SessionSummary { + const attachedJob = extras?.attachedJob ?? null return { id: session.id, active: session.active, @@ -231,8 +237,10 @@ export function toSessionSummary( backgroundTaskCount: session.backgroundTaskCount ?? 0, futureScheduledMessageCount: 0, nextScheduledAt: null, - attachedJob: extras?.attachedJob ?? null, - attachedJobUpdatedAt: extras?.attachedJob?.updatedAt ?? 0, + attachedJob, + attachedJobUpdatedAt: extras?.attachedJobUpdatedAt + ?? attachedJob?.updatedAt + ?? 0, model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort From 67b299a8cb67915664aeb1989d3aeb62b958aacf Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:18:59 +0000 Subject: [PATCH 107/168] fix(jobs): one attachedJob watermark allocator for REST and SSE GET /sessions and emitAttachedJobChanged share allocateAttachedJobVersion so equal-ms terminal patches are not rejected after a list refetch. Also preserve real Unix signal exit codes in hapi job run. Co-authored-by: Cursor --- cli/src/modules/sessionJob/runSessionJob.ts | 4 +++- hub/src/sync/sessionCache.ts | 15 ++++++++++----- hub/src/sync/syncEngine.ts | 5 +++++ hub/src/web/routes/sessions.ts | 6 ++---- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 238ef301c0..512c607e68 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -4,6 +4,7 @@ */ import { spawn, type ChildProcess } from 'node:child_process' +import { constants as osConstants } from 'node:os' import type { AttachedJobUpsert } from '@hapi/protocol' import { SessionJobError, @@ -131,7 +132,8 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { clearIntervalFn(heartbeat) if (signal) { - resolve(128 + (signal === 'SIGINT' ? 2 : signal === 'SIGTERM' ? 15 : 1)) + const signalNumber = osConstants.signals[signal] ?? 1 + resolve(128 + signalNumber) return } resolve(code ?? 1) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index ec0fd1bda3..e8e014a955 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -603,10 +603,17 @@ export class SessionCache { * tiann/hapi#1404 — emit primary attached job (or null) so session-list * caches update inline without a dedicated refetch. */ - /** Monotonic emit watermark per session — never follows primary.updatedAt - * (primary switches can go backwards and would strand the web cache). */ + /** Monotonic watermark per session — shared by REST list snapshots and SSE + * emits so equal-ms terminal patches are not rejected after a refetch. */ private attachedJobEmitVersion = new Map() + allocateAttachedJobVersion(sessionId: string): number { + const prev = this.attachedJobEmitVersion.get(sessionId) ?? 0 + const version = Math.max(Date.now(), prev + 1) + this.attachedJobEmitVersion.set(sessionId, version) + return version + } + emitAttachedJobChanged( sessionId: string, attachedJob: import('@hapi/protocol').AttachedJob | null @@ -615,9 +622,7 @@ export class SessionCache { const namespace = cached?.namespace ?? this.store.sessions.getSession(sessionId)?.namespace if (!namespace) return - const prev = this.attachedJobEmitVersion.get(sessionId) ?? 0 - const version = Math.max(Date.now(), prev + 1) - this.attachedJobEmitVersion.set(sessionId, version) + const version = this.allocateAttachedJobVersion(sessionId) this.publisher.emit({ type: 'session-updated', sessionId, diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index c1755e04f2..90fd3d3768 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -789,6 +789,11 @@ export class SyncEngine { return this.store.sessionJobs.getPrimaryRunningBySessionIds(sessionIds) } + /** Shared REST/SSE watermark allocator for attachedJob patches. */ + allocateAttachedJobVersion(sessionId: string): number { + return this.sessionCache.allocateAttachedJobVersion(sessionId) + } + upsertSessionJob( sessionId: string, jobKey: string, diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 74118fd3cd..a5047d84fd 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -119,13 +119,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const scheduledCounts = engine.getFutureScheduledMessageCounts(sessionRecords.map((session) => session.id)) const nextScheduledAt = engine.getNextScheduledAtBySessionIds(sessionRecords.map((session) => session.id)) const attachedJobs = engine.getPrimaryAttachedJobsBySessionIds(sessionRecords.map((session) => session.id)) - // Fresh wall-clock watermark so a REST refetch never resets the client - // SSE gate to 0 (which would let a lagged running patch resurrect). - const listJobWatermark = Date.now() const sessions = sessionRecords.map((session) => { const summary = toSessionSummary(session, { attachedJob: attachedJobs.get(session.id) ?? null, - attachedJobUpdatedAt: listJobWatermark + // Same allocator as SSE emits — equal-ms terminal patches stay applyable. + attachedJobUpdatedAt: engine.allocateAttachedJobVersion(session.id) }) return { ...summary, From 155fd71c661626a90fcb034e924a03a553c75861 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:19:30 +0000 Subject: [PATCH 108/168] fix(jobs): stub allocateAttachedJobVersion in sessions route mocks Co-authored-by: Cursor --- hub/src/web/routes/sessions-jobs.test.ts | 7 +++++++ hub/src/web/routes/sessions.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index 8622a34de1..e8408b3797 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -48,6 +48,13 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { return map }, getPrimaryAttachedJob: () => [...jobs.values()].find((j) => j.status === 'running') ?? null, + allocateAttachedJobVersion: (() => { + let n = 0 + return () => { + n += 1 + return Date.now() + n + } + })(), listSessionJobs: () => [...jobs.values()], upsertSessionJob: (_sid: string, key: string, body: AttachedJobUpsert) => { const now = Date.now() diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 056a23676c..9b1c7b1d10 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -165,7 +165,8 @@ function createApp(session: Session, opts?: { rewindConversation: opts?.rewindConversation ?? (async () => ({ type: 'success' })), suggestSessionTitle: opts?.suggestSessionTitle ?? (async () => 'Generated title'), updateSessionSummary: opts?.updateSessionSummary ?? (async () => {}), - getPrimaryAttachedJobsBySessionIds: opts?.getPrimaryAttachedJobsBySessionIds ?? (() => new Map()) + getPrimaryAttachedJobsBySessionIds: opts?.getPrimaryAttachedJobsBySessionIds ?? (() => new Map()), + allocateAttachedJobVersion: opts?.allocateAttachedJobVersion ?? (() => Date.now()) } as Partial const app = new Hono() @@ -1518,6 +1519,7 @@ describe('sessions routes', () => { }, getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), getPrimaryAttachedJobsBySessionIds: () => new Map(), + allocateAttachedJobVersion: () => Date.now(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial @@ -1551,6 +1553,7 @@ describe('sessions routes', () => { getFutureScheduledMessageCounts: (ids: string[]) => new Map(ids.map((id) => [id, 0])), getNextScheduledAtBySessionIds: (_ids: string[]) => new Map(), getPrimaryAttachedJobsBySessionIds: () => new Map(), + allocateAttachedJobVersion: () => Date.now(), resolveSessionAccess: () => ({ ok: false, reason: 'not-found' as const }) } as unknown as Partial From d68370c0be68426bb1a7a4f75396fe483413082f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:29:34 +0000 Subject: [PATCH 109/168] fix(jobs): always record merge redirects even with zero jobs transferred Agents may attach the first outliving job after merge while still holding the pre-merge HAPI_SESSION_ID; redirects must exist before that first set. Co-authored-by: Cursor --- hub/src/sync/sessionCache-merge-jobs.test.ts | 22 ++++++++++++++++++++ hub/src/sync/sessionCache.ts | 10 ++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 83b7c2c1c6..5f9dbddf0f 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -73,6 +73,28 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(newSession.id, 'default')).toBe(newSession.id) }) + it('records job redirects even when the source has no jobs yet', async () => { + const { store, cache } = setup() + const { oldSession, newSession } = makeSessions(cache) + + await cache.mergeSessions(oldSession.id, newSession.id, 'default') + + expect(store.sessions.getSession(oldSession.id)).toBeNull() + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed?.metadata?.jobsAcceptedFromSessionIds).toContain(oldSession.id) + expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) + + // First job attach after merge still lands on the canonical session + // when the agent keeps the pre-merge HAPI_SESSION_ID. + const upserted = store.sessionJobs.upsert( + cache.resolveAttachedJobSessionId(oldSession.id, 'default')!, + 'late', + { label: 'late attach', status: 'running', remaining: 1 } + ) + expect(upserted.outcome).toBe('upserted') + expect(store.sessionJobs.getPrimaryRunning(newSession.id)?.key).toBe('late') + }) + it('preserves A→B→C jobsAccepted ancestry so deleted A still resolves on C', async () => { const { store, cache } = setup() const a = cache.getOrCreateSession( diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index e8e014a955..63b2d88027 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1254,11 +1254,11 @@ export class SessionCache { // merge clobbers jobsAcceptedFromSessionIds when mergeSessionMetadata // rebuilds from the stale pre-merge newStored.metadata snapshot // (cold-review pass 3 Major — agents heartbeating $HAPI_SESSION_ID 404). - if (movedJobs.moved > 0 || movedJobs.collided > 0) { - this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) - if (!options.deleteOldSession) { - this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) - } + // Always record redirects even when the source had zero jobs yet — the + // first post-merge set/update still uses retained $HAPI_SESSION_ID. + this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) + if (!options.deleteOldSession) { + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) } if (newStored.model === null && oldStored.model !== null) { From 29200692e03018e14be79c0757841babd589582e Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:37:14 +0000 Subject: [PATCH 110/168] fix(jobs): install source redirect before merge awaits; allow empty update Point jobsTransferredToSession immediately after transfer so mid-merge heartbeats follow the new owner during scratchlist I/O. Empty CLI/MCP update bodies are heartbeat-only (hub stamps heartbeatAt). Co-authored-by: Cursor --- cli/src/commands/job.ts | 4 +--- cli/src/modules/sessionJob/sessionJobMcp.ts | 7 +------ hub/src/sync/sessionCache.ts | 17 ++++++++--------- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index b91d189254..b3753a86da 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -358,9 +358,7 @@ export async function handleJobCommand(args: string[]): Promise { ...(parsed.unit !== undefined ? { unit: parsed.unit } : {}), ...(parsed.detail !== undefined ? { detail: parsed.detail } : {}) } - if (Object.keys(body).length === 0) { - throw new SessionJobError('bad_args', 'update requires at least one field') - } + // Empty body is a heartbeat-only update; hub stamps heartbeatAt. const result = await updateSessionJob({ sessionIdPrefix: parsed.sessionIdPrefix, jobKey: parsed.jobKey, diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index 7f689f89bd..e5119663b9 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -148,12 +148,7 @@ export async function handleSessionJobTool( ...(args.unit !== undefined ? { unit: args.unit } : {}), ...(args.detail !== undefined ? { detail: args.detail } : {}) } - if (Object.keys(body).length === 0) { - return { - text: 'update requires at least one of label/status/done/total/remaining/unit/detail', - isError: true - } - } + // Empty body is a heartbeat-only update; hub stamps heartbeatAt. const result = await updateSessionJob({ sessionIdPrefix, jobKey, body }) return { text: `updated ${formatJobLine(result.job)} on ${result.sessionId}`, diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 63b2d88027..f9bab689b5 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1174,6 +1174,11 @@ export class SessionCache { // promise that scratchlist survives reloads. const movedScratchlist = this.store.scratchlist.transfer(oldSessionId, newSessionId) const movedJobs = this.store.sessionJobs.transfer(oldSessionId, newSessionId) + // Install the source→target redirect BEFORE any await below. Merge can + // spend time on scratchlist attachment I/O while the old session row + // still exists; without this pointer, retained $HAPI_SESSION_ID hits + // the emptied source and terminal PATCHes 404. + this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) if (movedJobs.moved > 0 || movedJobs.collided > 0) { this.emitAttachedJobChanged( newSessionId, @@ -1250,16 +1255,10 @@ export class SessionCache { } } - // Job-owner redirects AFTER metadata merge. Writing them before the - // merge clobbers jobsAcceptedFromSessionIds when mergeSessionMetadata - // rebuilds from the stale pre-merge newStored.metadata snapshot - // (cold-review pass 3 Major — agents heartbeating $HAPI_SESSION_ID 404). - // Always record redirects even when the source had zero jobs yet — the - // first post-merge set/update still uses retained $HAPI_SESSION_ID. + // Acceptor list AFTER metadata merge (writing before clobbers when + // mergeSessionMetadata rebuilds from the stale pre-merge snapshot). + // Source transfer pointer was installed immediately after job transfer. this.recordJobsAcceptedFromSession(newSessionId, oldSessionId, namespace) - if (!options.deleteOldSession) { - this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) - } if (newStored.model === null && oldStored.model !== null) { const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { From be5d3780980cf9fb20dc3d2202b1054281a9b4e0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:48:13 +0000 Subject: [PATCH 111/168] fix(jobs): expect empty MCP update as heartbeat Co-authored-by: Cursor --- cli/src/modules/sessionJob/sessionJobMcp.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts index b9a4fe95de..3fca228405 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.test.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -61,12 +61,20 @@ describe('sessionJobMcp', () => { expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) }) - it('rejects update with empty patch', async () => { + it('treats empty update as a heartbeat-only patch', async () => { + const { updateSessionJob } = await import('./sessionJob') const result = await handleSessionJobTool( { action: 'update', jobKey: 'beets' }, 'sid-1' ) - expect(result.isError).toBe(true) - expect(result.text).toMatch(/at least one/i) + expect(result.isError).toBe(false) + expect(result.text).toContain('updated') + expect(updateSessionJob).toHaveBeenCalledWith( + expect.objectContaining({ + sessionIdPrefix: 'sid-1', + jobKey: 'beets', + body: {} + }) + ) }) }) From 9148179707671e2637d6572f2a04dec0e46bf640 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:51:23 +0000 Subject: [PATCH 112/168] fix(jobs): treat merge redirect metadata as hub-owned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI update-metadata must not forge or erase jobsAcceptedFromSessionIds / jobsTransferredToSessionId — same strip/restore as supersede/clear links. Co-authored-by: Cursor --- .../handlers/cli/sessionHandlers.test.ts | 30 +++++++++++++------ .../socket/handlers/cli/sessionHandlers.ts | 9 +++++- shared/src/schemas.ts | 1 + 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 29e011935e..3637205120 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -402,9 +402,14 @@ describe('cli session handlers', () => { expect(uuids).toEqual(['msg-1', 'msg-2']) }) - it.each(['supersededBySessionId', 'opencodeClearOperation'] as const)( + it.each([ + ['supersededBySessionId', 'foreign-session'], + ['opencodeClearOperation', { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() }], + ['jobsAcceptedFromSessionIds', ['foreign-session']], + ['jobsTransferredToSessionId', 'foreign-session'], + ] as const)( 'ignores a forged hub-owned %s addition from CLI metadata', - (field) => { + (field, forged) => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('forged-clear-link', { path: '/tmp/project' }, null, 'default') const socket = new FakeSocket() @@ -418,20 +423,21 @@ describe('cli session handlers', () => { expectedVersion: session.metadataVersion, metadata: { path: '/tmp/project', - [field]: field === 'supersededBySessionId' - ? 'foreign-session' - : { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() } + [field]: forged } }, () => {}) expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).not.toHaveProperty(field) } ) - it('preserves existing hub-owned clear metadata across CLI metadata updates', () => { + it('preserves existing hub-owned clear and job-redirect metadata across CLI metadata updates', () => { const store = new Store(':memory:') const operation = { replacementSessionId: 'owned-target', state: 'completed', updatedAt: Date.now() } const session = store.sessions.getOrCreateSession('preserve-clear-link', { - supersededBySessionId: 'owned-target', opencodeClearOperation: operation + supersededBySessionId: 'owned-target', + opencodeClearOperation: operation, + jobsAcceptedFromSessionIds: ['old-session'], + jobsTransferredToSessionId: 'merge-target', }, null, 'default') const socket = new FakeSocket() registerSessionHandlers(socket as unknown as CliSocketWithData, { @@ -445,11 +451,17 @@ describe('cli session handlers', () => { metadata: { lifecycleState: 'archived', supersededBySessionId: 'forged-target', - opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 } + opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 }, + jobsAcceptedFromSessionIds: ['forged-session'], + jobsTransferredToSessionId: 'forged-target', } }, () => {}) expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).toMatchObject({ - supersededBySessionId: 'owned-target', opencodeClearOperation: operation, lifecycleState: 'archived' + supersededBySessionId: 'owned-target', + opencodeClearOperation: operation, + jobsAcceptedFromSessionIds: ['old-session'], + jobsTransferredToSessionId: 'merge-target', + lifecycleState: 'archived', }) }) }) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 3a7d29ebbc..7a2526a098 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -68,7 +68,14 @@ const updateStateSchema = z.object({ agentState: z.unknown().nullable() }) -const HUB_OWNED_METADATA_KEYS = ['supersededBySessionId', 'opencodeClearOperation'] as const +// Hub-only merge/clear links. CLI update-metadata must not forge or erase them +// (same strip/restore as supersededBySessionId — see sessionHandlers.test.ts). +const HUB_OWNED_METADATA_KEYS = [ + 'supersededBySessionId', + 'opencodeClearOperation', + 'jobsAcceptedFromSessionIds', + 'jobsTransferredToSessionId', +] as const function preserveHubOwnedMetadata(incoming: unknown, current: unknown): unknown { if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return incoming diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index bdd62a5820..92db878dbe 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -123,6 +123,7 @@ export const MetadataSchema = z.object({ // pre-merge $HAPI_SESSION_ID), and a kept-alive source points at the // post-merge owner. Must be declared here — SessionCache.refreshSession // parses via MetadataSchema and strips unknown keys (tiann/hapi#1404). + // Hub-owned: CLI update-metadata cannot forge/erase (HUB_OWNED_METADATA_KEYS). jobsAcceptedFromSessionIds: z.array(z.string()).optional(), jobsTransferredToSessionId: z.string().optional(), // Durable in-progress state for runner-backed OpenCode /clear. From c216692266acd785297e8e81abf5094fdd1aa305 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:03:36 +0000 Subject: [PATCH 113/168] fix(jobs): reconcile REST list against attachedJob watermark Slow /api/sessions can finish after SSE clear/progress and resurrect a stale attachedJob. Also prefer newer terminal results on merge key collisions. Co-authored-by: Cursor --- hub/src/store/migration-v23.test.ts | 25 +++++ hub/src/store/sessionJobs.ts | 14 ++- .../queries/reconcileAttachedJobs.test.ts | 101 ++++++++++++++++++ .../hooks/queries/reconcileAttachedJobs.ts | 34 ++++++ web/src/hooks/queries/useSessions.ts | 10 +- 5 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/queries/reconcileAttachedJobs.test.ts create mode 100644 web/src/hooks/queries/reconcileAttachedJobs.ts diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 1841355a41..748e380b89 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -70,4 +70,29 @@ describe('schema migration v22 to v26', () => { expect(store.sessionJobs.list(from.id)).toHaveLength(0) store.close() }) + + it('on key collision prefers a newer terminal source over an older terminal target', () => { + const store = new Store(':memory:') + const from = store.sessions.getOrCreateSession('from-term', { path: '/a' }, null, 'default') + const to = store.sessions.getOrCreateSession('to-term', { path: '/b' }, null, 'default') + store.sessionJobs.upsert(to.id, 'beets', { + label: 'old-complete', + status: 'completed', + remaining: 0 + }, 1_000) + store.sessionJobs.upsert(from.id, 'beets', { + label: 'new-fail', + status: 'failed', + remaining: 0 + }, 2_000) + const result = store.sessionJobs.transfer(from.id, to.id) + expect(result.collided).toBe(1) + expect(result.moved).toBe(1) + const kept = store.sessionJobs.list(to.id) + expect(kept).toHaveLength(1) + expect(kept[0]?.label).toBe('new-fail') + expect(kept[0]?.status).toBe('failed') + expect(store.sessionJobs.list(from.id)).toHaveLength(0) + store.close() + }) }) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index 098444189f..88f32d2219 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -259,12 +259,16 @@ export function transferSessionJobs( for (const job of rows) { const existing = getSessionJob(db, toSessionId, job.key) if (existing) { - // Prefer a live source over a terminal target (or newer stamp). - // Redirected heartbeats omit status, so discarding a running source - // cannot be repaired by a later heartbeat. + // Prefer a live source over a terminal target. When both are + // running or both terminal (incl. completed vs failed), prefer + // the newer updatedAt — otherwise a later terminal result loses + // to an older one with a different status. Redirected heartbeats + // omit status, so discarding a running source cannot be repaired. + const sourceRunning = job.status === 'running' + const targetRunning = existing.status === 'running' const sourceWins = - (job.status === 'running' && existing.status !== 'running') - || (job.status === existing.status && job.updatedAt > existing.updatedAt) + (sourceRunning && !targetRunning) + || (sourceRunning === targetRunning && job.updatedAt > existing.updatedAt) if (sourceWins) { db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') .run(toSessionId, job.key) diff --git a/web/src/hooks/queries/reconcileAttachedJobs.test.ts b/web/src/hooks/queries/reconcileAttachedJobs.test.ts new file mode 100644 index 0000000000..66c0a18b9d --- /dev/null +++ b/web/src/hooks/queries/reconcileAttachedJobs.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { SessionSummary, SessionsResponse } from '@/types/api' +import { reconcileAttachedJobsFromCache } from './reconcileAttachedJobs' + +function makeSummary(overrides: Partial & { id: string }): SessionSummary { + return { + active: false, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata: null, + metadataVersion: 0, + agentStateVersion: 0, + todosUpdatedAt: 0, + todoProgress: null, + pendingRequestsCount: 0, + pendingRequestKinds: [], + pendingRequests: [], + backgroundTaskCount: 0, + futureScheduledMessageCount: 0, + nextScheduledAt: null, + attachedJob: null, + attachedJobUpdatedAt: 0, + model: null, + effort: null, + ...overrides, + } +} + +describe('reconcileAttachedJobsFromCache', () => { + it('keeps a newer cached clear over a stale REST snapshot that still has the job', () => { + const id = 'sess-1' + const cached: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: null, + attachedJobUpdatedAt: 20, + }), + ], + } + const fetched: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: { + key: 'batch', + label: 'batch', + status: 'running', + startedAt: 1, + heartbeatAt: 1, + updatedAt: 10, + }, + attachedJobUpdatedAt: 10, + }), + ], + } + const next = reconcileAttachedJobsFromCache(fetched, cached) + expect(next.sessions[0]?.attachedJob).toBeNull() + expect(next.sessions[0]?.attachedJobUpdatedAt).toBe(20) + }) + + it('accepts a fresher REST snapshot', () => { + const id = 'sess-1' + const cached: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: { + key: 'batch', + label: 'batch', + status: 'running', + startedAt: 1, + heartbeatAt: 1, + updatedAt: 10, + }, + attachedJobUpdatedAt: 10, + }), + ], + } + const fetched: SessionsResponse = { + sessions: [ + makeSummary({ + id, + attachedJob: null, + attachedJobUpdatedAt: 30, + }), + ], + } + const next = reconcileAttachedJobsFromCache(fetched, cached) + expect(next.sessions[0]?.attachedJob).toBeNull() + expect(next.sessions[0]?.attachedJobUpdatedAt).toBe(30) + }) + + it('passes through when there is no cache', () => { + const fetched: SessionsResponse = { + sessions: [makeSummary({ id: 'a', attachedJobUpdatedAt: 1 })], + } + expect(reconcileAttachedJobsFromCache(fetched, undefined)).toBe(fetched) + }) +}) diff --git a/web/src/hooks/queries/reconcileAttachedJobs.ts b/web/src/hooks/queries/reconcileAttachedJobs.ts new file mode 100644 index 0000000000..372a311cbb --- /dev/null +++ b/web/src/hooks/queries/reconcileAttachedJobs.ts @@ -0,0 +1,34 @@ +import type { SessionSummary, SessionsResponse } from '@/types/api' + +/** + * Keep a fresher attachedJob from an in-flight SSE cache when a slower + * /api/sessions response would otherwise clobber it (clear/progress race). + */ +export function reconcileAttachedJobsFromCache( + fetched: SessionsResponse, + cached: SessionsResponse | undefined +): SessionsResponse { + if (!cached?.sessions?.length) { + return fetched + } + const cachedById = new Map(cached.sessions.map((session) => [session.id, session])) + return { + ...fetched, + sessions: fetched.sessions.map((session) => { + const previous = cachedById.get(session.id) + if (!previous) { + return session + } + const previousAt = previous.attachedJobUpdatedAt ?? 0 + const fetchedAt = session.attachedJobUpdatedAt ?? 0 + if (previousAt <= fetchedAt) { + return session + } + return { + ...session, + attachedJob: previous.attachedJob, + attachedJobUpdatedAt: previous.attachedJobUpdatedAt, + } satisfies SessionSummary + }), + } +} diff --git a/web/src/hooks/queries/useSessions.ts b/web/src/hooks/queries/useSessions.ts index 8e4a7b618c..ea125e828c 100644 --- a/web/src/hooks/queries/useSessions.ts +++ b/web/src/hooks/queries/useSessions.ts @@ -1,7 +1,8 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import type { ApiClient } from '@/api/client' -import type { SessionSummary } from '@/types/api' +import type { SessionSummary, SessionsResponse } from '@/types/api' import { queryKeys } from '@/lib/query-keys' +import { reconcileAttachedJobsFromCache } from './reconcileAttachedJobs' export function useSessions(api: ApiClient | null): { sessions: SessionSummary[] @@ -9,13 +10,16 @@ export function useSessions(api: ApiClient | null): { error: string | null refetch: () => Promise } { + const queryClient = useQueryClient() const query = useQuery({ queryKey: queryKeys.sessions, queryFn: async () => { if (!api) { throw new Error('API unavailable') } - return await api.getSessions() + const fetched = await api.getSessions() + const cached = queryClient.getQueryData(queryKeys.sessions) + return reconcileAttachedJobsFromCache(fetched, cached) }, enabled: Boolean(api), }) From 144840e85cb436dd60c228e73b826f8914fb7471 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:18:12 +0000 Subject: [PATCH 114/168] fix(jobs): retry terminal status write; reject startedAt on update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supervisor exit is the only running→terminal transition — retry transient hub failures. --started-at / startedAt only apply to set, not update/run. Co-authored-by: Cursor --- cli/src/commands/job.test.ts | 9 +++ cli/src/commands/job.ts | 8 +++ .../modules/sessionJob/runSessionJob.test.ts | 64 +++++++++++++++++++ cli/src/modules/sessionJob/runSessionJob.ts | 55 ++++++++++++---- .../modules/sessionJob/sessionJobMcp.test.ts | 9 +++ cli/src/modules/sessionJob/sessionJobMcp.ts | 4 ++ 6 files changed, 136 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/job.test.ts b/cli/src/commands/job.test.ts index e3785f1561..58822c5ff7 100644 --- a/cli/src/commands/job.test.ts +++ b/cli/src/commands/job.test.ts @@ -59,6 +59,15 @@ describe('parseJobArgs', () => { ]) expect(parsed.startedAt).toBe(1_785_304_595_000) }) + + it('rejects --started-at on update', () => { + expect(() => parseJobArgs([ + 'update', + 'sid', + 'beets', + '--started-at=1785304595000' + ])).toThrow(/--started-at is only valid with job set/) + }) }) describe('resolveSessionByPrefix', () => { diff --git a/cli/src/commands/job.ts b/cli/src/commands/job.ts index b3753a86da..358d17cd64 100644 --- a/cli/src/commands/job.ts +++ b/cli/src/commands/job.ts @@ -209,6 +209,10 @@ export function parseJobArgs(args: string[]): ParsedJobArgs { throw new SessionJobError('bad_args', `unexpected arg: ${arg}`) } + if (result.startedAt !== undefined && result.action !== undefined && result.action !== 'set') { + throw new SessionJobError('bad_args', '--started-at is only valid with job set') + } + return result } @@ -285,6 +289,10 @@ export async function handleJobCommand(args: string[]): Promise { throw new SessionJobError('bad_args', 'missing job key') } + if (parsed.startedAt !== undefined && parsed.action !== 'set') { + throw new SessionJobError('bad_args', '--started-at is only valid with job set') + } + if (parsed.action === 'clear') { const result = await clearSessionJob({ sessionIdPrefix: parsed.sessionIdPrefix, diff --git a/cli/src/modules/sessionJob/runSessionJob.test.ts b/cli/src/modules/sessionJob/runSessionJob.test.ts index fe52cdda90..6dfe3cd454 100644 --- a/cli/src/modules/sessionJob/runSessionJob.test.ts +++ b/cli/src/modules/sessionJob/runSessionJob.test.ts @@ -100,6 +100,70 @@ describe('runSessionJob', () => { expect(http.get).toHaveBeenCalledTimes(1) }) + it('retries terminal status write after transient failures', async () => { + let patchCalls = 0 + const http = { + post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), + get: vi.fn(async () => ({ + status: 200, + data: { sessions: [{ id: 'aaaaaaaa-1111-1111-1111-111111111111' }] } + })), + put: vi.fn(async () => ({ + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: 'running', + heartbeatAt: 1, + startedAt: 1, + updatedAt: 1 + } + } + })), + patch: vi.fn(async (_url: string, body: { status?: string }) => { + patchCalls += 1 + if (body.status === 'completed' && patchCalls < 3) { + throw new Error('transient hub 503') + } + return { + status: 200, + data: { + job: { + key: 'drain', + label: 'drain', + status: body.status ?? 'running', + heartbeatAt: 2, + startedAt: 1, + updatedAt: 2 + } + } + } + }) + } + + const sleeps: number[] = [] + const exitCode = await runSessionJob({ + sessionIdPrefix: 'aaaa', + jobKey: 'drain', + label: 'drain', + command: ['true'], + accessToken: 'token', + apiUrl: 'http://127.0.0.1:3006', + http: http as never, + spawnImpl: (() => fakeChild(0)) as never, + setIntervalImpl: ((() => 1) as never), + clearIntervalImpl: (() => undefined) as never, + sleepImpl: async (ms) => { sleeps.push(ms) } + }) + + expect(exitCode).toBe(0) + expect(patchCalls).toBe(3) + expect(sleeps).toEqual([1_000, 2_000]) + const lastPatch = http.patch.mock.calls.at(-1)?.[1] as { status?: string } + expect(lastPatch.status).toBe('completed') + }) + it('marks failed on non-zero exit', async () => { const http = { post: vi.fn(async () => ({ status: 200, data: { token: 'jwt' } })), diff --git a/cli/src/modules/sessionJob/runSessionJob.ts b/cli/src/modules/sessionJob/runSessionJob.ts index 512c607e68..6822a82448 100644 --- a/cli/src/modules/sessionJob/runSessionJob.ts +++ b/cli/src/modules/sessionJob/runSessionJob.ts @@ -29,9 +29,42 @@ export type RunSessionJobOptions = SessionJobClientOptions & { spawnImpl?: typeof spawn setIntervalImpl?: typeof setInterval clearIntervalImpl?: typeof clearInterval + /** Injected for tests (terminal-status retry backoff). */ + sleepImpl?: (ms: number) => Promise } const DEFAULT_HEARTBEAT_MS = 5 * 60 * 1000 +const TERMINAL_STATUS_ATTEMPTS = 3 + +async function markTerminalWithRetry(options: { + clientOpts: SessionJobClientOptions & { resolved: SessionJobResolvedClient } + jobKey: string + status: 'completed' | 'failed' + detail?: string + sleep: (ms: number) => Promise +}): Promise { + let lastError: unknown + for (let attempt = 0; attempt < TERMINAL_STATUS_ATTEMPTS; attempt += 1) { + try { + await updateSessionJob({ + ...options.clientOpts, + jobKey: options.jobKey, + body: { + status: options.status, + ...(options.detail !== undefined ? { detail: options.detail } : {}), + }, + }) + return + } catch (error) { + lastError = error + if (attempt === TERMINAL_STATUS_ATTEMPTS - 1) { + break + } + await options.sleep(1_000 * 2 ** attempt) + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)) +} export async function runSessionJob(options: RunSessionJobOptions): Promise { if (options.command.length === 0) { @@ -71,6 +104,8 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise new Promise((resolve) => setTimeout(resolve, ms))) const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS const child: ChildProcess = spawnFn(options.command[0]!, options.command.slice(1), { @@ -114,19 +149,11 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise((resolve) => { child.on('error', async (error) => { clearIntervalFn(heartbeat) - await inflightHeartbeat.catch(() => undefined) - try { - await updateSessionJob({ - ...clientOpts, - jobKey: options.jobKey, - body: { status: 'failed', detail: error.message } - }) - } catch { - // ignore - } + spawnErrorDetail = error.message resolve(127) }) child.on('exit', (code, signal) => { @@ -149,10 +176,12 @@ export async function runSessionJob(options: RunSessionJobOptions): Promise { }) ) }) + + it('rejects startedAt on update', async () => { + const result = await handleSessionJobTool( + { action: 'update', jobKey: 'beets', startedAt: 1_785_304_595_000 }, + 'sid-1' + ) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/startedAt is only valid with action=set/) + }) }) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index e5119663b9..ddf8f3589b 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -112,6 +112,10 @@ export async function handleSessionJobTool( } const jobKey = args.jobKey.trim() + if (args.startedAt !== undefined && args.action !== 'set') { + return { text: 'startedAt is only valid with action=set', isError: true } + } + if (args.action === 'clear') { const result = await clearSessionJob({ sessionIdPrefix, jobKey }) return { text: `cleared ${jobKey} on ${result.sessionId}`, isError: false } From 0b3f25ef36d15ff4c2e95fd5ac1bd6f83526aa68 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:29:51 +0000 Subject: [PATCH 115/168] fix(jobs): keep both live jobs on same-key merge Dual-running collisions remap the source key and record jobKeyRedirects so pre-merge supervisors cannot terminal-mark the winner. Also raise the flaky claudeRemote first-result test timeout under CI load. Co-authored-by: Cursor --- .../handlers/cli/sessionHandlers.test.ts | 4 + .../socket/handlers/cli/sessionHandlers.ts | 1 + hub/src/store/migration-v23.test.ts | 51 ++++++++++ hub/src/store/sessionJobs.ts | 64 +++++++++++-- hub/src/store/sessionJobsStore.ts | 3 +- hub/src/sync/sessionCache-merge-jobs.test.ts | 67 ++++++++++++++ hub/src/sync/sessionCache.ts | 92 +++++++++++++++++++ hub/src/sync/syncEngine.ts | 15 +++ hub/src/web/routes/sessions-jobs.test.ts | 3 + hub/src/web/routes/sessions.ts | 57 +++++++++--- shared/src/schemas.clear.test.ts | 8 +- shared/src/schemas.ts | 4 + 12 files changed, 346 insertions(+), 23 deletions(-) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts index 3637205120..0dc7f4e15c 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.test.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -407,6 +407,7 @@ describe('cli session handlers', () => { ['opencodeClearOperation', { replacementSessionId: 'foreign-session', state: 'reserved', updatedAt: Date.now() }], ['jobsAcceptedFromSessionIds', ['foreign-session']], ['jobsTransferredToSessionId', 'foreign-session'], + ['jobKeyRedirects', { 'foreign-session/beets': 'beets.foreign' }], ] as const)( 'ignores a forged hub-owned %s addition from CLI metadata', (field, forged) => { @@ -438,6 +439,7 @@ describe('cli session handlers', () => { opencodeClearOperation: operation, jobsAcceptedFromSessionIds: ['old-session'], jobsTransferredToSessionId: 'merge-target', + jobKeyRedirects: { 'old-session/beets': 'beets.oldsess1' }, }, null, 'default') const socket = new FakeSocket() registerSessionHandlers(socket as unknown as CliSocketWithData, { @@ -454,6 +456,7 @@ describe('cli session handlers', () => { opencodeClearOperation: { replacementSessionId: 'forged-target', state: 'reserved', updatedAt: 0 }, jobsAcceptedFromSessionIds: ['forged-session'], jobsTransferredToSessionId: 'forged-target', + jobKeyRedirects: { 'forged/beets': 'beets.forged' }, } }, () => {}) expect(store.sessions.getSessionByNamespace(session.id, 'default')?.metadata).toMatchObject({ @@ -461,6 +464,7 @@ describe('cli session handlers', () => { opencodeClearOperation: operation, jobsAcceptedFromSessionIds: ['old-session'], jobsTransferredToSessionId: 'merge-target', + jobKeyRedirects: { 'old-session/beets': 'beets.oldsess1' }, lifecycleState: 'archived', }) }) diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 7a2526a098..1c13c9d787 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -75,6 +75,7 @@ const HUB_OWNED_METADATA_KEYS = [ 'opencodeClearOperation', 'jobsAcceptedFromSessionIds', 'jobsTransferredToSessionId', + 'jobKeyRedirects', ] as const function preserveHubOwnedMetadata(incoming: unknown, current: unknown): unknown { diff --git a/hub/src/store/migration-v23.test.ts b/hub/src/store/migration-v23.test.ts index 748e380b89..e8a9a968f0 100644 --- a/hub/src/store/migration-v23.test.ts +++ b/hub/src/store/migration-v23.test.ts @@ -95,4 +95,55 @@ describe('schema migration v22 to v26', () => { expect(store.sessionJobs.list(from.id)).toHaveLength(0) store.close() }) + + it('on dual-running same-key collision keeps both under remapped source key', () => { + const store = new Store(':memory:') + const fromId = 'aaaaaaaa-1111-1111-1111-111111111111' + const toId = 'bbbbbbbb-2222-2222-2222-222222222222' + const from = store.sessions.getOrCreateSession( + 'tag-from-dual', + { path: '/a' }, + null, + 'default', + undefined, + undefined, + undefined, + fromId + ) + const to = store.sessions.getOrCreateSession( + 'tag-to-dual', + { path: '/b' }, + null, + 'default', + undefined, + undefined, + undefined, + toId + ) + expect(from.id).toBe(fromId) + expect(to.id).toBe(toId) + store.sessionJobs.upsert(to.id, 'beets', { + label: 'target-live', + status: 'running', + remaining: 9 + }, 1_000) + store.sessionJobs.upsert(from.id, 'beets', { + label: 'source-live', + status: 'running', + remaining: 3 + }, 2_000) + const result = store.sessionJobs.transfer(from.id, to.id) + expect(result.collided).toBe(1) + expect(result.moved).toBe(1) + expect(result.keyRedirects).toEqual([ + { fromKey: 'beets', toKey: 'beets.aaaaaaaa' } + ]) + const onTarget = store.sessionJobs.list(to.id) + expect(onTarget).toHaveLength(2) + expect(onTarget.map((j) => j.key).sort()).toEqual(['beets', 'beets.aaaaaaaa']) + expect(store.sessionJobs.get(to.id, 'beets')?.label).toBe('target-live') + expect(store.sessionJobs.get(to.id, 'beets.aaaaaaaa')?.label).toBe('source-live') + expect(store.sessionJobs.list(from.id)).toHaveLength(0) + store.close() + }) }) diff --git a/hub/src/store/sessionJobs.ts b/hub/src/store/sessionJobs.ts index 88f32d2219..a2952a4eef 100644 --- a/hub/src/store/sessionJobs.ts +++ b/hub/src/store/sessionJobs.ts @@ -240,6 +240,40 @@ export function deleteSessionJob(db: Database, sessionId: string, jobKey: string return result.changes > 0 } +export type SessionJobKeyRedirect = { + fromKey: string + toKey: string +} + +export type TransferSessionJobsResult = { + moved: number + collided: number + /** Source keys remapped on the target so two live supervisors stay isolated. */ + keyRedirects: SessionJobKeyRedirect[] +} + +const JOB_KEY_MAX = 128 + +/** Allocate `base.` (then `.N`) that fits JOB_KEY_MAX and is free on target. */ +export function allocateRemappedJobKey( + db: Database, + toSessionId: string, + fromSessionId: string, + fromKey: string +): string { + const short = fromSessionId.replace(/-/g, '').slice(0, 8) || 'src' + const suffix0 = `.${short}` + const base = fromKey.slice(0, Math.max(1, JOB_KEY_MAX - suffix0.length)) + let candidate = `${base}${suffix0}` + let n = 0 + while (getSessionJob(db, toSessionId, candidate)) { + n += 1 + const suffix = `.${short}.${n}` + candidate = `${fromKey.slice(0, Math.max(1, JOB_KEY_MAX - suffix.length))}${suffix}` + } + return candidate +} + /** * Re-point jobs during session merge (same contract as scratchlist transfer). * Call BEFORE deleteSession so CASCADE does not race the move. @@ -248,27 +282,39 @@ export function transferSessionJobs( db: Database, fromSessionId: string, toSessionId: string -): { moved: number; collided: number } { +): TransferSessionJobsResult { if (fromSessionId === toSessionId) { - return { moved: 0, collided: 0 } + return { moved: 0, collided: 0, keyRedirects: [] } } const rows = listSessionJobs(db, fromSessionId) let moved = 0 let collided = 0 + const keyRedirects: SessionJobKeyRedirect[] = [] for (const job of rows) { const existing = getSessionJob(db, toSessionId, job.key) if (existing) { - // Prefer a live source over a terminal target. When both are - // running or both terminal (incl. completed vs failed), prefer - // the newer updatedAt — otherwise a later terminal result loses - // to an older one with a different status. Redirected heartbeats - // omit status, so discarding a running source cannot be repaired. const sourceRunning = job.status === 'running' const targetRunning = existing.status === 'running' + // Two live supervisors still PATCH the pre-merge key via session + // redirect. Collapsing them would let the loser terminal-mark the + // winner — keep both under distinct keys and record a key remap. + if (sourceRunning && targetRunning) { + const toKey = allocateRemappedJobKey(db, toSessionId, fromSessionId, job.key) + db.prepare( + `UPDATE session_jobs SET session_id = ?, job_key = ? + WHERE session_id = ? AND job_key = ?` + ).run(toSessionId, toKey, fromSessionId, job.key) + keyRedirects.push({ fromKey: job.key, toKey }) + moved += 1 + collided += 1 + continue + } + // Prefer a live source over a terminal target. When both are + // terminal (incl. completed vs failed), prefer the newer updatedAt. const sourceWins = (sourceRunning && !targetRunning) - || (sourceRunning === targetRunning && job.updatedAt > existing.updatedAt) + || (!sourceRunning && !targetRunning && job.updatedAt > existing.updatedAt) if (sourceWins) { db.prepare('DELETE FROM session_jobs WHERE session_id = ? AND job_key = ?') .run(toSessionId, job.key) @@ -291,5 +337,5 @@ export function transferSessionJobs( moved += 1 } - return { moved, collided } + return { moved, collided, keyRedirects } } diff --git a/hub/src/store/sessionJobsStore.ts b/hub/src/store/sessionJobsStore.ts index 8394730e5b..9f6a75384c 100644 --- a/hub/src/store/sessionJobsStore.ts +++ b/hub/src/store/sessionJobsStore.ts @@ -12,6 +12,7 @@ import { toAttachedJob, transferSessionJobs, upsertSessionJob, + type TransferSessionJobsResult, type UpsertSessionJobResult } from './sessionJobs' @@ -61,7 +62,7 @@ export class SessionJobsStore { return deleteSessionJob(this.db, sessionId, jobKey) } - transfer(fromSessionId: string, toSessionId: string): { moved: number; collided: number } { + transfer(fromSessionId: string, toSessionId: string): TransferSessionJobsResult { return transferSessionJobs(this.db, fromSessionId, toSessionId) } } diff --git a/hub/src/sync/sessionCache-merge-jobs.test.ts b/hub/src/sync/sessionCache-merge-jobs.test.ts index 5f9dbddf0f..f3a44adfec 100644 --- a/hub/src/sync/sessionCache-merge-jobs.test.ts +++ b/hub/src/sync/sessionCache-merge-jobs.test.ts @@ -188,4 +188,71 @@ describe('mergeSessions job redirect through SessionCache (#1404)', () => { expect(cache.resolveAttachedJobSessionId(oldSession.id, 'default')).toBe(newSession.id) }) + + it('remaps dual-running same-key jobs and routes PATCH via jobKeyRedirects', async () => { + const { store, cache } = setup() + const oldId = 'aaaaaaaa-1111-1111-1111-111111111111' + const newId = 'bbbbbbbb-2222-2222-2222-222222222222' + const oldSession = cache.getOrCreateSession( + 'tag-dual-old', + { path: '/a', host: 'local', flavor: 'codex' }, + null, + 'default', + undefined, + undefined, + undefined, + oldId + ) + const newSession = cache.getOrCreateSession( + 'tag-dual-new', + { path: '/b', host: 'local', flavor: 'codex' }, + null, + 'default', + undefined, + undefined, + undefined, + newId + ) + expect(oldSession.id).toBe(oldId) + expect(newSession.id).toBe(newId) + + store.sessionJobs.upsert(newSession.id, 'beets', { + label: 'target-live', + status: 'running', + remaining: 9 + }, 1_000) + store.sessionJobs.upsert(oldSession.id, 'beets', { + label: 'source-live', + status: 'running', + remaining: 3 + }, 2_000) + + await cache.mergeSessionHistory(oldSession.id, newSession.id, 'default', { + mergeAgentState: false + }) + + const onTarget = store.sessionJobs.list(newSession.id) + expect(onTarget).toHaveLength(2) + const refreshed = cache.refreshSession(newSession.id) + expect(refreshed?.metadata?.jobKeyRedirects).toEqual({ + [`${oldId}/beets`]: 'beets.aaaaaaaa' + }) + expect( + cache.resolveAttachedJobKey(oldId, newId, 'beets', 'default') + ).toBe('beets.aaaaaaaa') + expect( + cache.resolveAttachedJobKey(newId, newId, 'beets', 'default') + ).toBe('beets') + + // Terminal update via pre-merge session id + original key touches only the remapped row. + const patched = store.sessionJobs.patch( + newId, + cache.resolveAttachedJobKey(oldId, newId, 'beets', 'default'), + { status: 'completed' }, + 3_000 + ) + expect(patched?.key).toBe('beets.aaaaaaaa') + expect(patched?.status).toBe('completed') + expect(store.sessionJobs.get(newId, 'beets')?.status).toBe('running') + }) }) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index f9bab689b5..b1c8de0362 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1179,6 +1179,12 @@ export class SessionCache { // still exists; without this pointer, retained $HAPI_SESSION_ID hits // the emptied source and terminal PATCHes 404. this.recordJobsTransferredToSession(oldSessionId, newSessionId, namespace) + this.recordJobKeyRedirects( + newSessionId, + oldSessionId, + movedJobs.keyRedirects, + namespace + ) if (movedJobs.moved > 0 || movedJobs.collided > 0) { this.emitAttachedJobChanged( newSessionId, @@ -1467,6 +1473,71 @@ export class SessionCache { } } + /** + * Persist key remaps from dual-running same-key merges, and inherit any + * redirects the source already held (A→B→C). + */ + private recordJobKeyRedirects( + toSessionId: string, + fromSessionId: string, + redirects: Array<{ fromKey: string; toKey: string }>, + namespace: string + ): void { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(toSessionId, namespace) + if (!latest) return + const meta = (latest.metadata && typeof latest.metadata === 'object' + ? { ...(latest.metadata as Record) } + : {}) as Record + const prevRaw = meta.jobKeyRedirects + const next: Record = {} + if (prevRaw && typeof prevRaw === 'object' && !Array.isArray(prevRaw)) { + for (const [k, v] of Object.entries(prevRaw as Record)) { + if (typeof v === 'string' && v.trim()) next[k] = v + } + } + const fromMeta = this.store.sessions + .getSessionByNamespace(fromSessionId, namespace) + ?.metadata as Record | null | undefined + const inheritedRaw = fromMeta?.jobKeyRedirects + if (inheritedRaw && typeof inheritedRaw === 'object' && !Array.isArray(inheritedRaw)) { + for (const [k, v] of Object.entries(inheritedRaw as Record)) { + if (typeof v === 'string' && v.trim()) next[k] = v + } + } + for (const { fromKey, toKey } of redirects) { + next[`${fromSessionId}/${fromKey}`] = toKey + } + const prevKeys = Object.keys( + prevRaw && typeof prevRaw === 'object' && !Array.isArray(prevRaw) + ? (prevRaw as Record) + : {} + ) + const nextKeys = Object.keys(next) + const unchanged = + prevKeys.length === nextKeys.length + && nextKeys.every((k) => (prevRaw as Record | undefined)?.[k] === next[k]) + if (unchanged) return + if (nextKeys.length === 0) { + delete meta.jobKeyRedirects + } else { + meta.jobKeyRedirects = next + } + const result = this.store.sessions.updateSessionMetadata( + toSessionId, + meta, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + this.refreshSession(toSessionId) + return + } + if (result.result !== 'version-mismatch') return + } + } + /** * Follow job-owner redirects after session merge/dedup so agents that still * hold the pre-merge `$HAPI_SESSION_ID` can heartbeat. @@ -1513,6 +1584,27 @@ export class SessionCache { return null } + /** + * Map a pre-merge job key onto the post-merge owner key when dual-running + * same-key merge remapped the source row. + */ + resolveAttachedJobKey( + requestedSessionId: string, + ownerSessionId: string, + jobKey: string, + namespace: string + ): string { + const access = this.resolveSessionAccess(ownerSessionId, namespace) + if (!access.ok) return jobKey + const meta = access.session.metadata as Record | null | undefined + const redirects = meta?.jobKeyRedirects + if (!redirects || typeof redirects !== 'object' || Array.isArray(redirects)) { + return jobKey + } + const mapped = (redirects as Record)[`${requestedSessionId}/${jobKey}`] + return typeof mapped === 'string' && mapped.trim() ? mapped : jobKey + } + private mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { if (!oldMetadata || typeof oldMetadata !== 'object') { return newMetadata diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 90fd3d3768..bc12f57c6a 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -363,6 +363,21 @@ export class SyncEngine { return this.sessionCache.resolveAttachedJobSessionId(sessionId, namespace) } + /** Follow dual-running same-key remaps after merge (tiann/hapi#1404). */ + resolveAttachedJobKey( + requestedSessionId: string, + ownerSessionId: string, + jobKey: string, + namespace: string + ): string { + return this.sessionCache.resolveAttachedJobKey( + requestedSessionId, + ownerSessionId, + jobKey, + namespace + ) + } + getActiveSessions(): Session[] { return this.sessionCache.getActiveSessions() } diff --git a/hub/src/web/routes/sessions-jobs.test.ts b/hub/src/web/routes/sessions-jobs.test.ts index e8408b3797..a9d3cd4d56 100644 --- a/hub/src/web/routes/sessions-jobs.test.ts +++ b/hub/src/web/routes/sessions-jobs.test.ts @@ -36,6 +36,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { const engine = { resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), resolveAttachedJobSessionId: (id: string) => id, + resolveAttachedJobKey: (_requested: string, _owner: string, jobKey: string) => jobKey, getSessionsByNamespace: () => [session], getFutureScheduledMessageCounts: () => new Map(), getNextScheduledAtBySessionIds: () => new Map(), @@ -157,6 +158,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { return { ok: false as const, reason: 'not-found' as const } }, resolveAttachedJobSessionId: (id: string) => (id === deletedId ? owner.id : id), + resolveAttachedJobKey: (_requested: string, _owner: string, jobKey: string) => jobKey, listSessionJobs: (sid: string) => (sid === owner.id ? [...jobs.values()] : []), getPrimaryAttachedJob: (sid: string) => (sid === owner.id ? jobs.get('beets')! : null), upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), @@ -182,6 +184,7 @@ describe('session-attached jobs routes (tiann/hapi#1404)', () => { const engine = { resolveSessionAccess: () => ({ ok: true as const, sessionId: session.id, session }), resolveAttachedJobSessionId: (id: string) => id, + resolveAttachedJobKey: (_requested: string, _owner: string, jobKey: string) => jobKey, listSessionJobs: () => [], getPrimaryAttachedJob: () => null, upsertSessionJob: () => ({ outcome: 'session-not-found' as const }), diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index a5047d84fd..00e213dd37 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1259,17 +1259,21 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho function resolveJobOwnerSession( c: Context, engine: SyncEngine - ): { sessionId: string; session: Session } | Response { + ): { requestedSessionId: string; sessionId: string; session: Session } | Response { + const rawId = c.req.param('id') ?? '' const sessionResult = requireSessionFromParam(c, engine) if (sessionResult instanceof Response) { // Session may already be deleted after merge — still try acceptor redirect. - const rawId = c.req.param('id') ?? '' const namespace = c.get('namespace') const redirected = engine.resolveAttachedJobSessionId(rawId, namespace) if (redirected !== rawId) { const access = engine.resolveSessionAccess(redirected, namespace) if (access.ok) { - return { sessionId: access.sessionId, session: access.session } + return { + requestedSessionId: rawId, + sessionId: access.sessionId, + session: access.session, + } } } return sessionResult @@ -1277,13 +1281,39 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const namespace = c.get('namespace') const ownerId = engine.resolveAttachedJobSessionId(sessionResult.sessionId, namespace) if (ownerId === sessionResult.sessionId) { - return sessionResult + return { + requestedSessionId: sessionResult.sessionId, + sessionId: sessionResult.sessionId, + session: sessionResult.session, + } } const access = engine.resolveSessionAccess(ownerId, namespace) if (!access.ok) { - return sessionResult + return { + requestedSessionId: sessionResult.sessionId, + sessionId: sessionResult.sessionId, + session: sessionResult.session, + } } - return { sessionId: access.sessionId, session: access.session } + return { + requestedSessionId: sessionResult.sessionId, + sessionId: access.sessionId, + session: access.session, + } + } + + function resolveJobKey( + c: Context, + engine: SyncEngine, + owner: { requestedSessionId: string; sessionId: string }, + jobKey: string + ): string { + return engine.resolveAttachedJobKey( + owner.requestedSessionId, + owner.sessionId, + jobKey, + c.get('namespace') + ) } app.get('/sessions/:id/jobs', (c) => { @@ -1310,10 +1340,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (sessionResult instanceof Response) { return sessionResult } - const jobKey = c.req.param('jobKey') - if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + const rawJobKey = c.req.param('jobKey') + if (!rawJobKey || !JOB_KEY_RE.test(rawJobKey)) { return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) } + const jobKey = resolveJobKey(c, engine, sessionResult, rawJobKey) const body = await c.req.json().catch(() => null) const parsed = AttachedJobUpsertSchema.safeParse(body) if (!parsed.success) { @@ -1335,10 +1366,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (sessionResult instanceof Response) { return sessionResult } - const jobKey = c.req.param('jobKey') - if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + const rawJobKey = c.req.param('jobKey') + if (!rawJobKey || !JOB_KEY_RE.test(rawJobKey)) { return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) } + const jobKey = resolveJobKey(c, engine, sessionResult, rawJobKey) const body = await c.req.json().catch(() => null) const parsed = AttachedJobPatchSchema.safeParse(body) if (!parsed.success) { @@ -1360,10 +1392,11 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (sessionResult instanceof Response) { return sessionResult } - const jobKey = c.req.param('jobKey') - if (!jobKey || !JOB_KEY_RE.test(jobKey)) { + const rawJobKey = c.req.param('jobKey') + if (!rawJobKey || !JOB_KEY_RE.test(rawJobKey)) { return c.json({ error: 'Invalid jobKey (1-128 chars: alnum, . _ -)' }, 400) } + const jobKey = resolveJobKey(c, engine, sessionResult, rawJobKey) const removed = engine.deleteSessionJob(sessionResult.sessionId, jobKey) if (!removed) { return c.json({ error: 'Job not found' }, 404) diff --git a/shared/src/schemas.clear.test.ts b/shared/src/schemas.clear.test.ts index b8197221c7..5d9eb46c13 100644 --- a/shared/src/schemas.clear.test.ts +++ b/shared/src/schemas.clear.test.ts @@ -15,10 +15,16 @@ describe('fresh-session clear schema contract', () => { path: '/tmp/project', host: 'host', jobsAcceptedFromSessionIds: ['old-session-id'], - jobsTransferredToSessionId: 'new-session-id' + jobsTransferredToSessionId: 'new-session-id', + jobKeyRedirects: { + 'old-session-id/beets': 'beets.oldsess1', + }, }) expect(parsed.jobsAcceptedFromSessionIds).toEqual(['old-session-id']) expect(parsed.jobsTransferredToSessionId).toBe('new-session-id') + expect(parsed.jobKeyRedirects).toEqual({ + 'old-session-id/beets': 'beets.oldsess1', + }) }) it('accepts cleared as an additive session-end reason', () => { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 92db878dbe..f8420c5ed4 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -126,6 +126,10 @@ export const MetadataSchema = z.object({ // Hub-owned: CLI update-metadata cannot forge/erase (HUB_OWNED_METADATA_KEYS). jobsAcceptedFromSessionIds: z.array(z.string()).optional(), jobsTransferredToSessionId: z.string().optional(), + // When merge remaps a live source job key (same-key dual-running), map + // `${fromSessionId}/${fromKey}` → toKey on the post-merge owner so + // pre-merge supervisors keep PATCHing the right row. + jobKeyRedirects: z.record(z.string(), z.string()).optional(), // Durable in-progress state for runner-backed OpenCode /clear. opencodeClearOperation: OpencodeClearOperationSchema.optional(), preferredPermissionMode: PermissionModeSchema.optional(), From 42c2793164816efbcf75df2cba52fc4e56695a42 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:49:38 +0000 Subject: [PATCH 116/168] fix(jobs): hard-refuse MCP set; louder stale heartbeat chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process-shaped work must use Shell hapi job run. MCP set is refused with the run recipe (wardrobe freeze). Stale rows show "no heartbeat · age". Co-authored-by: Cursor --- AGENTS.md | 13 +-- .../common/sessionJobInstruction.test.ts | 3 +- .../modules/common/sessionJobInstruction.ts | 9 +- .../modules/sessionJob/sessionJobMcp.test.ts | 44 ++++------ cli/src/modules/sessionJob/sessionJobMcp.ts | 85 ++++++++++--------- docs/guide/session-jobs.md | 20 +++-- web/src/components/SessionRowSummary.tsx | 9 +- web/src/lib/attachedJob.test.ts | 27 ++++-- web/src/lib/attachedJob.ts | 22 +++-- 9 files changed, 132 insertions(+), 100 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 90435935a3..ea331dc60d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,14 +174,15 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr ## Session-attached jobs (outliving work) -When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it so the session list stays truthful while `active: false`. Same tooling class as `ping_peer` / `inspect_peer` (MCP `session_job`). This is **not** thinking progress / todos / in-agent background tools. It is also **not** an A2A Layer 1 `work_ad` ([#1332](https://github.com/tiann/hapi/discussions/1332)) — jobs enrich Layer 0 `SessionSummary`; leave collaboration claims / handoffs to the work-graph ledger. +When an agent starts process-shaped work that will keep running after the agent goes idle (`nohup`, batch imports, long scripts, external daemons), attach it so the session list stays truthful while `active: false`. This is **not** thinking progress / todos / in-agent background tools. It is also **not** an A2A Layer 1 `work_ad` ([#1332](https://github.com/tiann/hapi/discussions/1332)) — jobs enrich Layer 0 `SessionSummary`; leave collaboration claims / handoffs to the work-graph ledger. -Agent contract (idle agents cannot heartbeat): +Agent contract (idle agents cannot heartbeat — bare set + nohup freezes the bar): -1. Prefer `hapi job run "$HAPI_SESSION_ID" --label … -- ` for supervised shell children (auto-heartbeat + exit status) -2. Manual path only with a self-heartbeating wrapper: MCP `session_job` or CLI `set` / `update` ≥~10m / clear -3. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent -4. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with `set --started-at` (or clear+set) +1. **Required for process-shaped work:** Shell `hapi job run "$HAPI_SESSION_ID" --label … -- ` (auto-heartbeat + exit status) +2. MCP `session_job` **refuses `action=set`**. Use it only for `update` / `clear` / `list` on a job the supervisor already created +3. Manual CLI `set` only with a self-heartbeating wrapper (`update` ≥~10m); never MCP set + nohup +4. Prefer honest `--remaining` or `--done`/`--total`; omit counts if unknown — never invent a percent +5. Elapsed wall clock is always shown from `startedAt` (not an ETA); correct late attach with CLI `set --started-at` (or clear+set) Full guide: `docs/guide/session-jobs.md`. CLI: `hapi job --help`. diff --git a/cli/src/modules/common/sessionJobInstruction.test.ts b/cli/src/modules/common/sessionJobInstruction.test.ts index 45ebbd72cd..12085a4d65 100644 --- a/cli/src/modules/common/sessionJobInstruction.test.ts +++ b/cli/src/modules/common/sessionJobInstruction.test.ts @@ -5,11 +5,12 @@ import { } from './sessionJobInstruction' describe('sessionJobInstruction', () => { - it('prefers MCP session_job + job run supervisor and forbids fake percent', () => { + it('requires job run and forbids MCP set / fake percent', () => { expect(SESSION_JOB_INSTRUCTION).toContain('session_job') expect(SESSION_JOB_INSTRUCTION).toContain('ping_peer') expect(SESSION_JOB_INSTRUCTION).toContain('hapi job run') expect(SESSION_JOB_INSTRUCTION).toContain('idle agents cannot') + expect(SESSION_JOB_INSTRUCTION).toMatch(/action=set \(refused\)/) expect(SESSION_JOB_INSTRUCTION).toContain('Never invent a fake percent') expect(SESSION_JOB_INSTRUCTION).toContain('HAPI_SESSION_ID') }) diff --git a/cli/src/modules/common/sessionJobInstruction.ts b/cli/src/modules/common/sessionJobInstruction.ts index aaec5f96d7..18515f2a2b 100644 --- a/cli/src/modules/common/sessionJobInstruction.ts +++ b/cli/src/modules/common/sessionJobInstruction.ts @@ -14,12 +14,11 @@ export const SESSION_JOB_INSTRUCTION = [ '(batch imports, long scripts, external daemons), attach a session job so', 'the session list shows progress while you are idle — same class of HAPI', 'tooling as ping_peer / inspect_peer.', - 'Prefer supervised CLI for process-shaped work (idle agents cannot heartbeat):', - 'hapi job run "$HAPI_SESSION_ID" --label -- …', + 'REQUIRED for process-shaped work (idle agents cannot heartbeat):', + 'Shell → hapi job run "$HAPI_SESSION_ID" --label -- …', '(auto-heartbeats + completed/failed on exit).', - 'Manual path: MCP tool session_job (aliases: mcp__hapi__session_job,', - 'hapi_session_job, functions.hapi__session_job) action=set, then action=update', - 'every ~10m from a self-heartbeating wrapper — never set-once and walk away.', + 'Do NOT use MCP session_job action=set (refused) or bare set+nohup — that freezes the bar.', + 'MCP session_job is only update / clear / list after job run created the meter.', 'Prefer honest remaining or done+total; omit counts when unknown', '(UI shows "running" + elapsed). Never invent a fake percent.', 'Full contract: hapi job --help.' diff --git a/cli/src/modules/sessionJob/sessionJobMcp.test.ts b/cli/src/modules/sessionJob/sessionJobMcp.test.ts index f168f51899..1bef390ba3 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.test.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { handleSessionJobTool, SESSION_JOB_TOOL_DESCRIPTION } from './sessionJobMcp' +import { + handleSessionJobTool, + SESSION_JOB_RUN_RECIPE, + SESSION_JOB_SET_REFUSED_TEXT, + SESSION_JOB_TOOL_DESCRIPTION +} from './sessionJobMcp' vi.mock('./sessionJob', () => ({ SessionJobError: class SessionJobError extends Error { @@ -9,18 +14,9 @@ vi.mock('./sessionJob', () => ({ this.code = code } }, - setSessionJob: vi.fn(async () => ({ - sessionId: 'sid-1', - job: { - key: 'beets', - label: 'beets import', - status: 'running', - remaining: 12, - heartbeatAt: 1, - startedAt: 1, - updatedAt: 1 - } - })), + setSessionJob: vi.fn(async () => { + throw new Error('setSessionJob must not be called from MCP') + }), updateSessionJob: vi.fn(async () => ({ sessionId: 'sid-1', job: { @@ -38,27 +34,23 @@ vi.mock('./sessionJob', () => ({ })) describe('sessionJobMcp', () => { - it('description steers outliving batch work and honest progress', () => { + it('description steers to job run and forbids MCP set', () => { expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/OUTLIVES/i) - expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Never invent a percent/i) + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/do NOT use action=set/i) expect(SESSION_JOB_TOOL_DESCRIPTION).toContain('hapi job run') + expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) }) - it('set requires label and always targets the caller session id', async () => { + it('hard-refuses action=set and never calls setSessionJob', async () => { const { setSessionJob } = await import('./sessionJob') const result = await handleSessionJobTool( { action: 'set', jobKey: 'beets', label: 'beets import', remaining: 12 }, 'sid-1' ) - expect(result.isError).toBe(false) - expect(result.text).toContain('set beets') - expect(setSessionJob).toHaveBeenCalledWith( - expect.objectContaining({ sessionIdPrefix: 'sid-1' }) - ) - }) - - it('description claims own-session only', () => { - expect(SESSION_JOB_TOOL_DESCRIPTION).toMatch(/Own-session only/i) + expect(result.isError).toBe(true) + expect(result.text).toBe(SESSION_JOB_SET_REFUSED_TEXT) + expect(result.text).toContain(SESSION_JOB_RUN_RECIPE) + expect(setSessionJob).not.toHaveBeenCalled() }) it('treats empty update as a heartbeat-only patch', async () => { @@ -84,6 +76,6 @@ describe('sessionJobMcp', () => { 'sid-1' ) expect(result.isError).toBe(true) - expect(result.text).toMatch(/startedAt is only valid with action=set/) + expect(result.text).toMatch(/startedAt is not valid over MCP/) }) }) diff --git a/cli/src/modules/sessionJob/sessionJobMcp.ts b/cli/src/modules/sessionJob/sessionJobMcp.ts index ddf8f3589b..63f6251468 100644 --- a/cli/src/modules/sessionJob/sessionJobMcp.ts +++ b/cli/src/modules/sessionJob/sessionJobMcp.ts @@ -1,50 +1,64 @@ /** * MCP surface for session-attached jobs (tiann/hapi#1404). * Same discovery class as ping_peer / inspect_peer — tool catalog, not docs-only. + * + * Hard contract: MCP cannot create a long-lived bar (action=set is refused). + * Create meters with Shell + `hapi job run` (babysitter). MCP is for + * update / clear / list after a supervisor or CLI wrapper owns heartbeats. */ import { z } from 'zod' -import type { AttachedJob, AttachedJobPatch, AttachedJobUpsert } from '@hapi/protocol' +import type { AttachedJob, AttachedJobPatch } from '@hapi/protocol' import { SessionJobError, clearSessionJob, listSessionJobs, - setSessionJob, updateSessionJob } from './sessionJob' export const SESSION_JOB_TOOL_NAME = 'session_job' +/** Exact Shell recipe agents should run instead of MCP set. */ +export const SESSION_JOB_RUN_RECIPE = + 'hapi job run "$HAPI_SESSION_ID" --label "