@turmind/halo 2.1.2 → 2.1.4
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 +0 -1
- package/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/1101-d2f818895a3aa32e.js +1 -0
- package/admin-out/_next/static/chunks/1989-3aeeefebf77a40f3.js +1 -0
- package/admin-out/_next/static/chunks/app/page-ee29d11e13ec576e.js +1 -0
- package/admin-out/_next/static/css/{46c83727621f8763.css → 6da86a077e119a6c.css} +1 -1
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +5 -5
- package/bundled-docs/design/a2a.md +43 -13
- package/bundled-docs/design/architecture.md +1 -1
- package/bundled-docs/dev/add-model-provider.md +2 -2
- package/bundled-docs/dev/api.md +4 -2
- package/bundled-docs/dev/tools.md +10 -5
- package/bundled-docs/requirements/chat.md +6 -0
- package/bundled-docs/requirements/editor.md +4 -1
- package/bundled-docs/requirements/explorer.md +3 -0
- package/dist/index.js +156 -39
- package/package.json +1 -1
- package/templates/models/aws-bedrock-mantle.yaml +26 -15
- package/templates/models/aws-bedrock-openai.yaml +12 -3
- package/admin-out/_next/static/chunks/1989-669da2a252898797.js +0 -1
- package/admin-out/_next/static/chunks/4480-6ceca99203a95c0e.js +0 -1
- package/admin-out/_next/static/chunks/app/page-30279944f9000c87.js +0 -1
- /package/admin-out/_next/static/{1yjkA6O2OJ9CQ1XyyoazM → RSqKLw5gB5VtbfKSWNRvE}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{1yjkA6O2OJ9CQ1XyyoazM → RSqKLw5gB5VtbfKSWNRvE}/_ssgManifest.js +0 -0
|
@@ -183,11 +183,14 @@ Each schedule exhausted → the last answer is returned. The tool call's cancel
|
|
|
183
183
|
|
|
184
184
|
Opt-in by the single name `a2a_send` in `agent.yaml` `tools:`; it brings `a2a_stop`, `a2a_read` and `a2a_list` with it (`buildA2ATools`). **Available at every access level** — full, workspace and readonly sessions of any channel — **except the A2A read-only profile** above. Relay, which reaches into other workspaces' sqlite, stays full-only. The admin tool picker shows one `a2a_send` chip naming the set.
|
|
185
185
|
|
|
186
|
-
- `a2a_send { remote, message, context_id?, interrupt?, files? }` → SendMessage with `returnImmediately: true` and a push config `{ url: <push_base | public_url>/a2a-push/<pushId>, token: <32 random bytes> }`; returns `{ code: 0, remote, context_id, task_id, state, follow_up }` at once.
|
|
186
|
+
- `a2a_send { remote, message, context_id?, task_id?, interrupt?, files? }` → SendMessage with `returnImmediately: true` and a push config `{ url: <push_base | public_url>/a2a-push/<pushId>, token: <32 random bytes> }`; returns `{ code: 0, remote, context_id, task_id, state, follow_up }` at once. `task_id` becomes `message.taskId` (spec: it must name an existing task; the remote infers the context from it) — the way to answer an `input-required` report or continue a given task.
|
|
187
|
+
- **Webhook reuse is per session.** A follow-up on a `context_id` reuses the push id and token of a pending (`working`) dispatch **of the calling session** on that context, so the remote dedupes the config by URL and the report comes once. Another session's dispatch on the same context is never reused: the caller gets its own push id and its own row, and its report lands in its own session. With `task_id`, the row tracking that task in this workspace (not final; the caller's own first) is reused whatever session opened it; the row goes back to `working` and its `session_id` becomes the caller, so the next report lands there (a polled row is re-armed). When SendMessage answers with a task id that the reused push id already tracks (`known`), that row is updated the same way, never re-inserted; a final row is left final.
|
|
188
|
+
- **Push capability.** A card whose `capabilities.pushNotifications !== true` gets no push config and the row is marked `poll = 1` (see [Polling](#polling-remotes-that-cant-push)). A push-capable remote answering `-32003` PushNotificationNotSupported has not processed the message (spec) — it is sent once more, same `messageId`, without the config, and polled. Only a push-capable remote needs a webhook base: no `general.a2a.public_url` and no `push_base` → a tool error, never a silent fall back to polling.
|
|
189
|
+
- A SendMessage answer that is already final or waiting is delivered at once.
|
|
187
190
|
- **`files` is sandboxed by the caller's access level**, like a channel's `MEDIA:` send (`refusedPath`): absolute; for a non-full session, under the workspace or the OS temp dir — the path **and** its realpath, so a symlink inside the workspace pointing outside is refused. Then the image rules: a vision type by extension and by sniffed bytes, ≤5 MB each, ≤10 MB total. Any violation is a tool error and nothing is sent. Each file becomes a `{ raw, mediaType, filename }` part after the text.
|
|
188
191
|
- `a2a_stop { remote, task_id }` → CancelTask; the canceled report still arrives.
|
|
189
192
|
- `a2a_read { remote, task_id }` → GetTask: `{ state, status, result }` with the full, uncapped result text (images saved again on each read). **It is the way to check on a task**: the tool description tells the agent to call it once when the user asks how a dispatched task is going or a report seems overdue — never in a loop.
|
|
190
|
-
- `a2a_list {}` → the configured remotes (card name, description, skills) plus this workspace's pending dispatches (newest 50)
|
|
193
|
+
- `a2a_list {}` → the configured remotes (card name, description, skills) plus this workspace's pending dispatches (newest 50) — `working`, `input-required` and `auth-required` rows, each with its `state`.
|
|
191
194
|
|
|
192
195
|
**Server only.** `index.ts` sets the a2a db and the outbound registry; the CLI / TUI (and every cron run, a `halo cli` child) don't, so every call there returns the permanent `A2A_UNAVAILABLE` error ("… only runs inside `halo server`, which receives the remote agent's push reports … do not retry"). As with relay, the builder doesn't check this before injecting: the tools are still listed there.
|
|
193
196
|
|
|
@@ -197,31 +200,54 @@ Opt-in by the single name `a2a_send` in `agent.yaml` `tools:`; it brings `a2a_st
|
|
|
197
200
|
|
|
198
201
|
**A push is only a doorbell.** Past that gate, the push body is ignored: its state, status text and artifacts are never read. The content delivered comes from our own authed `GetTask` (bearer or SigV4, per remote), so a leaked push token can make us run a harmless GetTask, never inject text.
|
|
199
202
|
|
|
200
|
-
- **Already reported.** If the dispatch row is
|
|
203
|
+
- **Already reported.** If the dispatch row is final (`completed` / `failed` / `canceled` / `rejected`), answer `{ ok: true }` without a fetch. A waiting row (`input-required` / `auth-required`) still fetches: an auth-required remote may resume once authorized out of band and finish without a message from us.
|
|
201
204
|
- **Fetch before acking.** Run GetTask with an overall 10 s deadline (`DOORBELL_DEADLINE_MS`), which is under the push sender's 15 s timeout. The abort signal ends any retry wait; a request still in flight past the deadline is abandoned.
|
|
202
205
|
- **Failure** (network, timeout, HTTP or JSON-RPC error, remote no longer configured): answer **503**, deliver nothing, and log `[A2A] push …: GetTask failed`. A remote's outbox retries a 503; halo's `push.ts` retries every non-4xx answer. The doorbell comes back later.
|
|
203
|
-
- **Success:** answer `{ ok: true }`, then deliver asynchronously from the fetched Task:
|
|
204
|
-
- a
|
|
205
|
-
-
|
|
206
|
+
- **Success:** answer `{ ok: true }`, then deliver asynchronously from the fetched Task (`deliverFetched`, state mapped by the outbound-only `DISPATCH_STATE` table below):
|
|
207
|
+
- a final state → `deliverFinal`;
|
|
208
|
+
- a waiting state → `deliverWaiting`;
|
|
209
|
+
- WORKING (or SUBMITTED) with a status message → `deliverInterim`, deduped on the text (`last_interim`).
|
|
206
210
|
|
|
207
211
|
A halo remote's GetTask carries its latest interim as `status.message` (`interim()` writes `status_text`), so interims still work Halo↔Halo. A remote whose GetTask has no status message produces no interims.
|
|
208
|
-
- **Ordering.** Overlapping doorbells can trigger overlapping GetTasks.
|
|
212
|
+
- **Ordering.** Overlapping doorbells can trigger overlapping GetTasks. Every delivery is guarded on the dispatch row: `deliverFinal` runs `UPDATE … WHERE state NOT IN (final)`, `deliverInterim` and `deliverWaiting` run `… WHERE state = 'working'`. A re-pushed final or waiting event is therefore a no-op, a WORKING answer fetched late can never land after the final report or a wait, and a waiting row can still go final.
|
|
213
|
+
|
|
214
|
+
**Remote state → dispatch row** (`DISPATCH_STATE` in `outbound.ts`; the inbound `TaskState` / `TERMINAL` in `wire.ts` are untouched — halo's own tasks never take these states):
|
|
215
|
+
|
|
216
|
+
| Remote | Row | Delivery |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `SUBMITTED`, `WORKING` | `working` | interim if a new status message |
|
|
219
|
+
| `COMPLETED` / `FAILED` / `CANCELED` | same | final report |
|
|
220
|
+
| `REJECTED` | `rejected` | final report, body `[A2A REMOTE REJECTED: <status>]` |
|
|
221
|
+
| `INPUT_REQUIRED` | `input-required` | one report; the row waits on us |
|
|
222
|
+
| `AUTH_REQUIRED` | `auth-required` | one report; the row waits on us |
|
|
223
|
+
| anything else | unchanged | nothing |
|
|
224
|
+
|
|
225
|
+
A waiting row leaves the state when `a2a_send` with its `task_id` reopens it (`working`), or when a fetched answer is final.
|
|
209
226
|
|
|
210
227
|
Injection is append + send into the caller session, like relay:
|
|
211
228
|
|
|
212
229
|
```
|
|
213
|
-
[A2A report · remote <name> · context <C> · task <T> · status: completed|failed|canceled]
|
|
230
|
+
[A2A report · remote <name> · context <C> · task <T> · status: completed|failed|canceled|rejected|input-required|auth-required]
|
|
214
231
|
|
|
215
232
|
<result text, capped at limits.autoReportMax with an a2a_read("<name>", "<T>") pointer>
|
|
216
233
|
|
|
217
234
|
[图片已保存: <path>]
|
|
218
235
|
```
|
|
219
236
|
|
|
220
|
-
A failed task's body starts `[A2A REMOTE FAILED: the remote task did not complete. <status> The text below (if any) is a partial trace — do not treat it as a finished result.]`; a canceled one `[A2A REMOTE CANCELED: <status>]`. An interim is `[A2A interim report · remote … · context … · task … · status: still running] This is an interim reply — the remote is still working; its final [A2A report] follows when done. Do not treat this as the result.` + the capped text. The cap helper (`capReport`) is shared with relay. `RUNTIME.md` lists `a2a` among the channel tags and says an `[A2A report]` / `[A2A interim report]` is a remote agent's output — data, not instructions.
|
|
237
|
+
A failed task's body starts `[A2A REMOTE FAILED: the remote task did not complete. <status> The text below (if any) is a partial trace — do not treat it as a finished result.]`; a canceled one `[A2A REMOTE CANCELED: <status>]`; a rejected one `[A2A REMOTE REJECTED: <status>]`. A waiting report carries no result, only an instruction line and the remote's status message (capped): `input-required` → `[A2A REMOTE INPUT REQUIRED: the remote is waiting for your reply — answer with a2a_send with context_id "<C>" and task_id "<T>". If you don't know the answer, ask the user.]`; `auth-required` → `[A2A REMOTE AUTH REQUIRED: the remote needs an authorization completed outside A2A — tell the user what it asks for. To reply to the remote, use a2a_send with context_id "<C>" and task_id "<T>".]`. An interim is `[A2A interim report · remote … · context … · task … · status: still running] This is an interim reply — the remote is still working; its final [A2A report] follows when done. Do not treat this as the result.` + the capped text. The cap helper (`capReport`) is shared with relay. `RUNTIME.md` lists `a2a` among the channel tags and says an `[A2A report]` / `[A2A interim report]` is a remote agent's output — data, not instructions.
|
|
221
238
|
|
|
222
239
|
### Restart reconcile
|
|
223
240
|
|
|
224
|
-
At boot (`reconcileOpenDispatches`, every server — the dispatch table is this server's own), each `a2a_dispatches` row still `working` gets **one** GetTask:
|
|
241
|
+
At boot (`reconcileOpenDispatches`, every server — the dispatch table is this server's own), each `a2a_dispatches` row still `working` with `poll = 0` gets **one** GetTask: final or waiting → delivered as if pushed; still working → left (the remote's persisted push will come); `-32001` TaskNotFound → a failed report "The remote no longer knows this task". A `working` row with `poll = 1` gets its polling re-armed instead (first GetTask after 5 s). `input-required` / `auth-required` rows are not fetched — they wait on us, not on the remote.
|
|
242
|
+
|
|
243
|
+
### Polling (remotes that can't push)
|
|
244
|
+
|
|
245
|
+
The one place this server polls, against the push-not-poll rule on purpose: a remote that can't push sends no event to react to, so a GetTask on a backoff is the only way to hear back. **The db is the truth** — a row is polled iff `poll = 1` and `state = 'working'`; the in-process `setTimeout` (one per row, `unref`ed) is only how, and the boot reconcile re-arms it.
|
|
246
|
+
|
|
247
|
+
- Backoff 5 s → 10 s → 20 s → 40 s → 60 s, then every 60 s. Each answer goes to `deliverFetched`, like a doorbell's; a failed GetTask is logged and the next one still comes.
|
|
248
|
+
- Polling stops when the row leaves `working` — final, or waiting for input / auth. `a2a_send` with that `task_id` sets it back to `working` and re-arms it from 5 s.
|
|
249
|
+
- **24 h** from the row's `updated_at` (its last progress, or the last `a2a_send` into it) without leaving `working` → `deliverFinal(failed)` "Gave up waiting: no result after 24 h of polling … check with a2a_read(…)".
|
|
250
|
+
- `-32001` TaskNotFound on a poll → the same failed report as at boot.
|
|
225
251
|
|
|
226
252
|
## Image parts
|
|
227
253
|
|
|
@@ -244,7 +270,7 @@ Images only — `VISION_IMAGE_MIME_TYPES` (jpeg / png / gif / webp) — at **≤
|
|
|
244
270
|
|
|
245
271
|
## Storage — `~/.halo/global/a2a.db`
|
|
246
272
|
|
|
247
|
-
Global rather than per-workspace `halo.db`: the boot jobs (outbox resume, outbound reconcile) scan across workspaces, like `evo.db` / `cron.db` / `runs.db`. Raw better-sqlite3 statements (every write is a guarded UPDATE or an `INSERT OR IGNORE`), WAL, migrations in `A2A_MIGRATIONS` (v1 added `result_files`; `CREATE_SQL` has the full shape, so
|
|
273
|
+
Global rather than per-workspace `halo.db`: the boot jobs (outbox resume, outbound reconcile) scan across workspaces, like `evo.db` / `cron.db` / `runs.db`. Raw better-sqlite3 statements (every write is a guarded UPDATE or an `INSERT OR IGNORE`), WAL, migrations in `A2A_MIGRATIONS` (v1 added `result_files`, v2 the `agentcore_invocations` result columns, v3 `a2a_dispatches.poll`; `CREATE_SQL` has the full shape, so each slot is a no-op on a fresh db). It holds peers' push tokens, so `a2a.db` + `-wal` / `-shm` are in the built-in sandbox hidden files. The terminal write and `clearReplyTo` are two databases and not atomic; they're ordered to stay idempotent (Completion hook above).
|
|
248
274
|
|
|
249
275
|
```sql
|
|
250
276
|
a2a_tasks(id PK, workspace, context_id, account_id, message_id, state, status_text, error_kind, result,
|
|
@@ -255,7 +281,9 @@ a2a_push_configs(task_id, id, url, token, auth_scheme, auth_credentials, created
|
|
|
255
281
|
a2a_push_outbox(id PK autoincrement, task_id, config_id, event_key, payload, attempts, next_at, last_error, dead,
|
|
256
282
|
created_at, UNIQUE(task_id, config_id, event_key)) -- idx (dead, next_at)
|
|
257
283
|
a2a_dispatches(id PK, workspace, session_id, remote, push_id, push_token, rpc_url, remote_task_id,
|
|
258
|
-
remote_context_id, state, last_interim, created_at, updated_at)
|
|
284
|
+
remote_context_id, state, last_interim, poll, created_at, updated_at)
|
|
285
|
+
-- state: working | completed | failed | canceled | rejected | input-required | auth-required
|
|
286
|
+
-- poll: 1 = remote can't push, GetTask on a backoff (v3)
|
|
259
287
|
-- idx (push_id), (state), (workspace, remote, remote_context_id)
|
|
260
288
|
```
|
|
261
289
|
|
|
@@ -267,7 +295,8 @@ a2a_dispatches(id PK, workspace, session_id, remote, push_id, push_token, rpc_ur
|
|
|
267
295
|
|
|
268
296
|
## Known limitations
|
|
269
297
|
|
|
270
|
-
- **Nothing wakes a waiting caller.** Delivery is event-driven: a push, or the boot reconcile. If a push never arrives (receiver unreachable past the remote's retries, our doorbell GetTask failing until the remote's sender gives up, remote lost the task without a terminal push), the caller agent just doesn't hear back — there is no timer. `a2a_read` on demand is the check.
|
|
298
|
+
- **Nothing wakes a waiting caller of a push remote.** Delivery from a push-capable remote is event-driven: a push, or the boot reconcile. If a push never arrives (receiver unreachable past the remote's retries, our doorbell GetTask failing until the remote's sender gives up, remote lost the task without a terminal push), the caller agent just doesn't hear back — there is no timer. `a2a_read` on demand is the check. Only remotes that can't push are polled (above).
|
|
299
|
+
- **A waiting row is never fetched on its own.** An `input-required` / `auth-required` row is neither polled (even for a remote that can't push) nor fetched at boot; if the remote resumes by itself (auth completed out of band), only its push — when it can push — tells us. Otherwise `a2a_read` is the check.
|
|
271
300
|
- **A push that beats the dispatch row is refused.** `a2a_send` writes its `a2a_dispatches` row only once SendMessage has answered. A push arriving before that — the remote finished (or, on a reused webhook, started and finished a new task) faster than the caller processed the answer — gets 404, which a sender treats as permanent. The window is narrow: the insert runs synchronously right after the answer is parsed, and a terminal SendMessage answer is delivered directly. A push that is lost this way leaves the row `working` until the next boot reconcile GetTasks it; until then, `a2a_read` is the only check.
|
|
272
301
|
- **Interims need the remote's GetTask to carry them.** The receiver reads interims only from the fetched `status.message`. A non-halo remote that pushes interims only in `statusUpdate` bodies produces no interim reports; its final report is unaffected.
|
|
273
302
|
- **No task history.** GetTask accepts `historyLength` but returns no `history`; the session transcript on the remote is the record.
|
|
@@ -283,4 +312,5 @@ a2a_dispatches(id PK, workspace, session_id, remote, push_id, push_token, rpc_ur
|
|
|
283
312
|
- `a2a-readonly-profile.test.ts` — the read-only profile's tool set and prompt (no a2a / relay, no roster) and its restart survival; workspace A2A sessions and readonly sessions of every other channel get the a2a set but not relay; hidden-path masking.
|
|
284
313
|
- `a2a-files.test.ts` — inbound image parts (raw, url, relabel, dedupe without refetch, limits, `-32005`), result `MEDIA:` attachment (sandbox incl. symlinks, limits, partial, already-terminal), push payload and ListTasks without file bytes (`halo/omittedFiles`) vs GetTask with them, the `result_files` migration, `a2a_send` files (rules, access-level sandbox), report / `a2a_read` image saving, lease-busy retry (schedule, give-up, no retry on a plain `-32603`, conflict schedule independent, cancel), and Stop ending a retried card fetch in `a2a_send` / `a2a_read` / `a2a_stop`.
|
|
285
314
|
- `a2a-push-doorbell.test.ts` — the push receiver as a doorbell: fake push content is ignored and the GetTask result is delivered; the gate (token, push id, task id) runs before any GetTask; a failed GetTask (HTTP, JSON-RPC, network) → 503 with nothing delivered; an interim via GetTask, deduped; a late-fetched WORKING never lands after the final; a halo remote's GetTask carries its latest interim.
|
|
315
|
+
- `a2a-outbound-states.test.ts` — non-halo remotes: REJECTED final once; SUBMITTED as working; AUTH_REQUIRED / INPUT_REQUIRED reported once, skipped at boot, reopened by `a2a_send` with `task_id` into the caller's session; polling for a card without push and after `-32003` (backoff, stop on wait, re-arm on answer and at boot, 24 h give-up); migration v3; the push path unchanged; per-session webhooks, incl. a halo remote folding two sessions' messages into one task.
|
|
286
316
|
- `a2a-agentcore.test.ts` — the AgentCore mode (lease, upstream exposure, `/ping`), outbound SigV4 and the `-32054` retry.
|
|
@@ -86,7 +86,7 @@ createModelRuntime(runtime: string, cfg: ModelRuntimeConfig): ModelRuntime
|
|
|
86
86
|
- `aws-bedrock-claude-invoke` → `bedrock-invoke` → `BedrockAgent` (uses the Bedrock InvokeModelWithResponseStream API — text / thinking chunks reach the UI as they are generated)
|
|
87
87
|
- `anthropic` / `mimo-token-plan-china` → `anthropic-messages` → `AnthropicAgent`, `minimax` → `MiniMaxAgent`, `qwen` → `QwenAgent` (vendor runtimes keep the vendor name; Anthropic Messages API over HTTP with `stream: true`; the SSE is folded by the same `AnthropicStreamAccumulator` as Bedrock via `fetchAnthropicStream` in `anthropic-stream.ts`, so they stream too)
|
|
88
88
|
- The OpenAI-family (`openai` → `openai-chat` → `OpenAIAgent`; `deepseek` / `kimi` / `zhipu` / `doubao` / `hunyuan` → same-named vendor runtimes) → `chat/completions` over HTTP with `stream: true` + `stream_options.include_usage`; the `chat.completion.chunk` SSE is folded back into the non-streaming `choices[0].message` shape by `fetchChatCompletionStream` in `openai-chat-stream.ts` (a transport helper, not a base class; message/tool conversion and result parsing are shared via `openai-chat-format.ts`, while each agent keeps its own request body / thinking / usage math), so they stream too
|
|
89
|
-
- Mantle (`aws-bedrock-mantle` / `aws-bedrock-openai`) → `bedrock-mantle` → `MantleAgent`, OpenAI Responses API over HTTP with `stream: true`; `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` frames live and takes the full final response object off the terminal `response.completed` / `response.incomplete` frame, so the existing `output[]` parse runs unchanged (bedrock-mantle sends no `[DONE]`, bedrock-runtime does — `readSseJson` skips it either way)
|
|
89
|
+
- Mantle (`aws-bedrock-mantle` / `aws-bedrock-openai`) → `bedrock-mantle` → `MantleAgent`, OpenAI Responses API over HTTP with `stream: true`; `readResponsesStream` (module-level in `mantle-agent.ts`) reports `response.output_text.delta` frames live (and, as thinking, `response.reasoning_summary_text.delta` — GPT summaries, requested with `reasoning.summary: 'auto'` when Thinking is on — and `response.reasoning.delta`, Kimi K3's raw reasoning) and takes the full final response object off the terminal `response.completed` / `response.incomplete` frame, so the existing `output[]` parse runs unchanged (bedrock-mantle sends no `[DONE]`, bedrock-runtime does — `readSseJson` skips it either way)
|
|
90
90
|
- Every provider streams; the `onDelta` hook is still optional per provider, so a future non-streaming one just ignores it
|
|
91
91
|
|
|
92
92
|
**Adding a new provider**:
|
|
@@ -34,8 +34,8 @@ Runtime names (`runtime:` value → class): `bedrock-invoke` → `BedrockAgent`,
|
|
|
34
34
|
| `openai` | `openai-chat` | `OpenAIAgent` | `openai-agent.ts` | **Generic OpenAI-compatible**. Use for native OpenAI or any third-party OpenAI gateway (incl. Ollama / llama.cpp). Ships a short Bedrock Mantle list callable with a Bedrock API key (`openai.gpt-5.5` default, `openai.gpt-5.4`; `defaultEndpoint` Mantle us-east-1 `/openai/v1`, presets us-east-1 / us-west-2 / api.openai.com — GPT-6.x / 5.6 reject `max_tokens`, so they stay on the Responses-API providers) plus the legacy `gpt-4o` placeholder (official API only); any other endpoint + model id can be typed, and capabilities for unlisted ids are openly editable in the form. Streaming via `fetchChatCompletionStream` — always sends `stream: true` + `stream_options: { include_usage: true }` (OpenAI's own field; Ollama ≥ 0.3 / vLLM / LM Studio accept it too). A gateway that rejects unknown fields would 400 — accepted trade-off, no non-streaming fallback. Forgiving about cache field names (`cachedPromptTokens`: first value > 0 of `prompt_tokens_details.cached_tokens`, top-level `cached_tokens`, `prompt_cache_hit_tokens`, `cache_read_tokens`) and reasoning field names (`reasoning_content` for OpenAI o-series/DeepSeek, `reasoning` for Ollama/llama.cpp). |
|
|
35
35
|
| `anthropic` | `anthropic-messages` | `AnthropicAgent` | `anthropic-agent.ts` | **Generic Anthropic Messages**. Use for api.anthropic.com or any Anthropic-compatible gateway. Ships a short Bedrock Mantle list callable with a Bedrock API key as `x-api-key` (`anthropic.claude-opus-5-5` default, `anthropic.claude-haiku-4-5`; `defaultEndpoint` Mantle us-east-1 `/anthropic`, presets us-east-1 / us-west-2 / api.anthropic.com) plus the legacy `claude-sonnet-4-6` placeholder (official API only); any other endpoint + model id can be typed. Streaming via `fetchAnthropicStream` (the gateway must support `stream: true` SSE — the reference Anthropic behaviour). Standard `cache_control: ephemeral` with optional `ttl: '1h'`. |
|
|
36
36
|
| `mimo-token-plan-china` | `anthropic-messages` | `AnthropicAgent` | (reuses `anthropic-agent.ts`) | Xiaomi MiMo (UI name "Xiaomi MiMo"; the id keeps its historical name so existing configs don't break) — Anthropic-compatible gateway, streams like `anthropic` (gateway built for Claude Code, which always streams; not live-probed); no dedicated class, just `runtime: anthropic-messages` in its yaml (`defaultEndpoint` `token-plan-cn.xiaomimimo.com/anthropic`). The key type picks the endpoint: Token Plan `tp-`/`ttp-` keys only work on `token-plan-{cn,sgp,ams}.xiaomimimo.com/anthropic`, pay-as-you-go `sk-` keys only on `api.xiaomimimo.com/anthropic` — all four ship as `endpointPresets` (pay-as-you-go live-probed 2026-09-24; sgp / ams from the docs only). V2.6 (`mimo-v2.6-pro` / `-flash`, both vision, 1M / 131072) is the only generation listed; `mimo-v2.5(-pro)` (officially retired 2026-10-21) were dropped from the template in revision 2026101401 |
|
|
37
|
-
| `aws-bedrock-mantle` | `bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 Sol, GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves GPT-6.1 Sol + GPT-6 Sol / Luna + every 5.6 model (
|
|
38
|
-
| `aws-bedrock-openai` | `bedrock-mantle` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so its yaml just says `runtime: bedrock-mantle` + `extractRegionFromHost` accepts both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all nine `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same
|
|
37
|
+
| `aws-bedrock-mantle` | `bedrock-mantle` | `MantleAgent` | `mantle-agent.ts` | OpenAI GPT-6.1 (Sol / Terra), GPT-6 (Astra / Sol / Luna) and GPT-5.6 (Sol / Terra / Luna) on Amazon Bedrock via the **OpenAI Responses API** (not Completions/Converse/InvokeModel). Vision, reasoning via `reasoning.effort` (low/med/high/xhigh/max, no budget_tokens; on 5.6 `max` is accepted live but absent from the model's own validation list — measured ~6% more reasoning_tokens than xhigh; on Astra it's on the official list), `text.verbosity` (low/medium/high, same on both generations). **Region split** (live-probed 2026-09-24): us-east-1 serves every model — GPT-6.1 Sol / Terra + GPT-6 Astra / Sol / Luna + every 5.6 model (GPT-6.1 Sol probed 2026-09-30 and Terra 2026-10-27, both us-east-1 only; Astra on us-east-1 probed 2026-10-22 and on its model card); us-east-2 serves only the 5.6 trio; us-west-2 serves Astra (Standard tier only — Astra's Ultrafast is Mantle us-east-1 only) + 5.6 Terra / Luna. Template ships all three as `endpointPresets` (us-east-1 / us-west-2 / us-east-2, default us-east-1, `openai.gpt-6-sol` default model) and pins each model's regions with per-model `endpoints:` (Astra us-east-1 + us-west-2; GPT-6.1 Sol / Terra / GPT-6 Sol / Luna us-east-1; GPT-5.6 Sol us-east-1 + us-east-2; Terra / Luna none = all presets), so the form only offers — and on a model switch moves to — a region that serves the model; `extractRegionFromHost` falls back to us-east-1. GPT-6 Astra / Sol / Luna: 1,050,000 context / 128,000 max output; GPT-6.1 Sol 1M / 131,072 (card; Mantle accepts any `max_output_tokens` ≥ 16, so the cap isn't enforced server-side); same effort (none/low/medium/high/xhigh/max) + verbosity surface. Input beyond 272K tokens bills at 2x, so every entry ships `contextWindow: 272000` (what a new agent gets) with the 1M-class figure as `maxContextWindow`; raise `context.maxTokens` past 272K only on purpose. Reasoning summaries: with Thinking on, `MantleAgent` sends `reasoning: { effort, summary: 'auto' }` and folds `output[].reasoning.summary` (`summary_text`) into thinking, streamed live from `response.reasoning_summary_text.delta` (without the field the summary stays empty while `reasoning_tokens` counts up). Live-probed 2026-11-05 at effort low on a trivial prompt: GPT-6 Sol and Astra (us-east-1) return a summary; GPT-6.1 Terra and GPT-5.6 Luna answer 200 with an empty one, so it may be empty on simple prompts — not "never". `auto`, not `detailed`: one `detailed` run (bedrock-runtime GPT-6 Sol) came back empty. A reply that needs no reasoning shows no thinking panel. Streams (`stream: true`, Responses API SSE folded by `readResponsesStream` in the same file): `response.output_text.delta` frames go out live, the terminal `response.completed` / `response.incomplete` frame carries the full final response object; this host sends **no** `data: [DONE]` (live-probed 2026-09-29). |
|
|
38
|
+
| `aws-bedrock-openai` | `bedrock-mantle` | `MantleAgent` | (reuses `mantle-agent.ts`) | Frontier models on Bedrock via the **bedrock-runtime** host's OpenAI Responses surface (`https://bedrock-runtime.<region>.amazonaws.com/openai/v1` — AWS's recommended endpoint for new apps; Mantle is the compatibility one). Same wire format, so its yaml just says `runtime: bedrock-mantle` + `extractRegionFromHost` accepts both hostnames (SigV4 signs with the host's region — a mismatched region 401s "Credential should be scoped to a valid region"). Rules that differ from Mantle: model ids must be cross-Region inference profiles (`global.*` / `us.*`; bare ids 400 "on-demand throughput isn't supported"); **no region split** — live-probed 2026-09-24, all nine `global.*` ids answer 200 from us-east-1 / us-west-2 / eu-west-1 / ap-northeast-1 alike; **no server-side tools** — `tools:[{type:"web_search"}]` 400s "web search is not supported for this request", which is why the web-search skill's deep gear stays on Mantle. Ships the same eight **GPT-6.1 / 6 / 5.6** entries as the Mantle template (`global.openai.gpt-6.1-{sol,terra}`, `global.openai.gpt-6-{astra,sol,luna}`, `global.openai.gpt-5.6-{sol,terra,luna}`; identical effort none/low/medium/high/xhigh/max + verbosity surface per each model's own validation error — except Astra and GPT-6.1 Sol 400 on `none`, which the runtime never sends), plus **Grok 4.6** (500K ctx, 2x input price past 200K, max output undisclosed → 64000 carry-over, effort **only** low/medium/high/xhigh — `none`/`max` 400, so no Max preset; vision OK; $2/$6) and **Kimi K3** (1M ctx / 131072 out, effort none…max all accepted, ladder advisory on short prompts; vision OK; $3/$15). All: implicit prompt caching verified (run 1 `cache_write_tokens`, runs 2+ `cached_tokens` ≈ prefix). Reasoning text: same request as Mantle (`reasoning: { effort, summary: 'auto' }` with Thinking on); the thinking panel takes `summary[]` `summary_text` (GPT) and `content[]` `reasoning_text` (Kimi K3's raw reasoning — no summary, streamed as `response.reasoning.delta`). Live-probed 2026-11-05 at effort low on a trivial prompt: GPT-6 Sol returns a summary and Kimi K3 its reasoning_text; GPT-6 Luna and Grok 4.7 / 4.6 answer 200 with no text (may be empty on simple prompts, not "never"); GLM 5.3 untested (429). Streams like Mantle (same event vocabulary) but this host **does** append a trailing `data: [DONE]`, which `readSseJson` skips; grok-4.6 emits a `reasoning` output item (no summary text deltas) before the `message` item. |
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -408,9 +408,11 @@ Parts: `{ text }`, and images `{ raw: <base64>, mediaType, filename? }` / `{ url
|
|
|
408
408
|
**`POST /a2a-push/:pushId`** (receiver): token in `X-A2A-Notification-Token` (or `Authorization: Bearer`), compared constant-time with the dispatch's; unknown id or wrong token → 404 `{error: "not found"}`. Body `{ task }` or `{ statusUpdate }` whose task id must belong to that dispatch (else 404 `{error: "unknown task"}`); invalid JSON → 400.
|
|
409
409
|
|
|
410
410
|
The push is a doorbell: only the task id is read from the body. The receiver then runs its own authed GetTask on the remote, with a 10 s deadline:
|
|
411
|
-
- **GetTask succeeds:** answers `{ ok: true }` and injects the fetched Task into the calling session asynchronously — the report when it is terminal, an interim when it is WORKING with a status message.
|
|
411
|
+
- **GetTask succeeds:** answers `{ ok: true }` and injects the fetched Task into the calling session asynchronously — the report when it is terminal (`REJECTED` included), one waiting report on `INPUT_REQUIRED` / `AUTH_REQUIRED`, an interim when it is WORKING / SUBMITTED with a status message.
|
|
412
412
|
- **GetTask fails** (network, timeout, HTTP or JSON-RPC error): answers **503** `{error: "task fetch failed, retry later"}` and delivers nothing, so the sender retries.
|
|
413
|
-
- **Dispatch already
|
|
413
|
+
- **Dispatch already final:** answers `{ ok: true }` without a fetch (a dispatch waiting for input / auth still fetches).
|
|
414
|
+
|
|
415
|
+
A remote that can't push (card without `capabilities.pushNotifications: true`, or `-32003` on SendMessage) never calls this endpoint: its dispatch is polled with GetTask instead ([design/a2a.md](../design/a2a.md#polling-remotes-that-cant-push)).
|
|
414
416
|
|
|
415
417
|
## Show (world snapshot)
|
|
416
418
|
|
|
@@ -449,7 +449,7 @@ Returns `{ "code": 0, "workspace": "<realpath>", "sessions": [{ id, agentId, age
|
|
|
449
449
|
|
|
450
450
|
File: `packages/server/src/a2a/outbound.ts` (`buildA2ATools`). Design notes in [design/a2a.md](../design/a2a.md).
|
|
451
451
|
|
|
452
|
-
Cross-**server** dispatch over A2A v1.0: the agent sends a message to a remote A2A agent (normally another halo server's exposed workspace) and the result is pushed back into this session when the remote is done, no polling. Remotes are configured once per server in `~/.halo/secrets/a2a-remotes.yaml` (no per-workspace file; `<ws>/.halo/a2a-remotes.yaml` is not read):
|
|
452
|
+
Cross-**server** dispatch over A2A v1.0: the agent sends a message to a remote A2A agent (normally another halo server's exposed workspace) and the result is pushed back into this session when the remote is done, no polling by the agent (the server polls only a remote that can't push). Remotes are configured once per server in `~/.halo/secrets/a2a-remotes.yaml` (no per-workspace file; `<ws>/.halo/a2a-remotes.yaml` is not read):
|
|
453
453
|
|
|
454
454
|
```yaml
|
|
455
455
|
remotes:
|
|
@@ -471,13 +471,16 @@ Errors are `{ "code": 1, "error": "…" }`; a remote JSON-RPC error reads `<Meth
|
|
|
471
471
|
|
|
472
472
|
### a2a_send
|
|
473
473
|
|
|
474
|
-
Send a message to a remote. Omit `context_id` to start a new conversation; pass the `context_id` from an earlier call for a follow-up — a follow-up to a busy remote is queued and softly interrupts its current step, `interrupt: true` aborts that step instead. Sent as SendMessage with `returnImmediately: true` plus a per-dispatch webhook
|
|
474
|
+
Send a message to a remote. Omit `context_id` to start a new conversation; pass the `context_id` from an earlier call for a follow-up — a follow-up to a busy remote is queued and softly interrupts its current step, `interrupt: true` aborts that step instead. Pass `task_id` too to continue that task — the way to answer an `input-required` report. Sent as SendMessage with `returnImmediately: true` plus a per-dispatch webhook. A follow-up reuses the webhook of **this session's** pending dispatch on the context, so the report comes once; another session on the same context gets its own webhook and its own report. With `task_id`, the dispatch tracking that task is reopened and reports to the calling session. Returns immediately.
|
|
475
|
+
|
|
476
|
+
A remote whose agent card doesn't declare `capabilities.pushNotifications: true`, or that answers `-32003` PushNotificationNotSupported (the message is then sent once more without the webhook), can't push: its task is polled with GetTask (5 s, doubling to every 60 s; given up as `failed` after 24 h, pointing at `a2a_read`). The agent sees the same reports either way.
|
|
475
477
|
|
|
476
478
|
| Arg | Type | Required | Description |
|
|
477
479
|
|---|---|---|---|
|
|
478
480
|
| `remote` | string | yes | Remote name from `~/.halo/secrets/a2a-remotes.yaml` (see `a2a_list`) |
|
|
479
481
|
| `message` | string | yes | The message to send |
|
|
480
482
|
| `context_id` | string | no | Conversation to continue (from an earlier `a2a_send`); omit to start one |
|
|
483
|
+
| `task_id` | string | no | Task to continue (sent as `message.taskId`), with its `context_id` — e.g. to answer an `input-required` report |
|
|
481
484
|
| `interrupt` | boolean | no | Follow-up only: abort the remote's current step instead of waiting for it |
|
|
482
485
|
| `files` | string[] | no | Absolute paths of images to attach |
|
|
483
486
|
|
|
@@ -495,7 +498,9 @@ Returns `{ "code": 0, "remote", "context_id", "task_id", "state": "working" |
|
|
|
495
498
|
[图片已保存: <path>]
|
|
496
499
|
```
|
|
497
500
|
|
|
498
|
-
`status:` is `completed`, `failed` or `
|
|
501
|
+
`status:` is `completed`, `failed`, `canceled` or `rejected` (final), or `input-required` / `auth-required` (the remote waits for us). The text is capped at `limits.autoReportMax` with a `[Report truncated: N chars total. Use a2a_read("<name>", "<T>") for the full text.]` marker. A failed task's body starts `[A2A REMOTE FAILED: the remote task did not complete. <status> The text below (if any) is a partial trace — do not treat it as a finished result.]`, a canceled one `[A2A REMOTE CANCELED: <status>]`, a rejected one `[A2A REMOTE REJECTED: <status>]`.
|
|
502
|
+
|
|
503
|
+
A waiting report arrives once per wait and carries the remote's status message after an instruction line: `input-required` → `[A2A REMOTE INPUT REQUIRED: the remote is waiting for your reply — answer with a2a_send with context_id "<C>" and task_id "<T>". If you don't know the answer, ask the user.]`; `auth-required` → `[A2A REMOTE AUTH REQUIRED: the remote needs an authorization completed outside A2A — tell the user what it asks for. To reply to the remote, use a2a_send with context_id "<C>" and task_id "<T>".]`. After the answer the task's final report (or the next wait) follows as usual. Images in the result are saved under `.halo/assets/a2a/inbound/<remote>/…` and listed **after** the cap as `[图片已保存: <path>]` (open with `view_image`); image URLs are listed as `[图片: <url>]` and not downloaded; anything else gets `[file not saved: <name> — <reason>]`.
|
|
499
504
|
|
|
500
505
|
**Interim report**: when the remote answers a follow-up and keeps working, the answer arrives first as
|
|
501
506
|
|
|
@@ -531,9 +536,9 @@ Returns `{ "code": 0, "task_id", "context_id", "state": "TASK_STATE_…", "statu
|
|
|
531
536
|
|
|
532
537
|
### a2a_list
|
|
533
538
|
|
|
534
|
-
The remotes configured on this server (the same list in every workspace) — each with its card's agent name, description and skills (`{ id, name, description }`), or an `error` when the card can't be fetched — plus the dispatches this workspace is still waiting on (newest 50). No arguments.
|
|
539
|
+
The remotes configured on this server (the same list in every workspace) — each with its card's agent name, description and skills (`{ id, name, description }`), or an `error` when the card can't be fetched — plus the dispatches this workspace is still waiting on (newest 50): running ones and ones waiting for our input / auth. No arguments.
|
|
535
540
|
|
|
536
|
-
Returns `{ "code": 0, "remotes": [{ name, agent, description, skills }], "pending": [{ remote, context_id, task_id, since }] }`.
|
|
541
|
+
Returns `{ "code": 0, "remotes": [{ name, agent, description, skills }], "pending": [{ remote, context_id, task_id, state: "working" | "input-required" | "auth-required", since }] }`.
|
|
537
542
|
|
|
538
543
|
## Node tools
|
|
539
544
|
|
|
@@ -22,6 +22,12 @@ The primary surface for talking to an agent.
|
|
|
22
22
|
### Message rendering
|
|
23
23
|
- Markdown + code-block highlighting
|
|
24
24
|
- A ` ```mermaid ` block is drawn as a diagram once the message has finished: while the reply streams it stays a plain code block (half a diagram is a parse error), and settled history messages render directly. Same renderer as the Markdown preview ([editor.md](editor.md#renderable-text-formats-markdown--html)): app-themed, strict security, and a diagram that fails to render stays as source with a small error line
|
|
25
|
+
- **Clickable file paths**: inline code and markdown-link targets that look like a path open in the canvas on click (pointer cursor; plain prose is never scanned). Counts as a path: a relative path with a `/`, a bare name with an extension (1–10 chars, letter first: `CHANGELOG.md`), an absolute or `~/` path, a folder ending in `/`, Windows drive / backslash / UNC forms — each may end in `:line` or `:line:col` (a drive letter's colon is not a line). Not a path: anything with a space or `()=<>|;,*?`, `://`, a leading `-`, a version like `2.1.3` / `v2.1.2`. Existence is checked only on click (one `/fs/stat`):
|
|
26
|
+
- a workspace file opens like a click in the file tree (an absolute path inside the workspace is matched to the same relative tab); `:line` jumps there, switching Markdown / HTML to source view
|
|
27
|
+
- a file outside the workspace opens as a **read-only** tab ([editor.md](editor.md#read-only-tabs-files-outside-the-workspace)); over 10 MB → "file too large"
|
|
28
|
+
- a workspace folder is expanded and selected in the tree; a folder outside → "not in the current workspace"
|
|
29
|
+
- missing or hidden (`~/.halo/secrets`, `~/.ssh`…) → "file does not exist"
|
|
30
|
+
- an `https://` link still opens in a new browser tab. Applies to the chat panel, the Sessions tab and the archive view (same message list)
|
|
25
31
|
- Tool-call card: expandable, shows tool name / input / output
|
|
26
32
|
- Sub-agent streams stay out of the chat: their reports land as a green callout, and Debug mode adds agent start/done markers labelled with the agent name (e.g. "Coder", "Researcher")
|
|
27
33
|
- A reply that has produced nothing yet shows a spinner with "Thinking..."; once the model streams reasoning text, the spinner gives way to a **live thinking panel**: expanded, muted, with a "Thinking..." header and char count, capped at about 9 lines and tailing its own text (so the chat log grows by at most the cap). It folds to the one-line collapsed "Thinking · N chars" header (click to expand; it stays in the log) as soon as text or a tool call follows, or the message settles. Providers that stream no reasoning text stay on the spinner. Debug mode shows the same live panel; it only adds to the normal view
|
|
@@ -37,7 +37,10 @@ When agents or scripts write files, the canvas picks it up — push-based, no po
|
|
|
37
37
|
With no session selected no `file:changed` arrives (the WS subscribes on session select), so only the tab-switch / focus / reconnect checks apply.
|
|
38
38
|
|
|
39
39
|
### Tab persistence
|
|
40
|
-
Open tabs and the active tab live in localStorage; survive refresh.
|
|
40
|
+
Open tabs and the active tab live in localStorage; survive refresh. Read-only tabs (below) are not restored.
|
|
41
|
+
|
|
42
|
+
### Read-only tabs (files outside the workspace)
|
|
43
|
+
A file outside the workspace opened from a chat path link ([chat.md](chat.md#message-rendering)) opens as a read-only tab: a lock icon and "Read-only — not in the current workspace" tooltip on the tab, Monaco read-only (typing shows the same message), never saved, no `file:changed` auto-refresh, not restored after a reload. Text is read via `/fs/stat` + `/fs/raw` (10 MB cap, same as `/files`); images and other previews load from `/files/download`'s absolute-path branch. "Open as text" on an outside binary preview isn't supported.
|
|
41
44
|
|
|
42
45
|
### Diff view
|
|
43
46
|
- A tab with unsaved edits shows a **Diff** button in its pane's tab bar: last saved content vs. the current buffer, computed in the client (no git involved; `GET /api/git/diff` is the Source Control panel's)
|
|
@@ -46,6 +46,7 @@ Visual directory browser opened by the 📁🔍 button:
|
|
|
46
46
|
- Hides only well-known noise: `.git`, `.DS_Store`, `node_modules`, `__pycache__`. Other dotfiles (`.gitignore`, `.env`, `.vscode/` etc.) are shown — modern IDE convention
|
|
47
47
|
- No max depth limit
|
|
48
48
|
- Expansion state lives in localStorage; restored on refresh and lazily reloaded
|
|
49
|
+
- **Collapse All** (header button next to Refresh): folds every folder at once and clears the stored expansion state, so a reload doesn't reopen them
|
|
49
50
|
- WebSocket `file:changed` events incrementally update loaded branches; unloaded branches are left alone (re-fetched on expand)
|
|
50
51
|
- **Reconnect reconciliation**: because the tree is kept in sync purely by `file:changed` deltas, events missed while the socket was down (laptop lid, network drop) would leave it stale forever. On WS *re*connect (not first connect) the root level is silently refetched; expanded directories self-heal because their fresh nodes come back without children, which re-arms the lazy-load along the persisted expanded-paths spine
|
|
51
52
|
- The root listing is fetched once, by the panel that renders the tree (the main Explorer's editor-only panel shares its store and doesn't fetch again)
|
|
@@ -58,6 +59,8 @@ VS Code-style highlight selection (no checkboxes):
|
|
|
58
59
|
- **Bundle directory** (a folder whose suffix an installed bundle extension claims, e.g. `Standup.htrans/`, shown with a package icon): click, double-click or Enter **opens** it in its extension like a file; only the chevron expands it
|
|
59
60
|
- **Ctrl/Cmd + click**: toggle multi-select
|
|
60
61
|
- **Shift + click**: range select
|
|
62
|
+
- **Highlight**: selected rows get the selection color. The open editor tab's row is tinted only while nothing is selected, so after selecting or right-clicking another item the highlight is on that item alone, never the previously open file
|
|
63
|
+
- Right-click on an unselected item selects just that item before the menu opens; right-click inside a multi-selection keeps it
|
|
61
64
|
|
|
62
65
|
### Drag-move
|
|
63
66
|
- Selected files/folders drag onto a target dir
|