@turmind/halo 1.5.11 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/admin-out/404.html +1 -1
  3. package/admin-out/_next/static/chunks/{941-4719e7343b54b9bd.js → 147-d4200c70445aabb6.js} +1 -1
  4. package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +1 -0
  5. package/admin-out/_next/static/chunks/app/{layout-5ec6c5f713a8562b.js → layout-ea161287d934ba96.js} +1 -1
  6. package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +1 -0
  7. package/admin-out/_next/static/chunks/{webpack-efd27999305b080a.js → webpack-dc73047e0279979b.js} +1 -1
  8. package/admin-out/_next/static/css/7b736202f0b1dbfd.css +1 -0
  9. package/admin-out/_next/static/css/981c9e5610ce535a.css +1 -0
  10. package/admin-out/index.html +1 -1
  11. package/admin-out/index.txt +6 -5
  12. package/bundled-docs/design/a2a.md +286 -0
  13. package/bundled-docs/design/agentcore.md +194 -122
  14. package/bundled-docs/design/architecture.md +2 -2
  15. package/bundled-docs/design/canvas-extensions.md +30 -9
  16. package/bundled-docs/design/observability.md +12 -4
  17. package/bundled-docs/design/relay.md +1 -1
  18. package/bundled-docs/design/session.md +3 -1
  19. package/bundled-docs/design/storage.md +9 -2
  20. package/bundled-docs/design/web.md +5 -56
  21. package/bundled-docs/dev/api.md +70 -4
  22. package/bundled-docs/dev/deploy.md +2 -1
  23. package/bundled-docs/dev/env.md +8 -4
  24. package/bundled-docs/dev/tools.md +93 -3
  25. package/bundled-docs/guide/channels/web.md +0 -26
  26. package/bundled-docs/guide/cli.md +40 -4
  27. package/bundled-docs/guide/delegation-and-access.md +3 -2
  28. package/bundled-docs/guide/skills.md +1 -1
  29. package/bundled-docs/requirements/agent.md +1 -1
  30. package/bundled-docs/requirements/chat.md +3 -1
  31. package/bundled-docs/requirements/editor.md +9 -3
  32. package/bundled-docs/requirements/workspace.md +6 -0
  33. package/dist/index.js +4428 -2299
  34. package/package.json +1 -1
  35. package/templates/models/aws-bedrock-claude-invoke.yaml +40 -1
  36. package/templates/models/aws-bedrock-openai.yaml +100 -6
  37. package/templates/prompts/all/RUNTIME.md +2 -1
  38. package/templates/skills/send-file/SKILL.md +1 -1
  39. package/templates/skills/send-file/a2a.md +13 -0
  40. package/admin-out/_next/static/chunks/989-86b0f677f69ccf3b.js +0 -1
  41. package/admin-out/_next/static/chunks/app/page-4792aa1c79f7e7f1.js +0 -1
  42. package/admin-out/_next/static/css/97f3ac570664d044.css +0 -3
  43. /package/admin-out/_next/static/{GO2W5JoiC46FIxLjtFAXx → _F_Fkyh9PainUNY82EEjY}/_buildManifest.js +0 -0
  44. /package/admin-out/_next/static/{GO2W5JoiC46FIxLjtFAXx → _F_Fkyh9PainUNY82EEjY}/_ssgManifest.js +0 -0
@@ -1,135 +1,180 @@
1
1
  # AgentCore Runtime Mode
2
2
 
3
- Halo server as an **Amazon Bedrock AgentCore Runtime** container — a fourth
4
- way to run halo (server / CLI / desktop / AgentCore). One env var flips it:
5
- `HALO_RUNTIME_MODE=agentcore`. Demo package: `packages/agentcore-demo/`
6
- (Dockerfile, chat frontend, CDK stack, auth Lambdas — its README carries the
7
- operational gotchas; this doc covers how the mode works inside the server).
3
+ Halo server as an **Amazon Bedrock AgentCore Runtime** container speaking the
4
+ **A2A protocol** (AgentCore `serverProtocol: A2A`) — a fourth way to run halo
5
+ (server / CLI / desktop / AgentCore). `halo agentcore --workspace <path>` sets
6
+ `HALO_RUNTIME_MODE=agentcore-a2a` and runs the server in the foreground; the
7
+ implementation is `packages/server/src/a2a/agentcore.ts`. The repo ships no
8
+ deploy package (Dockerfile / IaC): you build the image and create the runtime
9
+ yourself. The former HTTP-protocol mode (`/invocations`, WS `/ws`, per-user
10
+ workspaces) and its demo package were removed 2026-10-09 (git history `7e98369`).
8
11
 
9
12
  ```
10
- Browser (static frontend, S3 + CloudFront)
11
- │ /api/login, /api/verify → CloudFront /api/* behavior → API GW → auth Lambda (DDB users, JWT)
12
- │ /api/ws-presign → presign Lambda (SigV4-signs the AgentCore WS URL)
13
- │ wss (presigned URL, 5-min TTL)
13
+ Caller (any halo server, or any A2A client that SigV4-signs)
14
+ │ POST …/runtimes/<escaped ARN>/invocations/ (A2A JSON-RPC, SigV4)
15
+ │ GET …/invocations/.well-known/agent-card.json
16
+ │ X-Amzn-Bedrock-AgentCore-Runtime-Session-Id = fixed id
14
17
  ▼
15
- AgentCore Runtime (per-session microVM, auth terminated here)
16
- │ X-Amzn-Bedrock-AgentCore-Runtime-Session-Id = user UUID
18
+ AgentCore Runtime (per-session microVM, verifies the signed request)
17
19
  ▼
18
- halo server :8080 (HALO_RUNTIME_MODE=agentcore)
19
- ├── GET /ping Healthy | HealthyBusy (any running agent session)
20
- ├── POST /invocations {"input":{"prompt"}} → full assistant message
21
- └── WS /ws streaming frames (primary path)
20
+ halo agentcore :9000 (HALO_RUNTIME_MODE=agentcore-a2a)
21
+ ├── GET /ping Healthy | HealthyBusy
22
+ ├── POST / A2A JSON-RPC
23
+ └── GET /.well-known/agent-card.json card (no securitySchemes)
24
+ one fixed workspace (EFS) — <ws>/.halo/{a2a.db, runs.db, agentcore.lease}
22
25
  ```
23
26
 
27
+ ## Running it
28
+
29
+ `halo agentcore [-w <path>] [-p N]`; the workspace is required and must exist.
30
+
31
+ `HALO_HOME` must be initialized first: run `halo setup --non-interactive` in
32
+ the image build (seeds `~/.halo/global` — agents, skills, models). `halo
33
+ agentcore` skips the CLI's setup gate, but the server still exits at boot with
34
+ "~/.halo/global/ not initialized" without it. Edits to the seeded files (e.g.
35
+ pointing the agents' Bedrock endpoint at the runtime's region) go after it.
36
+
37
+ | Setting | Meaning |
38
+ |---|---|
39
+ | `-w, --workspace` / `HALO_WORKSPACE` | The one workspace served — an EFS mount in practice. |
40
+ | `-p, --port` | Listen port, default `9000` (the AgentCore contract). Always overrides `HALO_PORT`. |
41
+ | `HALO_A2A_PUBLIC_URL` | The runtime's invoke URL (`…/runtimes/<escaped ARN>/invocations`); becomes the card's interface URL (`<url>/`). Unset → warning, and the card advertises the container's own origin. |
42
+ | `HALO_A2A_ACCESS` | Access level of every session: `workspace` (default) \| `full` \| `readonly`. Anything else is a boot error. |
43
+ | `HALO_LOG_LEVEL=info` | Needed for CloudWatch — see the ops notes. |
44
+
24
45
  ## What the mode changes (packages/server/src/index.ts)
25
46
 
26
- `config.server.runtimeMode === 'agentcore'` (env `HALO_RUNTIME_MODE`) skips:
27
-
28
- - **Password/JWT gate** — no admin password needed at startup, `/ws` skips the
29
- cookie check. AgentCore terminates auth upstream (SigV4 presign / OAuth);
30
- the container is only reachable through the runtime.
31
- - **Single-instance lock** — one microVM per session; many server processes
32
- coexist by design.
33
- - **Channels, cron, evolution, archive daemon, extensions watcher** —
34
- meaningless in an ephemeral per-session microVM; sessions are driven only
35
- through the AgentCore surface.
36
-
37
- Everything else (agent loop, tools, skills, sqlite persistence) is the normal
38
- server. The adapter itself is `packages/server/src/routes/agentcore.ts`,
39
- mounted at the root (the AgentCore contract paths live outside `/api`).
40
-
41
- ## Per-user workspace isolation
42
-
43
- `userWorkspace(base, runtimeSessionId)` maps each runtime session id to
44
- `<HALO_WORKSPACE>/users/<sanitized-id>/` — an isolated workspace with its own
45
- `.halo/` (sqlite + session files). The id chain:
46
-
47
- DDB `agentcore-demo-users` UUID → login response → frontend uses it as
48
- sessionId → presign Lambda signs it into
49
- `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` → adapter routes the workspace.
50
-
51
- So **1 user = 1 runtime session id = 1 workspace**: users never see each
52
- other's history; reconnecting with the same id resumes the same conversation.
53
- `HALO_WORKSPACE` points at an EFS mount (`/mnt/workspace` in the CDK stack,
54
- `/mnt/efs` by the Dockerfile default) — microVMs are ephemeral, EFS makes
55
- the workspaces survive session termination and image rollouts.
56
- `~/.halo/global/runs.db` (the [run ledger](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb)) lives under the container's `HALO_HOME`, which is **not** on EFS — it dies with the microVM, so the restart nudge doesn't apply in agentcore mode (the writes themselves are harmless, just moot).
57
-
58
- ## WS protocol quirks (why /init exists)
59
-
60
- The AgentCore WS proxy only forwards **client frames containing `inputText`**
61
- and cannot push server frames on connect. Hence:
62
-
63
- - Frontend sends `{"inputText":"/init"}` after open; the server special-cases
64
- it (also accepts `{"type":"init"}`) and replies with a `history` frame —
65
- a full snapshot, which the frontend renders by rebuild-from-scratch (not
66
- append), keyed by a signature so identical snapshots skip re-rendering.
67
- - Frames with no `inputText` text and no `imageRefs` are silently ignored
68
- server-side — the frontend uses `{type:'ping'}` every 30s purely to keep
69
- the proxy from cutting the socket.
70
- - `/session switch` sends a `{type:'switch'}` frame → frontend clears, then
71
- the follow-up history frame rebuilds.
72
-
73
- Slash commands run through the shared `dispatchCommand` with a module-level
74
- `activeOverrides` map keyed by **`runtimeSessionId`** (one entry per runtime
75
- session, holding "which halo session is this socket's current one"). The entry
76
- lives only as long as the socket: `ws.on('close')` deletes it alongside the event
77
- listener, since a reconnect re-resolves from the session id anyway — without the
78
- delete the map grew one permanent entry per runtime session for the container's
79
- whole lifetime (audit B-L3). Every command is `accessLevel: 'full'` by design:
80
- auth terminated upstream at AgentCore, so whoever reached this socket already
81
- owns the isolated per-user workspace behind it.
82
-
83
- ## WS image upload protocol (chunking)
84
-
85
- The AgentCore WS proxy hard-caps a single frame at **64KB** — send more and
86
- the connection is cut with close code 1009 ("Policy violated: message size
87
- limit of 64 KB for a message frame is exceeded", verified against the Tokyo
88
- prod runtime). An inline base64 image blows past that instantly, so images
89
- ride as chunked `image_chunk` frames, assembled server-side, then referenced
90
- by id from the actual message frame:
91
-
92
- ```jsonc
93
- // 1..N chunk frames per image (inputText empty string is required — the
94
- // proxy only forwards client frames that carry an inputText key at all)
95
- {"inputText":"", "type":"image_chunk", "uploadId":"u1", "seq":0, "total":8,
96
- "mimeType":"image/jpeg", "data":"<base64 slice, ≤48KB>"}
97
-
98
- // message frame referencing the finished upload(s)
99
- {"inputText":"describe this", "imageRefs":["u1"]}
47
+ `config.server.runtimeMode === 'agentcore-a2a'` skips:
48
+
49
+ - **Password/JWT gate** and the **single-instance lock** — AgentCore verifies
50
+ the signed request before forwarding; many microVMs coexist by design.
51
+ - **Channels, cron, evolution, archive daemon, extensions watcher, admin WS
52
+ and transcribe proxy** (every WS upgrade gets the router's 400).
53
+ - **The whole `/api/*` surface** (auth middleware included) **and the admin
54
+ static bundle / SPA fallback.** The container answers only `/ping`, `POST /`
55
+ and the card (the only paths AgentCore passes through); everything else is a
56
+ plain JSON 404, no HTML.
57
+ - The normal `/a2a/<path>` mount and the `/a2a-push` receiver. There is no
58
+ outbound A2A from inside the container (`a2a_send` reports itself
59
+ unavailable): a remote's webhook can't reach a microVM.
60
+
61
+ Everything else (agent loop, tools, skills, sqlite) is the normal server. The
62
+ card is `<ws>/.halo/agent-card.json`, seeded (name `Halo`, `skills: []`) if
63
+ missing, never overwritten — edit it to describe the agent.
64
+
65
+ ## Workspace and lease
66
+
67
+ Sessions live in the workspace's `.halo/` and A2A task state in
68
+ `<ws>/.halo/a2a.db`, so tasks survive microVM recycling. `a2a.db` holds push
69
+ tokens, so it (and `-wal` / `-shm`) is hidden from non-full sessions
70
+ (`tools/sandbox.ts`).
71
+
72
+ Each runtime session id gets its own microVM, all mounting the same EFS, so a
73
+ single-writer lease guards it — `<ws>/.halo/agentcore.lease`, plain NFSv4 file
74
+ ops only:
75
+
76
+ - **Activation is lazy.** AgentCore boots microVMs ahead of time (a warm pool)
77
+ and mounts the filesystem only when a session is assigned, at its first
78
+ invocation — at boot `HALO_WORKSPACE` is still the image's empty dir. So
79
+ nothing touches the workspace at boot: the first request other than `/ping`
80
+ creates `.halo/` and seeds the card.
81
+ - **Acquire on request.** Each `POST /` makes one acquisition attempt unless the
82
+ lease is held (concurrent POSTs share one attempt). There is no background
83
+ retry: a refused microVM never polls, so only the microVM callers are actually
84
+ talking to competes. AgentCore can leave microVMs it no longer routes to
85
+ (see gotchas), and a polling one would take the workspace away from the
86
+ fixed session id.
87
+ - The holder rewrites a heartbeat every **10 s**; a lease older than **45 s**
88
+ is stale and taken over by the next request (rename, then a 2 s inline
89
+ recheck that it still names us). The owner is a per-process random id, so a
90
+ successor takes over after release or staleness, whatever its session id.
91
+ - `a2a.db`, the SessionManager and the push sender start only once the lease
92
+ is held; `runtime.lock` is then deleted (its pid probe can't see other microVMs).
93
+ - Without the lease: `POST /` answers JSON-RPC `-32603` "workspace is in use by
94
+ another runtime session — call with the fixed runtime session id", and
95
+ `/ping` reports `Healthy` (never busy) so AgentCore can reap that microVM.
96
+ - **Handoff takes ~45 s in practice, not a graceful release.** Graceful stop
97
+ does try to delete the lease, but on AgentCore the EFS mount is usually
98
+ already detached when SIGTERM arrives (`StopRuntimeSession`, idle reap). The
99
+ next session — the same id too, which comes back on a fresh microVM — waits
100
+ out the stale window and gets `-32603` meanwhile. Measured: 42–47 s
101
+ typically, 1.6 s once when the release did land.
102
+ - On **losing** the lease (heartbeat stalled past the stale window and someone
103
+ took over) the process exits immediately, skipping the graceful flush that
104
+ would write into the new holder's workspace.
105
+
106
+ ### Resume after a mid-task kill
107
+
108
+ The [run ledger](session.md#run-ledger--restart-nudge-for-interrupted-roots-haloglobalrunsdb) lives on the workspace in this mode — `<ws>/.halo/runs.db` (hidden from non-full sessions, like `a2a.db`), not the container's `HALO_HOME`, which dies with the microVM. So a task cut off mid-run (the 8 h `maxLifetime`, a crash, a recycle) resumes on the next microVM:
109
+
110
+ - `onAcquired` opens the ledger (`setRunsDb(createRunsDb(<ws>/.halo))`) **before** `registry.getOrCreate`; `index.ts` opens none in this mode. The SessionManager constructor then runs the server's boot chain — claim `runtime.lock`, orphan reconcile, goal sweep, run-ledger sweep — which drains the rows the dead microVM left and nudges each interrupted root.
111
+ - The `POST /` that acquired the lease waits for those nudges to reach their sessions (`nudgesSettled`) before it is handled, so a GetTask on the interrupted task sees its root running → `WORKING`, not idle.
112
+ - Nothing wakes a new microVM by itself: the resume happens on the **next invocation** — the caller's `a2a_read` / GetTask, or a new send. No caller-side polling.
113
+ - `ownsRuntimes` stays `false`, so the lazy FAILED in `routes.ts` `reconcileStale` remains the fallback for a task the sweep didn't resume (root idle, quiet subtree): "Interrupted by a server restart on the remote. Send again on the same context to resume."
114
+ - Verified locally: `kill -9` mid-task (2 of 6 `sleep 30` calls done) → new process refused for ~45 s, then the first accepted GetTask read `WORKING` and the task completed with all six results.
115
+
116
+ ## Access model
117
+
118
+ "Upstream" strategy: AgentCore verifies the caller's SigV4 before forwarding,
119
+ so the container does no token auth and the card carries no `securitySchemes`.
120
+ Every call is one fixed caller (`accountId: agentcore`) on the fixed workspace,
121
+ mounted at `/` (any sub-path is 404), with the session level from
122
+ `HALO_A2A_ACCESS` — default `workspace`, not `full`. See
123
+ [guide/delegation-and-access.md](../guide/delegation-and-access.md).
124
+
125
+ ## Caller side (any halo server)
126
+
127
+ In the caller server's `~/.halo/secrets/a2a-remotes.yaml` (server-wide, full-only):
128
+
129
+ ```yaml
130
+ remotes:
131
+ agentcore:
132
+ card: https://bedrock-agentcore.ap-northeast-1.amazonaws.com/runtimes/<escaped ARN>/invocations/.well-known/agent-card.json
133
+ auth: sigv4
134
+ push_base: http://<caller's VPC-reachable host>:<port>
100
135
  ```
101
136
 
102
- Limits (all enforced in `acceptChunk` / `claimImageRefs`): ≤4 images per
103
- message, ≤8MB of base64 per image, ≤4 concurrent pending uploads per
104
- connection, ≤256 chunks per upload. Assembly state (`Map<uploadId,
105
- PendingUpload>`) is **per-connection, in-memory only** — a dropped socket
106
- discards any partial uploads and the client just re-sends; there is no
107
- cross-reconnect persistence by design. The server doesn't ack individual
108
- chunks (that'd add N round-trips for no benefit since the proxy forwards
109
- mid-stream frames fine) — it only replies with an `error` frame when a chunk
110
- or `imageRefs` fails validation.
111
-
112
- On a completed reference, the image is decoded and persisted via
113
- `saveInboundMedia` into the user's workspace — the same storage path
114
- telegram uses — and the message text gets a `[图片已保存: <path>]` marker
115
- appended so history replay can show the image was there without re-sending
116
- the bytes.
117
-
118
- ## Session lifecycle (the part everyone gets wrong)
119
-
120
- - Idle timeout (`idleRuntimeSessionTimeout`) terminates a session whose
121
- `/ping` reports `Healthy`; `maxLifetime` (8h) force-terminates even busy
122
- ones; failed health checks kill immediately.
123
- - **An open WebSocket = an in-flight invocation** — the session never counts
124
- as idle while a socket is open. The 30s frontend keepalive therefore keeps
125
- the whole session warm; effective idle timeout ≈ "after the tab closes".
126
- - `/ping` returns `HealthyBusy` whenever any agent session is running
127
- (`registry.list().some(({ sm }) => sm.hasRunningSessions())`) — the official
128
- keep-alive for long tool chains with no open connection.
129
- - Termination is cheap: data is on EFS, next connect cold-starts (~3s) and
130
- history reloads. `stop-runtime-session` kills one session on demand; there
131
- is **no list/get-runtime-sessions API** (observe via CloudWatch `Sessions`
132
- metric + runtime log filtering).
137
+ - `auth: sigv4` signs the card GET and every RPC (service `bedrock-agentcore`,
138
+ region from the host name, SDK default credential chain) — `a2a/outbound.ts`.
139
+ - It sends a fixed `X-Amzn-Bedrock-AgentCore-Runtime-Session-Id` =
140
+ `halo-` + sha256(caller workspace realpath + remote name), 69 chars: the same
141
+ (workspace, remote) always lands on the same microVM, so card, RPCs and
142
+ follow-ups share it.
143
+ - A `-32054` "retry" error (AgentCore's RetryableConflict — the session's
144
+ microVM is being provisioned or torn down) is retried with backoff
145
+ 0.5 → 1 → 2 → 4 → 8 s, re-signed each time; the plain ConflictException
146
+ shares the code but isn't retried.
147
+ - `push_base` is where the container posts task results (the webhook is
148
+ `<push_base>/a2a-push/<id>`); default is the caller's own public URL, which
149
+ must be reachable from the runtime's network. A failed push is retried with
150
+ backoff and given up after 10 attempts or **1 h** of age, whichever comes
151
+ first (the age cap is 24 h elsewhere), because pending pushes keep `/ping`
152
+ busy.
153
+ - The container checks the push URL against `general.a2a.url_allowlist` in its
154
+ own settings.yaml (default `100.64.0.0/10,*.ts.net`): a private-IP or
155
+ plain-http `push_base` is refused unless listed there.
156
+ - Only `halo server` calls out: `a2a_send` is available at every access level
157
+ except the A2A read-only profile, and unavailable inside the AgentCore
158
+ container itself and in CLI/TUI.
159
+
160
+ ## Session lifecycle
161
+
162
+ - `/ping` returns `HealthyBusy` while the lease is held and an agent session is
163
+ running (`hasRunningSessions()`) or a push is pending — the keep-alive for
164
+ long tool chains with no open request.
165
+ - Idle timeout (`idleRuntimeSessionTimeout`, **60 s**) terminates a session
166
+ whose `/ping` reports `Healthy` — the microVM exits on its own once idle (no
167
+ self-stop, no extra IAM). So the 8 h `maxLifetime`, which force-terminates
168
+ even busy ones, bounds one busy stretch rather than accumulating across
169
+ tasks; a task cut off by it resumes (see above). Failed health checks kill
170
+ immediately.
171
+ - Termination is cheap: state is on EFS; the next call cold-starts a microVM
172
+ and takes the lease once the old one is stale (~45 s, see above). A call in
173
+ that gap gets the lease-busy `-32603`; the caller side (`a2a/outbound.ts`)
174
+ retries it at 5 / 10 / 15 / 20 s, matched on the code plus the `LEASE_BUSY`
175
+ message prefix — keep that text stable.
176
+ There is **no list/get-runtime-sessions API** (observe via the CloudWatch
177
+ `Sessions` metric + runtime log filtering).
133
178
 
134
179
  ## Ops crib sheet
135
180
 
@@ -142,8 +187,35 @@ the bytes.
142
187
  newer runtimes).
143
188
  - VPC mode has no public IP: private subnets need `0.0.0.0/0 → NAT` or all
144
189
  egress (Bedrock included) hangs → opaque 502s.
145
- - Full operational detail + deploy walkthrough:
146
- `packages/agentcore-demo/README.md`.
190
+
191
+ ## Gotchas
192
+
193
+ Found deploying `halo_a2a` (ap-northeast-1, 2026-10-09):
194
+
195
+ - **EFS is mounted only at a session's first invocation**, not at microVM
196
+ boot (warm pool) — hence lazy activation. Anything touching the workspace
197
+ at startup writes into the image's empty dir (first card fetch: 500, ENOENT).
198
+ - **StopRuntimeSession stops one microVM.** Concurrent first calls on a new
199
+ session id can land on several; the others linger until the idle timeout (60 s).
200
+ Harmless now that refused microVMs don't poll the lease.
201
+ - **EFS is gone by SIGTERM** — graceful lease release usually can't land
202
+ (`missing` / `Unknown system error -512`); handoff = stale window.
203
+ - **curl `--aws-sigv4` can't sign the ARN-form invoke URL** (curl 8.5 doesn't
204
+ double-encode the escaped `%3A` / `%2F` in the canonical path → 403). Use
205
+ the id form `…/runtimes/<runtime-id>/invocations?accountId=<acct>` for
206
+ JSON-RPC (`accountId` required). The card is served only on the ARN form,
207
+ so sign that with botocore or the server's own `sigv4Headers` (smithy), which
208
+ both get it right.
209
+ - **aws-cli `create-agent-runtime` has no `metadataConfiguration`** (2.36);
210
+ only `update-agent-runtime` takes `requireMMDSV2` — create, then update.
211
+ - **`pnpm deploy --prod` rewrites the repo's
212
+ `node_modules/.pnpm-workspace-state-v1.json`** as a prod-only install; the
213
+ next `pnpm <script>` in that checkout then tries `install --production` and
214
+ aborts without a TTY. Back the file up around `pnpm deploy` (or run builds
215
+ with `pnpm_config_verify_deps_before_run=warn` — `pnpm_config_*`, not
216
+ `npm_config_*`).
217
+ - `-32054` RetryableConflict never showed up in practice (concurrent cold
218
+ first calls, bursts after a stop); the caller retry stays as cheap insurance.
147
219
 
148
220
  ## Observability
149
221
 
@@ -41,7 +41,7 @@
41
41
  │ /api/wecom/* (wecom descriptor) │
42
42
  │ /ws → setupWebSocketHandler({wss,registry}) │
43
43
  │ /* → static frontend (Next.js out/) │
44
- │ /ping, /invocations → createAgentCoreRoutes() (agentcore mode) │
44
+ │ /ping, POST / (A2A) → a2a/agentcore.ts (agentcore-a2a mode) │
45
45
  └─────────────────────────────────────────────────────────────────────┘
46
46
  ```
47
47
 
@@ -188,7 +188,7 @@ File: `ws/terminal-manager.ts`. Spawns a shell via node-pty. On disconnect, deta
188
188
  Files: `evolution/{ticker,evo-wrapper,enqueue,spawn,archive}.ts`, `db/evo-db.ts`, `routes/evolution.ts`. Internal agents `__evo_agent__` / `__score__` / `__apply_agent__` (in `templates/agents/`) drive a 12-phase orchestration: snapshot → evo drafts → wrapper dry-runs → scorer grades → reviewer approves → apply agent merges → wrapper history-snapshots + cps to main. Per-task wrapper Node child process owns all sub-cli calls; ticker is stateless and lives in the server. State in `~/.halo/global/evo.db`. See [evolution.md](evolution.md).
189
189
 
190
190
  ### Run ledger — restart nudge for interrupted roots
191
- Files: `agents/run-ledger.ts` (sweep + skip rules) + `db/runs-db.ts` (the global `~/.halo/global/runs.db` table `running_sessions`, same singleton pattern as `evo.db`/`cron.db`). `runSession` inserts its session id on entry and deletes it in the finally (server-only, gated on `reconcileOrphansOnBoot`), so whatever is left at boot is exactly what the previous process was mid-run on; the sweep drains those rows and nudges each interrupted root. `index.ts` runs this eagerly: right after constructing the `SessionManagerRegistry` and before `bootChannels`, it loops `listRunningWorkspaces()` and claims+builds a `SessionManager` for each one that isn't already owned by another live server, rather than waiting for someone to open the workspace. See [design/session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb).
191
+ Files: `agents/run-ledger.ts` (sweep + skip rules) + `db/runs-db.ts` (the global `~/.halo/global/runs.db` table `running_sessions`, same singleton pattern as `evo.db`/`cron.db`). `runSession` inserts its session id on entry and deletes it in the finally (server-only, gated on `reconcileOrphansOnBoot`), so whatever is left at boot is exactly what the previous process was mid-run on; the sweep drains those rows and nudges each interrupted root. `index.ts` runs this eagerly: right after constructing the `SessionManagerRegistry` and before `bootChannels`, it loops `listRunningWorkspaces()` and claims+builds a `SessionManager` for each one that isn't already owned by another live server, rather than waiting for someone to open the workspace. See [design/session.md](session.md#run-ledger--restart-nudge-for-interrupted-roots-haloglobalrunsdb).
192
192
 
193
193
  ## REST routes
194
194
 
@@ -31,7 +31,7 @@ The admin editor's preview set was compile-time: eight plugins registered in `pr
31
31
  | `extensions` | ✓ | non-empty; each `^\.[a-z0-9]+$` (dot, lowercase); matched against the lowercased file ext |
32
32
  | `entry` | ✓ | relative HTML path, no leading `/`, no `..`; file must exist |
33
33
  | `priority` | – | `default` (default; opens the file directly) \| `option` (open-with menu only) |
34
- | `capabilities` | – | `"save"` \| `"media"` (iframe gets `allow="microphone; display-capture; clipboard-write"`) \| `"transcribe"` (may open the server's [transcription proxy](#streaming-transcription-proxy)); **unknown value = manifest error** (reject rather than let the host silently drop a declared capability) |
34
+ | `capabilities` | – | `"save"` \| `"media"` (iframe gets `allow="microphone; display-capture; clipboard-write"`) \| `"transcribe"` (may open the server's [transcription proxy](#streaming-transcription-proxy)) \| `"fs-read"` (read-only `fs` over the workspace and the whole machine + the host file picker, bundle or not — see [Scoped file access and the picker](#scoped-file-access-and-the-picker-fs-read)); **unknown value = manifest error** (reject rather than let the host silently drop a declared capability) |
35
35
  | `bundle` | – | boolean, default `false`; `true` = every suffix names a **directory** (see [Bundle extensions](#bundle-extensions)). Non-boolean, or `true` together with `save` (`bundle extensions cannot declare save`) = manifest error |
36
36
  | `platforms` | – | non-empty array of `web` \| `desktop-mac` \| `desktop-win` \| `desktop-linux` (deduped); omitted = everywhere. Empty / unknown value = manifest error |
37
37
  | `settings` | – | `{ params?: [field…], secrets?: [field…] }`, field = `{ key, description?, description_zh?, default?, type?, options? }` (skill `config.yaml` format). Validated **strictly** (a skill's config.yaml is parsed leniently): `key` `^[a-z][a-z0-9_]{0,63}$`, unique across both lists, `type` ∈ string\|int\|float\|boolean\|enum (enum needs `options`), scalar `default` stringified; anything else = manifest error. `ExtensionInfo.settings` carries the declarations, never values. See [Extension settings](#extension-settings) |
@@ -113,12 +113,14 @@ Types in `packages/core/src/protocol/extension-frames.ts` (`EXTENSION_PROTOCOL_V
113
113
 
114
114
  | Dir | `type` | Fields | Semantics |
115
115
  |---|---|---|---|
116
- | ext→host | `ready` | `protocol` | listener installed; host sends nothing before it. v1 host doesn't read `protocol` (field reserved for negotiation) |
117
- | host→ext | `init` | `protocol`, `file{name,path,size,ext}`, `capabilities`, `theme`, `themeVars`, `bundle`, `platform`, `lang`, `export` | once, right after `ready`; `capabilities` = what the host grants; for a bundle `file` is the directory; `platform` = host platform, `lang` = `zh`\|`en` admin UI language; `theme` / `themeVars` see [Theme tokens](#theme-tokens); `export` = this host accepts `export` frames (see [Export](#export)) — older hosts omit it, so show export UI only when it is `true` |
116
+ | ext→host | `ready` | `protocol` | listener installed; host sends nothing before it. v1 host doesn't read `protocol` (field reserved for negotiation). A second `ready` (the extension reloaded itself) is not ignored: the host re-sends `init` (+ `load`, except to a bundle) and clears the tab's dirty flag, since the reloaded document holds nothing |
117
+ | host→ext | `init` | `protocol`, `file{name,path,size,ext}`, `capabilities`, `theme`, `themeVars`, `bundle`, `platform`, `lang`, `export` | right after each `ready`; `capabilities` = what the host grants; for a bundle `file` is the directory; `platform` = host platform, `lang` = `zh`\|`en` admin UI language; `theme` / `themeVars` see [Theme tokens](#theme-tokens); `export` = this host accepts `export` frames (see [Export](#export)) — older hosts omit it, so show export UI only when it is `true` |
118
118
  | host→ext | `load` | `buffer`, `mtime` | file bytes (transferred); resent when the file changes on disk and the doc isn't dirty → "replace current document". **Never sent to a bundle** |
119
119
  | ext→host | `dirty` | `dirty` | needs `save` (or `bundle`, where it means "busy"); otherwise ignored + `console.warn` |
120
- | ext→host | `fs` | `id`, `op: read\|write\|append\|list\|stat`, `path`, `buffer?` | bundle only (else `fs-result{denied}` + warn); `path` bundle-relative POSIX, validated before any request |
120
+ | ext→host | `fs` | `id`, `op: read\|write\|append\|list\|stat`, `path`, `scope?: workspace\|system`, `buffer?` | no `scope`: bundle only (else `fs-result{denied}` + warn), `path` bundle-relative POSIX, all ops. With `scope` (needs `fs-read`, bundle or not; else `denied` + warn): `read` / `stat` / `list` only (`write` / `append` → `denied`), `workspace` = workspace-relative POSIX, `system` = absolute normalized path (POSIX `/…`, Windows `C:/…`, forward slashes, no `..`). Paths are validated before any request — see [Scoped file access](#scoped-file-access-and-the-picker-fs-read) |
121
121
  | host→ext | `fs-result` | `id`, `ok`, `buffer?` \| `entries?` \| `size?`+`mtime?` — or `code: not-found\|invalid-path\|denied\|io`, `error` | exactly one per `fs`, same `id`; `read` buffer transferred |
122
+ | ext→host | `pick` | `id`, `accept: string[]`, `start?` | `fs-read` only (else `pick-result{denied}` + warn): open the host's file picker. `accept` = lower-case suffixes with the dot (`['.zip']`), `[]` = any file; `start` = workspace-relative or absolute dir to open at (default: the bundle dir for a bundle, else the open file's dir) |
123
+ | host→ext | `pick-result` | `id`, `ok: true`, `path`, `name`, `size` — or `ok: false`, `code: cancelled\|denied`, `error` | exactly one per `pick`, same `id`. `path` is workspace-relative when the file's realpath is inside the workspace, else absolute (read it with `scope: 'system'`). A second `pick` while a picker is open → `cancelled` |
122
124
  | host→ext | `save-request` | – | Save button pressed and doc dirty; ext answers `save` |
123
125
  | ext→host | `save` | `buffer` | no `save` capability → `save-error{denied}`, no network; a save while one is in flight is dropped |
124
126
  | host→ext | `saved` | `mtime` | PUT succeeded; ext clears dirty, `mtime` is the new baseline |
@@ -170,20 +172,39 @@ An extension that can render its document as an image (draw.io *File ▸ Export
170
172
 
171
173
  ### MRU, keep-alive, upgrade, uninstall
172
174
 
173
- - `editor-panel` keeps the last 5 preview tabs mounted (`PREVIEW_CACHE_SIZE`); "heavy" previews mount active-only. `isHeavyPreview` is true for an extension **without** `save` and not `bundle`: nothing to lose on unmount, and each viewer may hold a WebGL context (Chrome caps ~16 per page). An extension **with** `save` stays in the MRU because its dirty state lives only in the iframe; a dirty preview is never evicted past the cap. The active heavy preview's wrapper is keyed by path: `FilePreview`'s `Dispatch` is keyed only by file extension and a host posts the file bytes once per mount, so switching between two heavy tabs of the same type (two `.ipynb`) used to keep showing the first file.
175
+ - `editor-panel` keeps the last 5 preview tabs mounted (`PREVIEW_CACHE_SIZE`); "heavy" previews mount active-only. `isHeavyPreview` is true for an extension **without** `save` and not `bundle`: nothing to lose on unmount, and each viewer may hold a WebGL context (Chrome caps ~16 per page). An extension **with** `save` stays in the MRU because its dirty state lives only in the iframe; a dirty preview is never evicted past the cap. The mounted previews render in **path-sorted** order, not MRU order: promoting a tab reorders the MRU, React would then move the keyed nodes with `insertBefore`, and moving an iframe in the DOM reloads it — a running emulator or editor would start over on every tab switch. With a stable order, insertions and removals never move the siblings. The active heavy preview's wrapper is keyed by path: `FilePreview`'s `Dispatch` is keyed only by file extension and a host posts the file bytes once per mount, so switching between two heavy tabs of the same type (two `.ipynb`) used to keep showing the first file.
174
176
  - **Upgrade while mounted** (`extension:changed` gives the tab a new `info.version`): not dirty → remount with the new URL; dirty → non-blocking banner "updated to vX" with a Reload link (`reload` asks to discard first).
175
177
  - **Uninstall while mounted**: not dirty → `resolve()` drops the candidate and the tab falls to the next one (built-in plugin if the ext has one, else the fallback page). Dirty → `Dispatch` keeps rendering the last-shown extension (`uninstalled` banner) so the edits can still be saved; the loaded iframe already holds its assets, though a fresh load would 404. Once saved, the tab falls through the same way.
176
178
  - **Fallback page** (`UnsupportedPreview`): "This file type has no built-in preview. Extensions for more file types are available at halo-hub ↗" + Open as Text / Download. The link is a constant — no lookup, no per-ext URL, no online recommendation (would need a network call or a bundled index that goes stale).
177
179
  - **Settings → Extensions** (`features/settings/extensions-view.tsx`): upload zip, list (errors in red), remove with `confirmAction`. It never re-fetches after a mutation — `extension:changed` updates it.
178
180
 
181
+ ### Immersive maximize
182
+
183
+ Maximizing the canvas on a **viewer** extension (an emulator, a model viewer) hides every bit of chrome around it. The predicate is `isImmersiveViewer(info)` in `registry.ts`: any extension **without** `save`, bundles included (a bundle's state lives in its own directory, not behind a Save button). It is *derived* — `editorStore.maximized` stays the only persisted state, and leaving immersive is `setMaximized(false)`; the extension host decides (it knows which candidate actually rendered), publishing itself through `claimImmersive()` (`features/editor/immersive.ts`) while `maximized` is on and the pane's active tab (`ImmersivePane` context, provided by `EditorPanel` for the focused pane only) is its path.
184
+
185
+ - **Pure CSS.** While immersive, the host's banners + iframe region become a `fixed inset-0 z-[70]` overlay, and `EditorPanel` / `workspace-layout` class-hide the panel header, tab bar, non-focused panes and the floating bottom panel (`hidden`, never unmounted). The iframe is never reparented or remounted, so the viewer keeps its state.
186
+ - **Real fullscreen** only from the maximize click: the button calls `armImmersiveFullscreen()` (valid for that click's task), and the host that goes immersive takes the arm inside the click's user activation and requests fullscreen on `<html>`. A `maximized` restored from localStorage has no activation and stays in-page. The browser or Electron ending fullscreen ends immersive too (one Esc, one exit) — unless the viewer had its own fullscreen (an emulator's canvas), which keeps that Esc.
187
+ - **Exit**: **Esc** — the iframe is same-origin, so its keydown is watched inside each loaded document (the viewer's own fullscreen takes that Esc first) — or the top pill (`editor.immersive.exit`, "× Exit · Esc"). Mouse: top-centre, shown for 2.5 s on entering, pinned while the pointer is within 48 px of the viewport top (watched by listeners, no hot zone over the iframe), hidden 1.5 s after it leaves. Touch: always on screen as a compact × at top-left, where viewers keep their title label; a tap in the top band widens it for 3 s, and the pill exits only for a press that started on it. On exit the host hands focus back to the iframe, except for "Back to chat".
188
+ - **Reply toast.** The chat is hidden, so when any loaded chat tab's turn settles while immersive (`workspace-layout`, the same hook as the finish notification) an in-page toast (`z-[80]`, ~8 s, newest session first) says "Agent replied · <title>" (or "N sessions replied") with **Back to chat**: it exits immersive without refocusing the iframe, opens that session's tab, switches the bottom panel to Chat and dispatches `halo:focus-chat-input` so the caret lands in the composer (`message-input.tsx` listens). The toast is dropped as soon as immersive ends.
189
+ - The host file picker (`pick`, below) renders in a body portal at `z-[90]`, above the overlay and its toast.
190
+
179
191
  ### Bundle extensions
180
192
 
181
193
  A `bundle: true` extension opens a **directory** whose name ends in one of its suffixes (`Standup.htrans/`) — a package the extension owns, first user `htrans` (meeting recorder). Routing is separate from files: `resolveBundle(ext)` lists bundle extensions only, `resolve(ext)` never does (a *file* `x.htrans` is untouched), and `isBundleName(name)` = a `default` bundle extension that runs here claims the suffix.
182
194
 
183
- - **Explorer**: such a directory gets a package icon; clicking (or Enter on) the row opens it as a preview tab like a file, double-click too; only the chevron expands it, so its contents stay browsable. Context menu adds Open to the Side. "New File" with a bundle suffix runs `mkdir` and opens the new bundle. The tab has no Open as Text / Download. Persisted tabs carry `bundle: true`; on restore the tab reopens only if a bundle extension still claims it, otherwise it is dropped (the directory is never read as text).
195
+ - **Explorer**: such a directory gets a package icon; clicking (or Enter on) the row opens it as a preview tab like a file, double-click too; only the chevron expands it, so its contents stay browsable. Context menu adds Open to the Side. "New File" with a bundle suffix runs `mkdir` and opens the new bundle — both the tree's inline New File and the Explorer header's New File button (which would otherwise create an empty file no extension can open). The tab has no Open as Text / Download. Persisted tabs carry `bundle: true`; on restore the tab reopens only if a bundle extension still claims it, otherwise it is dropped (the directory is never read as text).
184
196
  - **Access**: no `load`; the extension reads and writes inside its directory with `fs` frames. The host maps them to `GET /files/download?inline=1` (read), `GET /files/tree` (list), `GET /files/stat` (stat) and `PUT /files/raw?create=1&root=<bundle>[&append=1]` (write / append; a vanished bundle → `not-found`), and serializes write / append **per path** (`createKeyedQueue`), so appends land in request order; other ops run concurrently. `file:changed` never reloads a bundle.
185
197
  - **Busy**: `dirty: true` = "recording, don't drop me" → tab dot, close confirm (also when the same bundle is open in the other split pane — each pane runs its own iframe), MRU pin, page-unload warning: the browser always shows its generic beforeunload prompt; the desktop shell shows a native confirm, which the admin only triggers while a tab is busy or unsaved. Deleting, renaming or drag-moving a busy bundle (or an ancestor directory) asks the same close confirm first, then closes the tab **before** the file operation. Ctrl+S / Save does nothing on a bundle tab. Bundle extensions are never heavy (they stay in the MRU like `save` extensions). fs results that arrive after the iframe was remounted (retry / upgrade) are dropped.
186
198
 
199
+ ### Scoped file access and the picker (`fs-read`)
200
+
201
+ A viewer extension (emulator, model viewer) otherwise sees only its own file or bundle. `fs-read` lets it read elsewhere on the machine and lets the user choose what to read. UX gating in the host, **not** an isolation boundary: the iframe is same-origin with the admin cookie and could call `/api/fs/raw` itself, which is why the guard below sits on the routes.
202
+
203
+ - **`fs` with `scope`** (`extension-host-logic.ts` `onFsFrame`): `workspace` → the same `/files/*` read routes with a workspace-relative path; `system` → admin-cookie routes by absolute path: `read` → `GET /fs/raw` (streamed bytes), `stat` → `GET /fs/stat`, `list` → `GET /fs/browse?files=1` (name + type per entry). Validation happens before any request: `isSystemPath` (POSIX `/…` or `C:/…`, forward slashes, no NUL, no empty / `.` / `..` segment); unknown scope or op, a missing capability, or `write` / `append` → `fs-result{denied}`; a bad path → `invalid-path`.
204
+ - **`pick`**: opens `FilePicker` (`shared/components/file-picker.tsx`) — a whole-machine browser with Workspace and Home shortcuts, a path box, a breadcrumb and the `accept` filter (directories always show; keyboard: arrows / Enter / Backspace for up / Esc cancels, the capture listener swallowing Esc before maximize sees it). One picker at a time, tied to the iframe attempt that asked; a reply for a since-remounted iframe is dropped. If the viewer is in its own fullscreen the host first drops back one level, so the picker isn't covered. On pick, the host `stat`s both the file and the workspace and compares **realpaths**, so a symlinked workspace or ROM dir lands on the right side: inside → workspace-relative `path`, else the absolute one. Closing it answers `cancelled`.
205
+ - **Hidden-path guard** (`routes/files.ts`): `/fs/stat`, `/fs/raw` and `/fs/browse?files=1` all refuse the sandbox hidden lists (`~/.halo/secrets`, `~/.ssh`, `.git-credentials`, the global dbs …) via `isHiddenHostPath` (exported from `tools/sandbox.ts`; no second list), judged on the **realpath** so a symlink can't reach them. A refused path answers with the same 404 and message as a missing file, and a listing silently omits hidden entries. `/fs/browse?files=1` lists the same noise-filtered tree as `/files/tree` but with dot-entries shown and symlinks counted as what they point at; `sizes=1` adds file sizes. The default folder-picker response is unchanged. See [dev/api.md → Filesystem browse](../dev/api.md#filesystem-browse-not-project-scoped--for-the-workspace-picker).
206
+ - Settings → Extensions shows a "Reads files on this machine" tag for an extension that declares it.
207
+
187
208
  ### Platforms
188
209
 
189
210
  `currentPlatform()` (`registry.ts`, computed once): UA contains `Electron/` → `desktop-mac|win|linux` by OS, else `web`. An extension whose `platforms` excludes it is skipped by every resolve (routes as if not installed; a bundle directory is a plain folder), the fallback page says `"<name>" only runs on: …`, and Settings → Extensions shows a platforms badge plus a gray "not supported here" tag.
@@ -194,7 +215,7 @@ An installed extension with manifest `settings` gets one Settings section (`sett
194
215
 
195
216
  ### Streaming transcription proxy
196
217
 
197
- WS `/api/transcribe/stream?ext=<id>&lang=<auto|xx-XX|xx-XX,yy-YY,…>` (`routes/transcribe-ws.ts`) relays an extension's live audio to **Amazon Transcribe streaming** so AWS credentials never reach the browser. `index.ts` runs both WS servers in `noServer` mode behind one http `upgrade` dispatcher (`/ws` → admin socket, unchanged incl. AgentCore; `/api/transcribe/stream` → proxy, not mounted in AgentCore mode; any other path → socket destroyed) — a `ws` server attached with `{ server, path }` would 400 every other path's handshake.
218
+ WS `/api/transcribe/stream?ext=<id>&lang=<auto|xx-XX|xx-XX,yy-YY,…>` (`routes/transcribe-ws.ts`) relays an extension's live audio to **Amazon Transcribe streaming** so AWS credentials never reach the browser. `index.ts` runs both WS servers in `noServer` mode behind one http `upgrade` dispatcher (`/ws` → admin socket and `/api/transcribe/stream` → proxy, neither mounted in AgentCore mode, where every upgrade is refused; any other path → socket destroyed) — a `ws` server attached with `{ server, path }` would 400 every other path's handshake.
198
219
 
199
220
  - **Handshake**: login cookie (`isAuthenticated`, same as `/ws`) else 401; `ext` must be installed and declare `transcribe` else 403. The extension iframe is same-origin, so its WS carries the cookie.
200
221
  - **Upstream**: `StartStreamTranscription` (`pcm`, 16 kHz). `lang=auto` (default) → `IdentifyMultipleLanguages` + `LanguageOptions` = `ext-<id>.params.auto_languages` → manifest default → `zh-CN,zh-HK,en-US` (Cantonese must be a candidate, otherwise it is transcribed as wrong Mandarin text); one code (`^[a-z]{2}-[A-Z]{2}$`) → `LanguageCode`; a comma-separated list of 2–5 distinct codes → `IdentifyMultipleLanguages` + `LanguageOptions` = exactly that list (htrans sends `zh-CN,en-US` / `zh-HK,en-US` for its Mandarin + English / Cantonese + English options); anything else → `error bad-request`, upstream never called. "One dialect per language" in a list is left to Transcribe (its BadRequest maps to `bad-request`). Region `ext-<id>.params.region` → manifest default → `us-east-1`. Credentials: all three of `ext-<id>.secrets.access_key_id` / `secret_access_key` / `session_token` empty → the SDK default chain (`defaultProvider()`); both keys set → static keys (+ `session_token` if set); any other partial set → `error credentials` naming the missing key(s), upstream never called (no silent switch to the machine's identity). All read per connection.
@@ -225,9 +246,9 @@ The hub repo keeps one directory per extension (`glb` / `ipynb` commit their ven
225
246
 
226
247
  ## Extension capability boundary
227
248
 
228
- An extension **can**: receive one file's bytes and metadata; render in its own iframe; load static assets from its own directory via the path token; report `error`; receive `theme` and `lang`; with `save`, report `dirty` and hand back bytes for the host to write to **that same file**, and (on `init.export` hosts) hand back export bytes the host writes as a **new sibling file** after an overwrite confirm; with `bundle`, read / write / append / list / stat **inside its bundle directory** via `fs`; with `media`, use the microphone and screen capture; with `transcribe`, stream audio to the server's transcription proxy (results only — credentials stay server-side).
249
+ An extension **can**: receive one file's bytes and metadata; render in its own iframe; load static assets from its own directory via the path token; report `error`; receive `theme` and `lang`; with `save`, report `dirty` and hand back bytes for the host to write to **that same file**, and (on `init.export` hosts) hand back export bytes the host writes as a **new sibling file** after an overwrite confirm; with `bundle`, read / write / append / list / stat **inside its bundle directory** via `fs`; with `fs-read`, **read** (read / stat / list) any file in the workspace or on the machine outside the sandbox hidden lists, and ask the user to pick one; with `media`, use the microphone and screen capture; with `transcribe`, stream audio to the server's transcription proxy (results only — credentials stay server-side).
229
250
 
230
- An extension **cannot** (sandbox + protocol): open popups, downloads, forms or navigate the top window; use camera / autoplay, or mic / screen capture without `media` (`allow=""`); add admin UI (toolbar, commands, sidebar); talk to other iframes. The **protocol** gives it no way to read or write any workspace file except the open one, or outside its bundle directory. Since the host grants `allow-same-origin` (see Host), the sandbox no longer isolates it from the admin's origin: a hostile extension *could* script `parent.document` or call the cookie-authed `/api/*` (measured: `fetch('/api/extensions')` from inside → 200). That is the accepted trust model — installing an extension is like installing a skill — not a gap to patch per route. **Outbound network is not blocked** — no CSP is injected, so `fetch('https://…')` works where the remote allows CORS; trust model is "code the user chose to install" (same as a skill), and hub policy is offline-capable extensions. A hard block would be one `Content-Security-Policy` header on the asset route, no protocol change.
251
+ An extension **cannot** (sandbox + protocol): open popups, downloads, forms or navigate the top window; use camera / autoplay, or mic / screen capture without `media` (`allow=""`); add admin UI (toolbar, commands, sidebar); talk to other iframes. The **protocol** gives it no way to write any workspace file except the open one (or inside its bundle directory), and no way to read others without `fs-read`. Since the host grants `allow-same-origin` (see Host), the sandbox no longer isolates it from the admin's origin: a hostile extension *could* script `parent.document` or call the cookie-authed `/api/*` (measured: `fetch('/api/extensions')` from inside → 200). That is the accepted trust model — installing an extension is like installing a skill — not a gap to patch per route. **Outbound network is not blocked** — no CSP is injected, so `fetch('https://…')` works where the remote allows CORS; trust model is "code the user chose to install" (same as a skill), and hub policy is offline-capable extensions. A hard block would be one `Content-Security-Policy` header on the asset route, no protocol change.
231
252
 
232
253
  Adding a capability: (1) extend `ExtensionCapability` + the `CAPABILITIES` whitelist in `registry.ts`; (2) add the frame type in `extension-frames.ts`; (3) gate it in `extension-host-logic.ts` (undeclared → ignore + warn, like `save`); (4) if it needs the server (e.g. reading a sibling file), the **host** calls `/api/files/*` — an extension never touches the API directly. Install, scan, watcher and registry stay untouched.
233
254
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- A vendor-neutral observability layer built on the official OpenTelemetry JS SDK. One setting, `general.observability.endpoint`, turns on OTLP http/protobuf export of traces, metrics and logs to whatever collector the operator points at — empty means fully off. Principle: zero AWS (or any vendor) code in the server — SigV4 signing, backend endpoints, and resource enrichment all live in the collector, not in halo.
5
+ A vendor-neutral observability layer built on the official OpenTelemetry JS SDK. One setting, `general.observability.endpoint`, turns on OTLP http/protobuf export of traces, metrics and logs to whatever collector the operator points at — empty means fully off. It applies to every process that runs the agent loop: the server, `halo cli` and `halo tui` — and so the cron jobs and evolution runs, which are spawned `halo cli` children. Principle: zero AWS (or any vendor) code in the server — SigV4 signing, backend endpoints, and resource enrichment all live in the collector, not in halo.
6
6
 
7
7
  ## Configuration
8
8
 
@@ -13,7 +13,9 @@ A vendor-neutral observability layer built on the official OpenTelemetry JS SDK.
13
13
  | `general.observability.headers` | string, **secret** | `''` | Extra headers for every OTLP request, comma-separated `k=v` (e.g. `authorization=Bearer …`). Restart required. |
14
14
  | `general.observability.capture_content` | boolean | `false` | Put prompt / completion / tool argument+result text on spans (`gen_ai.*.messages` etc.). Off = metadata only (model, tokens, latency, tool names). Restart required. |
15
15
 
16
- All four keys are `globalOnly` and take effect on server restart — `otel.ts` reads them once at boot, before anything else touches the OTel API.
16
+ All four keys are `globalOnly` and take effect on server restart — `otel.ts` reads them once at boot, before anything else touches the OTel API. A `halo cli` / `halo tui` process reads them the same way at its own start, so a change reaches the next run.
17
+
18
+ **Which process exported.** Every process shares `service.name`, so the resource also carries `halo.process` = `server` \| `cli` \| `tui` (`HaloProcess` in `otel.ts`; `initObservability()` defaults to `server`). Cron and evolution children are `halo cli` runs, so they report `cli`; their `session.id` (`cron-<jobId>`, or the `__evo_agent__`-style agent name) tells them apart from a hand-run cli. The startup log line names it: `[Observability] OTLP export → … (service.name=…, halo.process=cli, …)`.
17
19
 
18
20
  **Env-var mapping.** `initObservability()` maps settings onto the standard `OTEL_*` env vars with `??=`, so an operator who already exports them (or `--require`s an external distro) wins:
19
21
 
@@ -35,17 +37,23 @@ No `OTEL_EXPORTER_OTLP_ENDPOINT` after the mapping → `enabled=false`, `initObs
35
37
  | module | role |
36
38
  |---|---|
37
39
  | `packages/server/src/observability/otel.ts` | Bootstrap. `initObservability()` / `shutdownObservability()`, the `enabled` gate, `tracer` / `getMeter()` / `otelLogger` / `captureContent()` exports. |
38
- | `packages/server/src/observability/otel-sdk.ts` | The ONE module that imports the SDK packages and OTLP exporters. |
40
+ | `packages/server/src/observability/otel-sdk.ts` | The ONE module that loads the SDK packages and OTLP exporters (`import()`ed inside `registerSdk`, see below). |
39
41
  | `packages/server/src/observability/genai-spans.ts` | The span model: `beginTurn` / `onAgentEvent` / `recordRetry` / `endTurn`. |
40
42
  | `packages/server/src/agents/session-manager.ts` | Calls the four hooks above from the turn loop (see below). |
41
43
  | `packages/server/src/logger.ts` | Bridges every `logger.*` call into an OTel LogRecord when `enabled`. |
42
44
  | `packages/server/src/index.ts` | `await initObservability()` (before `initLogger()`) and `await shutdownObservability()` (in `gracefulShutdown`). |
45
+ | `packages/cli/src/harness.ts` · `index.ts` | `initRuntime('cli' \| 'tui')` calls `initObservability(<process>)` before `initLogger()` (the list-only `agents` / `sessions` commands pass nothing, so stay off); the exit flushes below. The server package exports `./observability/otel` for this. |
43
46
 
44
47
  **Hook call sites in `session-manager.ts`** (all in `runAgentTurn`): `beginTurn(session, message)` at the start, `onAgentEvent(session, event)` inside the agent event loop (one call per `AgentEvent`), `recordRetry(kind)` at the 7 retry catch sites, `endTurn(session, {error?})` once after the retry loop (the turn's single exit point, success or failure).
45
48
 
46
49
  **The `enabled` gate.** `otel.ts` exports a single `enabled: boolean`, set once by `initObservability()`. Every hook in `genai-spans.ts` and the `logger.ts` bridge checks it first — an unconfigured server pays nothing beyond one boolean read per hook call.
47
50
 
48
- **Why `otel-sdk.ts` is a dynamic import.** `logger.ts` is shared with the CLI (`halo cli`), which never wants observability. Eagerly importing the SDK module graph would cost ~100ms on every `halo cli` start. `otel.ts` only `import()`s `otel-sdk.ts` after confirming an endpoint is configured, so the unconfigured path (server or CLI) never loads it.
51
+ **Why the SDK is loaded by dynamic import.** The SDK module graph costs ~120ms, which every unconfigured `halo` start (`--version` included) would otherwise pay. `otel.ts` only `import()`s `otel-sdk.ts` after confirming an endpoint is configured, and `otel-sdk.ts` itself `import()`s the SDK / exporter packages inside `registerSdk` rather than at the top: the npm bundle (esbuild) inlines `otel-sdk.ts`, which would hoist top-level imports of those external packages into the bundle's static imports and load them on every start. So the unconfigured path (server, `halo cli`, `halo tui`) never loads the SDK.
52
+
53
+ **Flush on exit (cli / tui).** A short-lived process must push its batched spans / metrics / logs before it goes. `shutdownObservability()` force-flushes and shuts down every provider, is idempotent (a signal can land while the end-of-run flush is in flight), and is **capped at 3 s** (`SHUTDOWN_CAP_MS`) so a dead collector delays an exit but never hangs it; it resolves `false` when the cap fired, and is instant when observability is off. The 3 s sits inside the cron runner's 30 s and the evolution wrapper's 10 s SIGTERM→SIGKILL grace. Three exit paths:
54
+ - **natural end** (one-shot run done, TUI quit, a thrown error): a `beforeExit` hook registered by `initRuntime` flushes; when the cap fired the exporters' retry timers still hold the event loop, so the hook then calls `process.exit()` itself (stdout has drained by then). `beforeExit` never fires on `process.exit()`.
55
+ - **SIGINT / SIGTERM**: the handlers stop the harness first (ending the turn, so its spans are closed and in the batch), then `exitAfterFlush(130 | 143)` (`harness.ts`) flushes and exits. A second SIGINT still hard-exits at once.
56
+ - Any other `process.exit()` skips the flush. On Windows `child.kill('SIGTERM')` delivers no catchable signal, so a timed-out cron run there loses its last batch.
49
57
 
50
58
  **Why the meter is lazy.** The `@opentelemetry/api` tracer and logger are proxies — they stay no-op until a real provider is registered, then transparently start forwarding, so `tracer` / `otelLogger` can be module-level consts. The metrics API has **no proxy**: a `Meter` fetched via `metrics.getMeter()` before `setGlobalMeterProvider()` runs is a permanent no-op forever after, even once a provider registers later. `getMeter()` in `otel.ts` is therefore a function, not a const, and `genai-spans.ts` calls it lazily on first metric emission (`instruments()`, cached after).
51
59
 
@@ -61,7 +61,7 @@ No quiet gate (the session is by definition not done) and **`reply_to` is kept**
61
61
 
62
62
  ## What it deliberately doesn't do
63
63
 
64
- - **No cross-server reach.** Same `SessionManagerRegistry`, same process. Different server → not supported yet (A2A is planned).
64
+ - **No cross-server reach.** Same `SessionManagerRegistry`, same process. Different server → [A2A](a2a.md), which reuses the same `reply_to` column (`{ a2a: taskId }`), quiet gate and interim doors.
65
65
  - **No session discovery beyond a flat root list.** `relay_list` returns a workspace's root sessions (id / agent / title / status, newest 100) so the secretary can reuse an existing conversation; it doesn't walk trees or search transcripts — that's the admin's job.
66
66
  - **No broadcast / fan-out helper.** The secretary calls `relay_send` once per department; the reports come back individually with the workspace + session id in the header, so it can tell them apart without extra bookkeeping.
67
67
  - **No ACL beyond full access.** A full-access secretary can reach every workspace on the box — that's the deployment's trust boundary already (it can `shell_exec` into them anyway).
@@ -182,6 +182,8 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
182
182
 
183
183
  **Eager boot** (`index.ts`): rather than waiting for someone to open a workspace, right after constructing `SessionManagerRegistry` (before `bootChannels`) the server calls `listRunningWorkspaces()` and for each workspace with leftover rows whose `.halo` dir still exists: `claimWorkspaceRuntime(ws)` first — if another live server owns it, `continue` (rows stay for a later boot, and no SessionManager is built, since caching a non-owner SM would freeze "not owner" for the whole process lifetime) — otherwise `registry.getOrCreate(ws)`, which fires the same constructor chain (claim → reconcile → retained `sweepActiveGoals` → `sweepInterruptedRuns`). An interrupted root with nobody around is exactly the one that must nudge itself without a human opening the tab first.
184
184
 
185
+ **AgentCore A2A mode** (`a2a/agentcore.ts`): the ledger is `<HALO_WORKSPACE>/.halo/runs.db` on the EFS mount instead of `~/.halo/global/runs.db` (a microVM's home dies with it), opened only once the workspace lease is held; the sweep runs on lease acquisition rather than at boot, and the first `POST /` waits for its nudges to start (`nudgesSettled`) before it is served — see [agentcore.md](agentcore.md). That file joins the workspace-relative hidden list (`WORKSPACE_HIDDEN_FILES`).
186
+
185
187
  `runs.db`/`-wal`/`-shm` join the built-in sandbox hidden-files list (`DEFAULT_HIDDEN_FILES`, always included; `general.sandbox.hidden_files` only adds to it) alongside `evo.db` / `cron.db` (see [storage.md](storage.md)). Known gap carried from the design doc: `<ISO>` in the nudge is sweep time, not the actual interruption time — for a workspace that sat locked by another server before this boot could claim it, that's a real but accepted drift.
186
188
 
187
189
  ### Message queue and drain
@@ -190,7 +192,7 @@ The orphan reconcile above fixes the *children*'s `stoppedAt`; nobody told the *
190
192
 
191
193
  A `QueuedMessage` is `{ text, sourceSessionId?, images? }`: `sourceSessionId` is set for agent→agent entries (drives the `(from: session X)` prefix and the sibling-status suffix) and absent for user messages; `images` is the user multimodal payload (agent entries have none).
192
194
 
193
- **runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-halo-globalrunsdb) above.
195
+ **runSession loop** (`runSession(sessionId, message)`, `message: string | ContentBlock[]`): runs the opening turn when there is one, then drains. An empty **string** message means "the work is already in `messageQueue`" (the `querySession` idle path) — it skips the opening turn and goes straight to drain. The per-turn reset (fresh `toolCallLog` / `warnedToolHashes`, `interruptRequested = false`) runs before the opening turn; `drainQueue` repeats the same reset before each merged batch so a stale interrupt flag never leaks across turns. Server-only: right before `session.promise = runFn()` it inserts the session id into the run ledger, and in the `finally` (right after `session.promise = null`, before emitting `complete`) it deletes it — see [Run ledger](#run-ledger--restart-nudge-for-interrupted-roots-haloglobalrunsdb) above.
194
196
 
195
197
  **Observability.** `runAgentTurn` is wrapped by `beginTurn` / `endTurn` from `observability/genai-spans.ts` — one `invoke_agent` span per turn, with `chat` / `execute_tool` child spans created from the `onAgentEvent` hook inside the event loop; see [design/observability.md](observability.md).
196
198