@turmind/halo 1.5.10 → 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.
- package/README.md +2 -2
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/{388-d5950f264d91625a.js → 147-d4200c70445aabb6.js} +1 -1
- package/admin-out/_next/static/chunks/989-a7f752480eaaa2dc.js +1 -0
- package/admin-out/_next/static/chunks/app/{layout-5ec6c5f713a8562b.js → layout-ea161287d934ba96.js} +1 -1
- package/admin-out/_next/static/chunks/app/page-8173112f256bc483.js +1 -0
- package/admin-out/_next/static/chunks/{webpack-efd27999305b080a.js → webpack-dc73047e0279979b.js} +1 -1
- package/admin-out/_next/static/css/7b736202f0b1dbfd.css +1 -0
- package/admin-out/_next/static/css/981c9e5610ce535a.css +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +6 -5
- package/bundled-docs/design/a2a.md +286 -0
- package/bundled-docs/design/agent.md +7 -5
- package/bundled-docs/design/agentcore.md +194 -122
- package/bundled-docs/design/architecture.md +2 -2
- package/bundled-docs/design/canvas-extensions.md +30 -9
- package/bundled-docs/design/halo-city.md +6 -0
- package/bundled-docs/design/observability.md +12 -4
- package/bundled-docs/design/prompt-system.md +1 -1
- package/bundled-docs/design/relay.md +6 -6
- package/bundled-docs/design/session.md +15 -12
- package/bundled-docs/design/storage.md +9 -2
- package/bundled-docs/design/web.md +36 -59
- package/bundled-docs/dev/acp-adapter.md +59 -175
- package/bundled-docs/dev/api.md +75 -6
- package/bundled-docs/dev/deploy.md +3 -2
- package/bundled-docs/dev/desktop-packaging.md +14 -0
- package/bundled-docs/dev/env.md +10 -6
- package/bundled-docs/dev/tools.md +101 -11
- package/bundled-docs/guide/README.md +1 -1
- package/bundled-docs/guide/channels/README.md +3 -3
- package/bundled-docs/guide/channels/acp.md +67 -42
- package/bundled-docs/guide/channels/feishu.md +1 -1
- package/bundled-docs/guide/channels/slack.md +1 -1
- package/bundled-docs/guide/channels/telegram.md +1 -1
- package/bundled-docs/guide/channels/web.md +10 -34
- package/bundled-docs/guide/channels/wechat.md +1 -1
- package/bundled-docs/guide/channels/wecom.md +1 -1
- package/bundled-docs/guide/chat.md +0 -1
- package/bundled-docs/guide/cli.md +41 -6
- package/bundled-docs/guide/delegation-and-access.md +3 -2
- package/bundled-docs/guide/getting-started.md +1 -1
- package/bundled-docs/guide/skills.md +2 -3
- package/bundled-docs/requirements/agent.md +1 -1
- package/bundled-docs/requirements/chat.md +17 -1
- package/bundled-docs/requirements/command.md +1 -2
- package/bundled-docs/requirements/editor.md +9 -3
- package/bundled-docs/requirements/explorer.md +1 -0
- package/bundled-docs/requirements/terminal.md +15 -0
- package/bundled-docs/requirements/workspace.md +6 -0
- package/dist/index.js +5392 -2786
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/agents/default/agent.yaml +0 -1
- package/templates/models/aws-bedrock-claude-invoke.yaml +40 -1
- package/templates/models/aws-bedrock-openai.yaml +100 -6
- package/templates/prompts/all/RUNTIME.md +2 -1
- package/templates/prompts/all/TOOL_SHELL.windows.md +2 -1
- package/templates/skills/send-file/SKILL.md +1 -1
- package/templates/skills/send-file/a2a.md +13 -0
- package/templates/skills/skill/SKILL.md +1 -1
- package/templates/skills/workspace/stage.py +1 -1
- package/admin-out/_next/static/chunks/989-1797287c46139412.js +0 -1
- package/admin-out/_next/static/chunks/app/page-44ab337b2f071e4d.js +0 -1
- package/admin-out/_next/static/css/d53aa54212002364.css +0 -3
- package/templates/skills/acp/SKILL.md +0 -243
- package/templates/skills/acp/templates/SKILL.md.tmpl +0 -75
- package/templates/skills/acp/templates/ask.py +0 -177
- package/templates/skills/acp/templates/config.yaml.tmpl +0 -38
- /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → _F_Fkyh9PainUNY82EEjY}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{A-Rjx2yltZDKaSO1qVv7K → _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
|
|
4
|
-
|
|
5
|
-
`
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
│
|
|
12
|
-
│ /
|
|
13
|
-
│
|
|
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,
|
|
16
|
-
│ X-Amzn-Bedrock-AgentCore-Runtime-Session-Id = user UUID
|
|
18
|
+
AgentCore Runtime (per-session microVM, verifies the signed request)
|
|
17
19
|
▼
|
|
18
|
-
halo
|
|
19
|
-
├── GET /ping
|
|
20
|
-
├── POST /
|
|
21
|
-
└──
|
|
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'`
|
|
27
|
-
|
|
28
|
-
- **Password/JWT gate**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
`
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
the
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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, /
|
|
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-
|
|
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` |
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
|
@@ -112,6 +112,12 @@ js/
|
|
|
112
112
|
|
|
113
113
|
## 服务端配套
|
|
114
114
|
|
|
115
|
+
- **部署:和 Halo 同源。** 页面所在域名下把 `/api/show/` 反代到 Halo,浏览器
|
|
116
|
+
只访问自己的 origin,也就不经过 CORS(`main.js` 自动连接时先试
|
|
117
|
+
`location.origin`)。不要跨域部署:请求带 `credentials: 'include'`,跨域时
|
|
118
|
+
只有 `server.cors_origins` 里逐字列出的 origin 才拿得到 Allow-Credentials,
|
|
119
|
+
换域名就挂;前置 SSO 的 307 也会被浏览器报成跨域错误。nginx 写法见
|
|
120
|
+
[halo-city/README.md](../../../halo-city/README.md#正式部署和-halo-同源)
|
|
115
121
|
- `GET /api/show/session?ws=&id=` — 只读会话详情:裁剪后的消息日志 + 真实
|
|
116
122
|
token 上限。鉴权同 `/api/show/state`(x-token;`full`/`observer` 可跨
|
|
117
123
|
workspace 读取,其余 accessLevel 只能看自己 workspace,**且只能看自己
|
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -125,7 +125,7 @@ Startup seeds `templates/prompts/{bootstrap,all,root}/` into `~/.halo/global/pro
|
|
|
125
125
|
- **On mac / linux**: every `*.windows.md` is ignored; only the plain `.md` files load.
|
|
126
126
|
- Files with no `*.windows.md` sibling load on every platform.
|
|
127
127
|
|
|
128
|
-
Replacement is **whole-file**, not per-section — so split the platform-divergent content into its own file and keep the common content in an un-suffixed file. Example in `prompts/all/`: `TOOL_GUIDELINES.md` (common) + `TOOL_SHELL.md` (unix Shell section) / `TOOL_SHELL.windows.md` (cmd.exe, `dir`/`findstr`, `%USERPROFILE%`, `python` vs `python3`, `.py` skill caveats). To add a new platform difference anywhere, extract that block to `FOO.md` and add `FOO.windows.md`.
|
|
128
|
+
Replacement is **whole-file**, not per-section — so split the platform-divergent content into its own file and keep the common content in an un-suffixed file. Example in `prompts/all/`: `TOOL_GUIDELINES.md` (common) + `TOOL_SHELL.md` (unix Shell section) / `TOOL_SHELL.windows.md` (cmd.exe, `dir`/`findstr`, `%USERPROFILE%`, `python` vs `python3`, `halo.cmd` not bare `halo` — that resolves to the GUI `Halo.exe`, `.py` skill caveats). To add a new platform difference anywhere, extract that block to `FOO.md` and add `FOO.windows.md`.
|
|
129
129
|
|
|
130
130
|
Result (the returned `systemPrompts` object; the sections below write these fields as `bootstrapPrompt` / `allPrompt` / `rootPrompt`):
|
|
131
131
|
- `prompts/bootstrap/*.md` → `systemPrompts.bootstrap`
|