@turmind/halo 1.5.11 → 1.6.1
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/{941-4719e7343b54b9bd.js → 147-d4200c70445aabb6.js} +1 -1
- package/admin-out/_next/static/chunks/58.d26b1c4f14404226.js +1 -0
- package/admin-out/_next/static/chunks/989-ddba0eb72566da23.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-be47983ba8ff20b1.js +1 -0
- package/admin-out/_next/static/chunks/webpack-74f577c1b094f942.js +1 -0
- package/admin-out/_next/static/css/3b4dcd0ac6b8eae8.css +1 -0
- package/admin-out/_next/static/css/7b736202f0b1dbfd.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 +6 -4
- package/bundled-docs/design/agentcore.md +194 -122
- package/bundled-docs/design/architecture.md +2 -2
- package/bundled-docs/design/canvas-extensions.md +59 -12
- package/bundled-docs/design/cron.md +22 -11
- package/bundled-docs/design/observability.md +12 -4
- package/bundled-docs/design/prompt-system.md +10 -5
- package/bundled-docs/design/relay.md +1 -1
- package/bundled-docs/design/session.md +7 -2
- package/bundled-docs/design/storage.md +40 -10
- package/bundled-docs/design/web.md +5 -56
- package/bundled-docs/design/ws.md +1 -1
- package/bundled-docs/dev/add-model-provider.md +15 -9
- package/bundled-docs/dev/api.md +93 -14
- package/bundled-docs/dev/deploy.md +5 -1
- package/bundled-docs/dev/desktop-packaging.md +11 -5
- package/bundled-docs/dev/env.md +9 -5
- package/bundled-docs/dev/previews.md +26 -0
- package/bundled-docs/dev/tools.md +93 -3
- package/bundled-docs/guide/agents.md +6 -6
- package/bundled-docs/guide/channels/web.md +0 -26
- package/bundled-docs/guide/cli.md +45 -8
- package/bundled-docs/guide/delegation-and-access.md +3 -2
- package/bundled-docs/guide/skills.md +13 -7
- package/bundled-docs/guide/workspace.md +1 -1
- package/bundled-docs/requirements/agent.md +6 -1
- package/bundled-docs/requirements/chat.md +9 -3
- package/bundled-docs/requirements/command.md +4 -0
- package/bundled-docs/requirements/editor.md +28 -8
- package/bundled-docs/requirements/skill.md +15 -9
- package/bundled-docs/requirements/workspace.md +6 -0
- package/dist/index.js +6081 -3013
- package/package.json +1 -1
- package/templates/models/anthropic.yaml +109 -13
- package/templates/models/aws-bedrock-claude-invoke.yaml +76 -11
- package/templates/models/aws-bedrock-mantle.yaml +60 -28
- package/templates/models/aws-bedrock-openai.yaml +146 -29
- package/templates/models/deepseek.yaml +7 -3
- package/templates/models/doubao.yaml +72 -20
- package/templates/models/hunyuan.yaml +74 -14
- package/templates/models/kimi.yaml +63 -3
- package/templates/models/mimo-token-plan-china.yaml +9 -1
- package/templates/models/minimax.yaml +9 -3
- package/templates/models/openai.yaml +120 -17
- package/templates/models/qwen.yaml +90 -4
- package/templates/models/zhipu.yaml +9 -1
- package/templates/prompts/all/RUNTIME.md +2 -1
- package/templates/schema.sql +1 -1
- package/templates/skills/cron/SKILL.md +38 -2
- package/templates/skills/cron/manage-cron.py +115 -7
- package/templates/skills/send-file/SKILL.md +1 -1
- package/templates/skills/send-file/a2a.md +13 -0
- package/templates/skills/skill/SKILL.md +22 -11
- package/templates/skills/workspace/share.md +5 -2
- package/templates/skills/workspace/stage.py +51 -14
- package/admin-out/_next/static/chunks/220.7566bc89f3d98993.js +0 -1
- package/admin-out/_next/static/chunks/989-86b0f677f69ccf3b.js +0 -1
- package/admin-out/_next/static/chunks/app/page-4792aa1c79f7e7f1.js +0 -1
- package/admin-out/_next/static/chunks/webpack-efd27999305b080a.js +0 -1
- package/admin-out/_next/static/css/97f3ac570664d044.css +0 -3
- /package/admin-out/_next/static/{GO2W5JoiC46FIxLjtFAXx → j6buZnQDPISEFgtAuTGyl}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{GO2W5JoiC46FIxLjtFAXx → j6buZnQDPISEFgtAuTGyl}/_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
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Canvas preview extensions — installable file viewers in sandboxed iframes
|
|
2
2
|
|
|
3
|
-
> API: [dev/api.md → Canvas Preview Extensions](../dev/api.md#canvas-preview-extensions). WS frame `extension:changed`: [ws.md](ws.md#other-server--client-messages). User-visible behavior: [requirements/editor.md](../requirements/editor.md). Code: `packages/core/src/protocol/extension-{types,frames}.ts`, `packages/server/src/extensions/{registry,watcher,install}.ts` + `routes/extensions.ts` + `middleware/auth.ts` (scoped token), `PUT /files/raw` in `routes/files.ts`, `routes/transcribe-ws.ts` (transcription proxy) + `settings-schema.ts` `extensionSections()`, `packages/admin/src/features/editor/previews/{registry,extension-host,extension-host-logic,extension-token}.ts(x)`, `packages/server/templates/skills/extension
|
|
3
|
+
> API: [dev/api.md → Canvas Preview Extensions](../dev/api.md#canvas-preview-extensions). WS frame `extension:changed`: [ws.md](ws.md#other-server--client-messages). User-visible behavior: [requirements/editor.md](../requirements/editor.md). Code: `packages/core/src/protocol/extension-{types,frames}.ts`, `packages/server/src/extensions/{registry,watcher,install}.ts` + `routes/extensions.ts` + `middleware/auth.ts` (scoped token), `PUT /files/raw` in `routes/files.ts`, `routes/transcribe-ws.ts` (transcription proxy) + `settings-schema.ts` `extensionSections()`, `packages/admin/src/features/editor/previews/{registry,extension-host,extension-host-logic,extension-token}.ts(x)`, `packages/server/templates/skills/extension/`, `packages/server/src/agents/skill-sources.ts` (extension-shipped skills).
|
|
4
4
|
|
|
5
5
|
## Problem
|
|
6
6
|
|
|
@@ -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`, `jupyter?` | 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`; `jupyter` = `{ projectId }` when this host serves the [notebook kernel proxy](#notebook-kernels-jupyter-proxy) — sent to every non-bundle extension in a workspace panel (1.6.1+), omitted by older hosts → hide kernel UI |
|
|
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,13 +215,39 @@ 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.
|
|
201
222
|
- **Frames**: client binary = PCM s16le mono 16 kHz, text `{"type":"end"}` = flush then server closes 1000. Server text: `ready` (sent on connect — the proxy is taking audio; it can't mean "Transcribe accepted" because the SDK's `send()` only resolves after the first audio event, so a client waiting for that would hit Transcribe's 15 s no-audio timeout; upstream rejections follow as `error`), `partial` / `final` `{start,end,text,lang}` (seconds from this stream's first audio byte), `error {code,message}` then close 1011. Codes: `credentials` (CredentialsProviderError / UnrecognizedClient / InvalidSignature / ExpiredToken / InvalidClientTokenId), `denied` (AccessDenied), `limit` (LimitExceeded), `bad-request` (BadRequest / bad `lang`), `io` (everything else, incl. > 30 s of audio queued unsent).
|
|
202
223
|
- **Lifetime**: the client WS closing aborts the upstream request and destroys the client (no orphan streams). Frames are capped at 1 MiB (`maxPayload`); a protocol violation (oversized frame → close 1009, invalid UTF-8 text → 1007) closes only that socket — each connection has an `error` listener, and the upgrade router never throws on a malformed request-target (unknown path → `400 Bad Request`). Reconnect across Transcribe's 4 h cap / ~15 s silence drop is the extension's job. Logs: `[Transcribe] stream open ext=… lang=… region=…` / `stream closed after Ns` / `stream error … code=…` (error name only) — never audio or credentials.
|
|
203
224
|
|
|
225
|
+
### Notebook kernels (Jupyter proxy)
|
|
226
|
+
|
|
227
|
+
The ipynb extension (2.0.0+) runs cells on **real kernels on the Halo machine**. Halo starts a local `jupyter server` on demand and exposes a fixed whitelist of it under `/api/jupyter/*` (`jupyter/manager.ts` process + kernel registry, `routes/jupyter.ts` HTTP + WS). Jupyter is never bundled: the user installs it (`pip install jupyter_server ipykernel`) and every kernelspec it finds is offered.
|
|
228
|
+
|
|
229
|
+
- **Trust level = the built-in terminal**: kernels run as the Halo service user, unsandboxed; admin cookie only (the HTTP routes sit behind `authMiddleware`, the WS handshake checks the cookie like `/ws`; web-channel / IM / A2A tokens can't reach it); not mounted in AgentCore mode; the child env is `cleanChildEnv()`. No manifest capability gates it — `init.jupyter` is the host's signal (a new capability value would make ipynb 2.0.0 uninstallable on a 1.6.0 registry, same reasoning as `init.export`). The iframe is same-origin with the login cookie, so the extension fetches / opens the WS to `/api/jupyter/*` itself.
|
|
230
|
+
- **Settings** (`general.jupyter.*`, globalOnly, read at each spawn — no restart): `enabled` (default `true`) and `path` (default empty = `jupyter` on PATH, else the full path of the executable).
|
|
231
|
+
- **Status** (`GET /api/jupyter/status`) never starts anything: disabled → `{enabled:false, available:false}`; executable missing → `available:false` + `error` + `hint: 'pip install jupyter_server ipykernel'`; else `<jupyter> server --version` is run once per resolved path and cached (a failing probe isn't cached, so a later `pip install` is picked up).
|
|
232
|
+
- **Server**: one per Halo process, started by the first `kernelspecs` / kernel start (concurrent callers share one start), restarted lazily after it exits. Args: `server --no-browser --ServerApp.ip=127.0.0.1 --ServerApp.port=<free port> --ServerApp.port_retries=0 --ServerApp.root_dir=<filesystem root> --ServerApp.terminals_enabled=False --ServerApp.shutdown_no_activity_timeout=600 --MappingKernelManager.cull_idle_timeout=3600 --MappingKernelManager.cull_interval=300 --MappingKernelManager.cull_connected=True` (+ `allow_root` when Halo itself runs as root). The random upstream token goes in `JUPYTER_TOKEN` (env, not argv — `ps` would show it) and every upstream call sends `Authorization: token …`. Ready = `GET /api/status` answers, within 30 s, else 503 with the stderr tail. When the process exits, the kernel Map is cleared and every client socket closes 1011.
|
|
233
|
+
- **Kernel start** (`POST /api/jupyter/kernels {projectId, path, name}`): `resolveProjectPath` + `validatePath` (symlinks followed; outside → 400), then the **real path of the notebook's directory** is sent as the upstream kernel `path`. jupyter_server 2.x `cwd_for_path` splits that on `/`, joins the parts onto `root_dir` and normalizes — with `root_dir` = `/` that is the absolute path itself; on Windows the native `C:\…` path is a single part and joining an absolute drive path onto the root replaces it, so any drive works (forward slashes would give a drive-relative `D:x`). jupyter silently walks up to `root_dir` when the directory doesn't exist, so Halo checks it first (400).
|
|
234
|
+
- **Kernel ops / channels** only for ids in Halo's in-process Map (unknown → 404, also when jupyter itself started it). Upstream 404 (culled) drops the id. A kernel jupyter shuts down on its own (idle cull) does **not** close its channels sockets — the only sign is an iopub `shutdown_reply` with `content.restart: false` (a restart sends the same frame with `restart: true`, and the socket keeps working across it); the proxy watches for that frame, drops the id and closes the client sockets 1000. A kernel that dies in a cell (`exit()`, a crash) is auto-restarted by jupyter: `status: restarting` → `starting`, same id, socket stays. Channels: WS `/api/jupyter/channels?kernel=<id>` → `ws://127.0.0.1:<port>/api/kernels/<id>/channels`, frames piped both ways as-is (text and binary), frames sent before the upstream handshake are queued. No subprotocol is offered upstream or accepted from the client, so jupyter speaks the **legacy JSON protocol** (every message a text frame carrying `channel`, `header`, `parent_header`, `metadata`, `content`, `buffers` go as binary frames). The proxy pings the client every 20 s (keepalive through reverse proxies; 2 missed pongs = dead).
|
|
235
|
+
- **Lifecycle**: a kernel with no client socket for **60 s** is DELETEd upstream (tab closed; the grace rides out a reload / reconnect, which cancels it; a kernel nobody ever connects to gets the same grace); the extension also sends a best-effort `DELETE` on unload. jupyter culls kernels idle for 1 h even while connected, and exits itself after 10 min with no kernels. Halo's graceful shutdown POSTs upstream `/api/shutdown` (stops every kernel, then exits; works on Windows too) and SIGKILLs after 5 s; a `process.on('exit')` hook kills the child on other exits. If Halo dies hard, the orphaned server goes on its own: its kernels are culled after 1 h idle, then it exits 10 min later.
|
|
236
|
+
- Logs: `[Jupyter] server <version> ready on 127.0.0.1:<port>`, `kernel <id> (<name>) started in <dir>`, `no client for 60s, shutting it down`, `server exited (…); dropping N kernel(s)`.
|
|
237
|
+
|
|
238
|
+
### Extension skills
|
|
239
|
+
|
|
240
|
+
An extension can ship agent skills next to its viewer: `~/.halo/global/extensions/<extId>/skills/<id>/` (`SKILL.md` + resource files + optional `config.yaml`, the ordinary skill layout). Discovery is by directory; the manifest has no skills field. `packages/server/src/agents/skill-sources.ts` is the one resolver: every skill reader goes through `listSkillDirs` / `skillSourceDir`. That covers session build / `activate_skill`, slash commands, the `/skill` verbs, `GET /skills`, Settings → Skills and evo's listing.
|
|
241
|
+
|
|
242
|
+
- **Which folders count**: only extensions whose manifest passes the registry's own validator (`readExtensionDir`, verdict cached per manifest mtime; an extension with no `skills/` subdirs is skipped without reading its manifest). A skill id must match `^[\w-]+$` and equal `<extId>` or start with `<extId>-`; anything else is ignored with one `[Skills] extension "<extId>": ignoring skills/<id> …` warning per manifest version, not per scan. If two extensions claim one id (`foo`'s `foo-bar` vs extension `foo-bar`), the lower extension id keeps it. Manifest `platforms` is not applied: it only decides where the viewer is offered, and an extension's skills load on every platform.
|
|
243
|
+
- **Precedence**: workspace > global > extension. A higher source with the same id wholly overrides (the whole folder, no per-file fallback); the shadowed extension copy is still listed with `overridden: true`. To customise an extension skill, copy its folder into `~/.halo/global/skills/<id>/` or `<workspace>/.halo/skills/<id>/`.
|
|
244
|
+
- **Scope `extension`, read-only**: `GET /skills` returns them with `scope: 'extension'` and `extId`. `POST` / `DELETE /skills` with that scope answer 403, so they never fall through to a same-id global skill. `/skill delete` refuses them, and evo neither lists nor patches them. The admin shows them in a Skills → **Extensions** section, rendered as a read-only file view with no editor, create or delete, and the subtitle "from extension X".
|
|
245
|
+
- **Not auto-enabled**: an agent uses one only when its `skills:` lists the id (ticked in the agent form like any skill). An id whose extension is gone shows the usual "not installed" hint.
|
|
246
|
+
- **Disable**: the per-workspace toggle still works and is stored in `disabled_items` as `extension:<id>` (disabling is a workspace setting, not a write into the extension). `loadSkillMetadata` skips it.
|
|
247
|
+
- **Settings**: a `config.yaml` surfaces in Settings → Skills under the skill's own `<id>` namespace. Extension sections are laid down first, so a same-id global or workspace skill's section replaces them.
|
|
248
|
+
- **Read from disk**: the server, CLI / TUI and cron children all scan the extensions root on every read, never the in-memory registry, so every process sees the same set. In the admin, the `extension:changed` frame also bumps the skill bus, so the Skills sidebar and the agent form's skill picker refetch. Uninstalling removes the skills at once (next read finds no folder, no restart). The watcher is non-recursive, so editing files inside an installed extension's `skills/` pushes nothing. Reinstalling the extension is the way to publish a change.
|
|
249
|
+
- **Sandbox**: `~/.halo/global/extensions/**` is outside the sandbox hidden lists, so workspace- and readonly-access sessions can read the skill's resources, and a workspace-access `shell_exec` can run its scripts. `activate_skill` names the skill's absolute directory in its resources header, so the agent knows where they are.
|
|
250
|
+
|
|
204
251
|
## `extension` skill and halo-hub
|
|
205
252
|
|
|
206
253
|
Built-in skill `templates/skills/extension/` (in `BUILTIN_SKILL_IDS`; `TEMPLATE_VERSION` bumped to 69 in the same change): object command `/extension install|list|remove|models` (`models` = model provider configs, see the end of this section), `requiresAccess: full`. The built-in `default` agent lists it in `skills:` (since `TEMPLATE_VERSION` 81), so `/extension` shows in the command popup wherever the default agent is the built-in one. The payload reaches the body as `$ARGUMENTS`; when it names a source the agent installs right away without confirming, and it mentions the hub only when asked what exists or when an install fails. Everything goes through `templates/ext.sh` (curl + unzip + node, + git for tag-based hubs; no `gh`). `ext.sh install <id|zip|url>`: a zip path / URL is used as given, an id is fetched from the hub (below); then the same pre-check + six-step atomic replace as the server. It never talks to the server — the watcher picks the rename up within ~300 ms. The `halo` skill has a one-line pointer.
|
|
@@ -215,19 +262,19 @@ Built-in skill `templates/skills/extension/` (in `BUILTIN_SKILL_IDS`; `TEMPLATE_
|
|
|
215
262
|
The hub repo keeps one directory per extension (`glb` / `ipynb` commit their vendored deps, `fetch-deps.sh` only refreshes them; `drawio` / `excalidraw` gitignore their build output and commit only the inputs), `scripts/pack.mjs extensions/<id>` → `dist/<id>-<version>.zip` with contents at the zip root (maintenance scripts and build inputs excluded), and one release per tag `<id>-v<version>` carrying exactly one zip (a second one would be picked by accident). Skills and workspaces in the same repo are tagged `skill-<id>-v<ver>` / `ws-<id>-v<ver>`; nothing installs them yet. Catalog today (only `glb-v1.0.0` is released so far):
|
|
216
263
|
|
|
217
264
|
- `glb` 1.1.0 — `.glb .gltf .obj .stl`, `default`, read-only (`<model-viewer>` 4.3.1 offline bundle with Draco/Basis decoders; `.obj` / `.stl` converted in memory to GLB via three.js; `.gltf` must be self-contained — external `.bin` / textures → `error` frame; no `.mtl`).
|
|
218
|
-
- `ipynb`
|
|
265
|
+
- `ipynb` 2.0.0 — `.ipynb`, `default`, `save`: notebook editor (markdown, KaTeX math, highlighted code, outputs incl. images / HTML tables; no widgets / JS outputs) that runs cells through the Jupyter proxy above when the host sends `init.jupyter` (Halo ≥ 1.6.1); older hosts edit and save without run controls.
|
|
219
266
|
- `drawio` 1.0.0 — `.drawio .dio`, `default`, `['save']`; offline draw.io v32.0.2 (~22 MB zip); release-zip install only (build step).
|
|
220
267
|
- `excalidraw` 1.0.0 — `.excalidraw`, `default`, `['save']`; offline Excalidraw 0.18 (~16 MB zip); release-zip install only (build step).
|
|
221
268
|
|
|
222
|
-
`glb`
|
|
269
|
+
`glb` is read-only, so active-only mount; `drawio` / `excalidraw` / `ipynb` are the real-world references for the save protocol.
|
|
223
270
|
|
|
224
271
|
**`models` verb — model provider configs (since `TEMPLATE_VERSION` 84).** The same skill carries `/extension models` (and natural language "更新模型列表 / update the model list"): `ext.sh models update [--yes]` takes the hub's newest `models-v*` release (one zip with every provider yaml, `scripts/pack.mjs models` → `dist/models-<YYYY.MM.DD>.zip`), but only from an **https release-API hub** — a local path, plain git, ssh or `http://` hub is refused (extensions keep the rules above), and every curl runs with `--proto =https --proto-redir =https`. After the same zip safety checks (`safe_unzip`: no `..`, no absolute paths, no symlinks) it hands the unpacked directory to `${HALO_CLI:-halo} models install <dir> [--yes]`, which owns all validation and the write into `~/.halo/global/models.d/` (see [storage.md → Model registry format](storage.md#model-registry-format) and [guide/cli.md](../guide/cli.md#model-provider-configs-halo-models)); its exit code passes through — `3` = a new provider or changed endpoints, nothing written, the agent shows the list and re-runs with `--yes` only after the user agrees. `ext.sh models list` = `halo models list`. The server's `models.d` watcher reloads the registry and pushes `models:changed`.
|
|
225
272
|
|
|
226
273
|
## Extension capability boundary
|
|
227
274
|
|
|
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).
|
|
275
|
+
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
276
|
|
|
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
|
|
277
|
+
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
278
|
|
|
232
279
|
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
280
|
|