@turmind/halo 1.6.7 → 2.0.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/admin-out/404.html +1 -1
- package/admin-out/_next/static/chunks/app/page-c9fb252d0cf34c20.js +1 -0
- package/admin-out/index.html +1 -1
- package/admin-out/index.txt +2 -2
- package/bundled-docs/design/agentcore.md +27 -0
- package/bundled-docs/design/architecture.md +4 -4
- package/bundled-docs/design/background-dispatch.md +3 -3
- package/bundled-docs/design/cron.md +12 -10
- package/bundled-docs/design/halo-city.md +7 -7
- package/bundled-docs/design/session.md +99 -80
- package/bundled-docs/design/storage.md +66 -49
- package/bundled-docs/design/web.md +4 -0
- package/bundled-docs/design/ws.md +4 -3
- package/bundled-docs/dev/add-model-provider.md +1 -0
- package/bundled-docs/dev/api.md +15 -12
- package/bundled-docs/dev/deploy.md +2 -0
- package/bundled-docs/dev/env.md +1 -1
- package/bundled-docs/dev/tools.md +3 -1
- package/bundled-docs/guide/cli.md +16 -0
- package/bundled-docs/guide/sessions.md +1 -1
- package/bundled-docs/guide/testing-agents-and-skills.md +2 -2
- package/bundled-docs/requirements/chat.md +1 -1
- package/dist/index.js +3435 -1789
- package/package.json +1 -1
- package/templates/agents/__score__/AGENT.md +1 -1
- package/templates/schema.sql +5 -1
- package/admin-out/_next/static/chunks/app/page-3584adfe2f9a0913.js +0 -1
- /package/admin-out/_next/static/{x5jl6YGvzL0Ys4lxsHVg1 → 52ayDC9phpvb-95S7qHHq}/_buildManifest.js +0 -0
- /package/admin-out/_next/static/{x5jl6YGvzL0Ys4lxsHVg1 → 52ayDC9phpvb-95S7qHHq}/_ssgManifest.js +0 -0
|
@@ -22,9 +22,9 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
22
22
|
│ │ └── <providerId>.yaml # merged with models/ by revision (see Model registry format)
|
|
23
23
|
│ ├── skills/<id>/
|
|
24
24
|
│ │ └── SKILL.md # Skill definition (frontmatter + body)
|
|
25
|
-
│ ├── internal-sessions
|
|
26
|
-
│ │
|
|
27
|
-
│ │ └── <
|
|
25
|
+
│ ├── internal-sessions/ # Internal-agent sessions (`__evo_agent__`, `__score__`, `__apply_agent__`)
|
|
26
|
+
│ │ ├── halo.db # their agent_sessions rows — global so they don't pollute any workspace's halo.db
|
|
27
|
+
│ │ └── <agentId>/<seg>.ui.jsonl … # same files as a workspace session (see Session files)
|
|
28
28
|
│ ├── evo.db # Cross-workspace evolution queue (evolution_runs + evolution_applies)
|
|
29
29
|
│ ├── cron.db # Cross-workspace cron jobs + run history (cron_jobs + cron_runs; schema in design/cron.md, migrations below)
|
|
30
30
|
│ ├── runs.db # Run ledger: (workspace, session_id) rows for server-driven runs in flight; steady state empty (running_sessions)
|
|
@@ -58,9 +58,9 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
58
58
|
│ ├── bootstrap/ # Overrides ~/.halo/global/prompts/bootstrap/ if present
|
|
59
59
|
│ ├── all/ # Overrides ~/.halo/global/prompts/all/ if present
|
|
60
60
|
│ └── root/ # Overrides ~/.halo/global/prompts/root/ if present
|
|
61
|
-
├── sessions/<agentId>/ # Session files
|
|
61
|
+
├── sessions/<agentId>/ # Session files: <seg>.ui.jsonl, <seg>.raw.jsonl, <seg>.ui.arch.<N>.<h>.jsonl.gz
|
|
62
62
|
├── memory/ # Project memory (dated entries)
|
|
63
|
-
├── canvas/self.html # Agent's visual face — platform-owned, force-copied
|
|
63
|
+
├── canvas/self.html # Agent's visual face — platform-owned, force-copied whenever ensureWorkspaceHalo runs (see below; design/express-self.md)
|
|
64
64
|
├── goal/<goalId>/ # Retained legacy goal-mode artifacts (GOAL_SPEC.md + decision-<n>.md); existing goals unchanged
|
|
65
65
|
├── logs/
|
|
66
66
|
├── evo/ # Self-evolution per-workspace artifacts
|
|
@@ -80,7 +80,7 @@ Defines the persisted-data format for every Halo surface. Format changes must re
|
|
|
80
80
|
└── docs/ # Project docs (requirements/design/dev/test/plans)
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
**When `.halo/` is created.** `ensureWorkspaceHalo()` (`init.ts`) creates `sessions/ agents/ skills/ logs/ memory/ canvas/ tmp/ evo/{runs,applies,history}` and copies in `canvas/self.html`. It runs each time a directory is opened as a workspace: `POST /api/fs/workspace/resolve` (the admin folder picker), channel account binding, `/workspace switch`,
|
|
83
|
+
**When `.halo/` is created.** `ensureWorkspaceHalo()` (`init.ts`) creates `sessions/ agents/ skills/ logs/ memory/ canvas/ tmp/ evo/{runs,applies,history}` and copies in `canvas/self.html`. It runs each time a directory is opened as a workspace: `POST /api/fs/workspace/resolve` (the admin folder picker), channel account binding and every inbound channel message (`resolveAccountWorkspace`), `/workspace switch`, every `getWorkspaceDb()` call, and `SessionManagerRegistry.getOrCreate()` on a cache miss only (a hit returns the manager built, and `.halo/` scaffolded, earlier in this process). `halo.db` appears the first time the workspace db is opened. `runtime.lock` appears when the server first claims the runtime. Read-only list routes (`GET /api/sessions/logs`, `GET /api/agent-configs`) call `hasWorkspaceHalo()` first. For a directory with no `.halo/` they return an empty or global-only list, so a list request never turns a directory into a workspace. The cron form's path field used to scaffold every real directory the user typed through. Show routes use `registry.peek()` for the same reason (see [halo-city.md](halo-city.md)). Any new read-only route that takes a workspace path should do the same.
|
|
84
84
|
|
|
85
85
|
Precedence: workspace > global, **at folder granularity**. For the same id, a workspace `agents/<id>/` or `skills/<id>/` folder entirely replaces the global one — every file in it, no per-file fallback to global (a workspace agent folder with only `AGENT.md` loses the global `agent.yaml`). Same whole-folder rule for `prompts/{bootstrap,all,root}/` (workspace scope directory replaces the global one). INSTRUCTIONS.md is the single-file exception: workspace root suppresses global.
|
|
86
86
|
|
|
@@ -112,42 +112,51 @@ Bias is deliberate: pid reuse can read a stale pid as "alive," which only costs
|
|
|
112
112
|
|
|
113
113
|
**`HALO_BADGE=DEV` servers never claim.** dev and prod serve the same workspaces, so prod (any other badge, or none) owns the lock outright: a DEV server (`config.server.ownsWorkspaceRuntimes === false`) builds its registry with `reconcileOrphansOnBoot: false`, skips the run-ledger eager sweep, and has channel route restore build the SessionManager directly. It never creates, rewrites or takes over `runtime.lock`, and runs no boot cleanup. Two costs: orphans left by a dev crash stay until prod's next restart cleans them, and a prod restart stamps the sub-sessions dev is running in a shared workspace stopped. That is what prod ownership means.
|
|
114
114
|
|
|
115
|
-
## Session
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
"parentSessionId": "parent_id", // sub-sessions only
|
|
133
|
-
"archiveCount": 2, // committed UI-log archive segments; absent = never archived
|
|
134
|
-
"archivedUserCount": 30, // main user turns that moved into segments
|
|
135
|
-
"messages": [SessionMessage], // UI event-log format (written by WS handler / UIState reducer)
|
|
136
|
-
"rawMessages": [AnthropicMessage], // raw Bedrock API shape (written by SessionManager saveAgentState); user text blocks start with the `[<iso>] ` arrival stamp — strip it (stripTurnStamp) before matching against `messages`
|
|
137
|
-
"output": "...", // accumulated assistant text from the latest turn
|
|
138
|
-
"lastActivityAt": "ISO8601" // latest turn's last text / tool event; null if none — read by get_session_output for released sessions
|
|
139
|
-
}
|
|
115
|
+
## Session files (2.0)
|
|
116
|
+
|
|
117
|
+
Directory: `.halo/sessions/{agentId}/` (internal agents: `~/.halo/global/internal-sessions/{agentId}/`). `{seg}` = the session id's leaf segment (`fileSegment(id)`: the part after the last `>`).
|
|
118
|
+
|
|
119
|
+
| File | Content | Write mode |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `{seg}.ui.jsonl` | UI records (below), no header | append; atomic rewrite (tmp + rename) on archive / `replaceMessageLog` / `deleteExchange` / a write by another process |
|
|
122
|
+
| `{seg}.raw.jsonl` | one model message (`AnthropicMessage`) per line; user text blocks start with the `[<iso>] ` arrival stamp — strip it (`stripTurnStamp`) before matching against the UI log | append; a change confined to the **last** message (a tool result landing in the batch's results message, a merge into the trailing user turn) rewrites that one line in place (`r+` at the last line's offset, then truncate) and appends the rest — same inode; atomic rewrite on any change to an earlier message (compaction, repair edit, image trim, `deleteExchange`) |
|
|
123
|
+
| `{seg}.migrating` | pid of the process converting this 1.x session (lock, see [Migration from 1.x](#migration-from-1x)) | `O_EXCL` create, deleted when the conversion ends |
|
|
124
|
+
| `{seg}.ui.arch.{N}.{h}.jsonl.gz` | archived UI messages (merged), one per line, gzip, oldest-first; `N` from 1, `h` = first 12 hex of sha1(first message id) | written once (tmp + rename), immutable |
|
|
125
|
+
|
|
126
|
+
No file ends in `.json` (the test-chat routes in `routes/agent-configs.ts` still list / bulk-delete `*.json`, so sessions created on 2.0 don't appear there — known limitation). A crashed atomic write can leave `<file>.tmp.<pid>.<ms>`; readers ignore it, session delete removes it.
|
|
127
|
+
|
|
128
|
+
**No metadata in files.** agentId = directory, leaf id = file name, archive count = committed segment files; full id, parent, agent name, created / updated, `title`, `exchange_count`, `context_tokens`, `total_output_tokens`, `output`, `last_activity_at` = the session's [`agent_sessions`](#tables) row. `source` (explorer / delegated) is derived from the parent.
|
|
129
|
+
|
|
130
|
+
### `ui.jsonl` records
|
|
131
|
+
|
|
140
132
|
```
|
|
133
|
+
{"t":"add","id":"<row id>","u"?:1,"m":<SessionMessage>} append a row ("u":1 = main user turn)
|
|
134
|
+
{"t":"patch","id":"<row id>","m":<SessionMessage>} replace that row, position kept
|
|
135
|
+
{"t":"inflight","id":"<row id>","m"?:<shell>,"from":k,"blocks":[ContentBlockEntry…]}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`id` leads every record, so a reader gets the first row's id from the first few KB without parsing a multi-MB line (the archive commit check).
|
|
139
|
+
|
|
140
|
+
Merge rule: rows in `add` order; `patch` replaces by id. `inflight` describes the one in-flight assistant: same id as the previous inflight record → `contentBlocks = previous.slice(0, from) ++ blocks`, `m` (shell: content, timestamp, agentName, taskId) only when it changed; a different id starts a fresh one. An `add` with the inflight id retires it. An inflight not retired at EOF (crash, or a mid-turn read by another process) is appended as the last row. It is not add-then-patch because rows (tool_call / tool_result / usage) keep landing *before* the streaming assistant, and a whole-message patch per write would rewrite every tool output of the turn each time (quadratic); the block delta writes each block's final form about once.
|
|
141
|
+
|
|
142
|
+
A last line that doesn't parse or has no trailing newline (torn write) is dropped by the reader; the next write is then a full rewrite.
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
**Writer baseline.** Every append / in-place write first `fstat`s the opened fd against the stamp this process last wrote (ino / size / mtime): another process writing the file between the writer's check and its open leaves a mismatch, and the write falls back to a full rewrite from memory instead of appending to a file it no longer knows. A short `write` throws and drops the baseline, so the next write rewrites clean.
|
|
143
145
|
|
|
144
|
-
|
|
146
|
+
**Crash fragments.** A raw tail rewrite writes the new lines first, then truncates: a crash in between leaves the new lines intact followed by a fragment of the old last line. That fragment doesn't parse and is skipped (a mid-file bad line is skipped, never fatal); the reader then has no baseline and the next write rewrites the file clean. Never a file without the tail message.
|
|
145
147
|
|
|
146
|
-
|
|
148
|
+
### Archive segments
|
|
147
149
|
|
|
148
|
-
|
|
150
|
+
Written at a turn end when the active `ui.jsonl` exceeds `ARCHIVE_SIZE_THRESHOLD` (5 MB): segment `N` = committed count + 1 holds everything except the newest main exchange (a compaction summary row is never a cut point), then `ui.jsonl` is atomically rewritten with the kept exchange — **that rename is the commit**. Committed count = segment files numbered 1..k, minus the highest when its `h` matches the active file's first row id (the rewrite never landed). Deleting a session deletes every file of it by directory listing, never by a count. Design + read path in [session.md](session.md#ui-log-archiving).
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
### Migration from 1.x
|
|
153
|
+
|
|
154
|
+
1.x kept a session in one `{seg}.json` (`messages` + `rawMessages` + a header: title, counts, tokens, `archiveCount` / `archivedUserCount`, `output`, `lastActivityAt`) with segments `{seg}.arch.{N}.json.gz`. Only `sessions/legacy-migrate.ts` reads them:
|
|
155
|
+
|
|
156
|
+
- **Automatic (2.0 only, removed in 2.1).** The lowest-level log open (`session-log.ts` `openLog`), which every reader and writer goes through, checks for `{seg}.json` without `{seg}.ui.jsonl` — on ENOENT, and also when only `raw.jsonl` exists (a half conversion) — and converts. Disk state, not memory, makes it once per session. **A failed conversion throws** `LegacyConversionError` from the open (readers and writers alike) instead of serving an empty session; every open retries. **Serialized across processes** by `{seg}.migrating` (`O_EXCL`, holding the converter's pid): a live holder is waited on synchronously for up to 10s and the waiter goes on as soon as the conversion landed (`ui.jsonl` present and `.json` gone); a dead pid's, our own pid's or an empty stale lock is taken over; past the wait the open throws. Single host only. Design: [session.md](session.md#session-files).
|
|
157
|
+
- **Command** `halo sessions migrate v1 v2` (stays long-term): every session of a workspace plus the global internal ones; takes the same per-session lock (its leftover cleanup too — a session converted by another process meanwhile counts as skipped), so it would be safe beside a live server; the refusal while a live process holds the workspace's `runtime.lock` stays as the conservative default; prints `migrated / cleaned / skipped / failed`, exit 1 on any failure.
|
|
158
|
+
- **Order**: `raw.jsonl` → committed segments (n ≤ header `archiveCount`) → db back-fill (the row's NULL columns from the header; an internal session gets its global row) → `ui.jsonl` (**the commit**) → read back through the 2.0 readers and compare counts and first / last ids → delete the 1.x files (`.json`, every `arch.*.json.gz` except a committed one that couldn't be read, `.json.tmp.*`). A failed check removes the new files and keeps the 1.x ones. A crash before the commit converts again on the next open; between commit and delete the command finishes it ("cleaned", after checking the 1.x history is an id-prefix of the 2.0 one).
|
|
159
|
+
- **Downgrade**: a migrated session has no `.json` any more, so 1.x shows it empty (its row stays), as it does sessions created on 2.0.
|
|
151
160
|
|
|
152
161
|
### SessionMessage
|
|
153
162
|
|
|
@@ -200,6 +209,13 @@ interface SessionMessage {
|
|
|
200
209
|
|
|
201
210
|
// Soft delete (deleteExchange) — greyed out in the UI, removed from raw context
|
|
202
211
|
deleted?: boolean
|
|
212
|
+
|
|
213
|
+
// user row holding a compaction summary (emitted mid-turn) — never an archive cut point
|
|
214
|
+
compactSummary?: boolean
|
|
215
|
+
|
|
216
|
+
// main user row: its arrival stamp (ISO) — the raw copy's text block starts
|
|
217
|
+
// with `[<stamp>] `; deleteExchange's turn key. Absent on rows from before it
|
|
218
|
+
stamp?: string
|
|
203
219
|
}
|
|
204
220
|
```
|
|
205
221
|
|
|
@@ -254,15 +270,13 @@ Every reader goes through `messageToolCalls(msg)` (`sessions/session-types.ts`):
|
|
|
254
270
|
|
|
255
271
|
1. Missing `type`: infer from `role` + existing fields
|
|
256
272
|
2. Missing `contentBlocks`: fall back to the `toolCalls` + `content` split layout — the only reason `toolCalls` is still read
|
|
257
|
-
3. Missing `
|
|
258
|
-
4.
|
|
273
|
+
3. Missing `compactSummary`: a user row whose content starts with `[Conversation Summary — ` is a summary
|
|
274
|
+
4. 1.x `{seg}.json` files: converted on first open (see [Migration from 1.x](#migration-from-1x)); no 2.0 reader reads them
|
|
259
275
|
|
|
260
276
|
### Change rules
|
|
261
277
|
|
|
262
|
-
-
|
|
263
|
-
-
|
|
264
|
-
- Persisted fields **must not** be removed
|
|
265
|
-
- Adding optional fields does **not** require a version bump
|
|
278
|
+
- A change to the file layout or record shape is a new format version with its own migration in `legacy-migrate.ts`-style code at the log open — readers and writers know only the current format
|
|
279
|
+
- `SessionMessage` fields **must not** be removed; adding optional fields needs no version
|
|
266
280
|
|
|
267
281
|
## Agent Config format
|
|
268
282
|
|
|
@@ -603,7 +617,7 @@ Channel DB: `~/.halo/secrets/channels/channels.db`
|
|
|
603
617
|
Schema seed: `packages/server/templates/schema.sql`
|
|
604
618
|
ORM: `packages/server/src/db/schema.ts`, `packages/server/src/db/channel-db.ts`
|
|
605
619
|
|
|
606
|
-
The databases hold session metadata
|
|
620
|
+
The databases hold session metadata (the only home of title / counts / tokens / latest output — session files carry none) and workspace-scoped preferences (e.g. disabled items); message content lives in the session JSONL files. Internal-agent sessions keep the same `agent_sessions` table in `~/.halo/global/internal-sessions/halo.db`. Agent/skill/project config goes through the filesystem.
|
|
607
621
|
|
|
608
622
|
### Tables
|
|
609
623
|
|
|
@@ -623,12 +637,14 @@ The databases hold session metadata indexes and workspace-scoped preferences (e.
|
|
|
623
637
|
| archived_at | INTEGER | Archived timestamp (null = not archived) |
|
|
624
638
|
| goal / goal_session_id | TEXT | Retained legacy columns (goal mode binding JSON / back-pointer); existing bindings are unchanged and still honored |
|
|
625
639
|
| reply_to | TEXT | Relay — JSON `{ workspace, sessionId }` of the caller session in another workspace; set by `relay_send`, cleared on report delivery. Added in `HALO_MIGRATIONS` slot (`db/index.ts`). |
|
|
626
|
-
| title | TEXT |
|
|
627
|
-
| exchange_count | INTEGER |
|
|
628
|
-
| context_tokens | INTEGER |
|
|
629
|
-
| total_output_tokens | INTEGER |
|
|
640
|
+
| title | TEXT | Session title, sticky once set |
|
|
641
|
+
| exchange_count | INTEGER | Main user turns over the session's lifetime (kept + archived); new rows 0 |
|
|
642
|
+
| context_tokens | INTEGER | Latest context size |
|
|
643
|
+
| total_output_tokens | INTEGER | Output tokens over the session's lifetime |
|
|
644
|
+
| output | TEXT | Latest turn's accumulated assistant text. Added in `HALO_MIGRATIONS` v3 |
|
|
645
|
+
| last_activity_at | TEXT | ISO time of the latest turn's last text / tool event; null if none. Added in `HALO_MIGRATIONS` v3 |
|
|
630
646
|
|
|
631
|
-
|
|
647
|
+
`title` … `total_output_tokens` are written by `updateSessionMeta()` off the single UI-log persist seam (`SessionManager.persistUILog`) and by the PATCH-rename path; `output` / `last_activity_at` by `updateSessionOutput()` on raw saves — mid-turn (`tool_call` / `tool_result` / `usage`, the save before each model call) at most once per 5s per session with a trailing write of the latest values, exact at turn end / release / Stop / compaction / `deleteExchange` / shutdown (a session mid-compact too), so a hard kill can leave them up to 5s behind the raw log. Both skip no-op writes and never touch `updated_at` (that column means "last activity"). `exchange_count` null = a row from before 2.0 → the first probe of its UI log migrates the 1.x file and back-fills the null columns from its header. An internal-agent session's metadata columns live only in the global `internal-sessions/halo.db` row (`sessionMetaDb()`); its workspace row's copy is never updated after creation and never read. Details in [session.md](session.md#sqlite-metadata).
|
|
632
648
|
|
|
633
649
|
**`disabled_items`** — per-workspace disable state for agents and skills (allows the same global item to be independently toggled in each workspace)
|
|
634
650
|
|
|
@@ -672,7 +688,7 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
672
688
|
- `schema.sql` / each db's `CREATE_SQL` always describes the FULL current shape, so a fresh db is complete after the CREATEs alone
|
|
673
689
|
- Every change to an already-existing db gets a numbered slot in that file's ordered migration list — `HALO_MIGRATIONS` (`db/index.ts`), `CRON_MIGRATIONS` (`db/cron-db.ts`), `A2A_MIGRATIONS` (`db/a2a-db.ts`); `channel-db.ts` / `evo-db.ts` / `runs-db.ts` currently pass an empty list. `runMigrations(sqlite, list)` (`db/migrate.ts`) runs `list[user_version..]` in order, each in its own transaction, stamping `PRAGMA user_version = i + 1` after each
|
|
674
690
|
- A fresh db is also at `user_version 0` and runs the whole list, so every slot must be a no-op against the current shape (`addColumnIfMissing`, `CREATE … IF NOT EXISTS`). A CHECK constraint can't be altered in SQLite: the slot rebuilds the table (create `_v2`, copy rows, drop, rename) and skips when `sqlite_master` already shows the new constraint — `HALO_MIGRATIONS` v2 (`disabled_items.scope` admits `'extension'`) is the pattern
|
|
675
|
-
- Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md)
|
|
691
|
+
- Current slots worth knowing: `HALO_MIGRATIONS` v2 — the `disabled_items` rebuild above (rows kept). `HALO_MIGRATIONS` v3 — `agent_sessions.output TEXT`, `agent_sessions.last_activity_at TEXT` (session store 2.0: the files carry no metadata). `CRON_MIGRATIONS` v5 — `cron_jobs.working_dir TEXT NULL` (job folder → `halo cli --dir`), `cron_jobs.stateless INTEGER NOT NULL DEFAULT 0` (1 = fresh session every run), `cron_runs.session_id TEXT NULL` (the session that run used). `CRON_MIGRATIONS` v6 — `cron_runs.workspace_path TEXT NULL` (the workspace the run fired against, where a stateless run's session lives), backfilled from the job row (`UPDATE … SET workspace_path = (SELECT workspace_path FROM cron_jobs …) WHERE workspace_path IS NULL`), so rows whose job is already gone stay NULL; column semantics in [cron.md](cron.md)
|
|
676
692
|
- Append only — never reorder or edit a shipped slot. A db opened by a newer halo (user_version > list length) logs a warning and continues
|
|
677
693
|
- Do not drop or rename existing columns
|
|
678
694
|
- New channel types: add rows with a new `channel_type` value + define config shape in the channel's `accounts.ts` adapter
|
|
@@ -692,3 +708,4 @@ All channel types (telegram, web, wechat, slack, feishu, wecom) share one table.
|
|
|
692
708
|
| v1+ | 2026-09-20 | Schema migrations moved from ad-hoc boot-time ALTERs to `PRAGMA user_version` + ordered per-db migration lists (`db/migrate.ts`); existing dbs pick up at slot 0 and every slot is idempotent. Session-message + WS frame types moved to `@turmind/halo-core/protocol` (re-exported from their old server paths; no wire change). |
|
|
693
709
|
| v1+ | 2026-10-10 | Model registry (additive): per-model `endpoints:`, `maxContextWindow`, `compressAt`; recommended `contextWindow` ≤ 272000; registry context / compressAt / verbosity defaults are scaffold-only; installer validates model fields and asks consent for URLs new to a provider. `HALO_MIGRATIONS` v2: `disabled_items.scope` admits `'extension'`. `CRON_MIGRATIONS` v5: `cron_jobs.working_dir`, `cron_jobs.stateless`, `cron_runs.session_id`. |
|
|
694
710
|
| v1+ | 2026-10-13 | `CRON_MIGRATIONS` v6 (additive): `cron_runs.workspace_path`, backfilled from the job row (NULL where the job is already gone). |
|
|
711
|
+
| v2 | 2026-10-15 | Session store 2.0: `{seg}.json` replaced by append-only `{seg}.ui.jsonl` (`add` / `patch` / `inflight` records) + `{seg}.raw.jsonl`; archive segments `{seg}.ui.arch.{N}.{h}.jsonl.gz`, committed count = segment files (no stored counter), archived at turn end over 5 MB; no metadata in files — `HALO_MIGRATIONS` v3 adds `agent_sessions.output` / `last_activity_at`, internal sessions get rows in `~/.halo/global/internal-sessions/halo.db`; `SessionMessage.compactSummary`. 1.x files are converted on first open (or `halo sessions migrate v1 v2`) and deleted after a read-back check. |
|
|
@@ -93,6 +93,10 @@ data: {"type":"error","error":"..."}
|
|
|
93
93
|
|
|
94
94
|
> **`toolUseId`**: both tool frames carry the provider's tool_use id, so a client can pair a result with its call even when calls interleave (the [ACP adapter](../dev/acp-adapter.md) uses it as the ACP `toolCallId`, and it matches the `toolUseId` in `/web/history`'s assistant `contentBlocks`). Additive — clients that pair by order can ignore it. It can be an empty string (some OpenAI-compatible streams carry no id); treat empty as missing.
|
|
95
95
|
|
|
96
|
+
#### A failure ends the stream with `error`
|
|
97
|
+
|
|
98
|
+
A throw out of the stream's source (`handleMessage` / `subscribe` — e.g. `sendUserMessage` refusing a session whose 1.x conversion failed, see [session.md](session.md#session-files)) is caught in `streamWithKeepalive`, logged (`[WebChannel] SSE stream failed`, with the stack), and ends the stream with `data: {"type":"error","error":"<message>"}` — skipped when the client is already gone. The HTTP status stays 200 (the stream had started), so a client must read the `error` frame, not the status. Before 2.0 such a throw closed the stream bare after the `session` frame, indistinguishable from a reply with no text.
|
|
99
|
+
|
|
96
100
|
#### SSE keepalive
|
|
97
101
|
|
|
98
102
|
`/web/chat` and `/web/subscribe` write an SSE comment line `: keepalive` every 15 s (`SSE_KEEPALIVE_MS` / `streamWithKeepalive` in `routes/web.ts`; the timer is cleared when the stream ends). A long tool call can otherwise leave the stream silent long enough for an idle reverse proxy / CDN to cut it. Comment lines aren't events — `EventSource` and any spec-following parser drop them; a hand-rolled parser must skip lines that don't start with `data:`.
|
|
@@ -66,7 +66,7 @@ Both directions are typed in `packages/core/src/protocol/ws-frames.ts` (`WsClien
|
|
|
66
66
|
| `chat:stop` | Hard-abort the current generation (ends the turn, no re-run) → `stopUserSession`. During a manual `/compact` (compacting, no turn running) it cancels the compact instead (`cancelCompact`, history rolls back). During an auto-compact — which runs inside a turn and is not cancellable — the abort takes effect once the compact finishes. |
|
|
67
67
|
| `chat:interrupt` | Interrupt the in-flight turn now (aborts a command mid-run); the server then folds any queued messages into one follow-up turn → `interruptSession`. Admin chat esc maps to this. Same compact semantics as `chat:stop`: a manual `/compact` with no turn running is cancelled; an auto-compact finishes first, then the interrupt lands. |
|
|
68
68
|
| `session:delete` | Delete session files + cascade-delete descendants in SQLite (handled inline), then release this connection's listener for it (no save — the session is gone) |
|
|
69
|
-
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the
|
|
69
|
+
| `exchange:delete` | Delete one exchange (a user turn + its responses): **soft-delete** in the UI log (`deleted: true` markers, kept visible/greyed) + **physical-delete** the turn's copy from `rawMessages` (LLM context; located by the row's `stamp` — a row from before stamps by its text, among old rows and turns only) → `deleteExchange`. Fields: `userOrdinal` (0-based index among *main-conversation* user turns, i.e. excluding `taskId` sub-agent messages — matches the admin's `isMainConversationMessage` count), `archiveCount` (the archived-segment count the client's view was opened against — its archive anchor, from the `state:snapshot` it got on subscribe), optional `sessionId` / `projectId`. Rejected (→ `error`) while the session is running or compacting. The server also compares `archiveCount` against the archive segments on disk (`countArchiveSegments`); only a **mismatch** is refused, with `code: 'archived'` ("… archived history since it was opened — reopen it to delete individual turns") — a session with archives can still be deleted from as long as the anchor matches. A turn after the last compaction whose raw copy can't be located (a stamped row whose block raw doesn't hold — never a text fallback; an unstamped row merged with others, or its text not found), a stamp shared by two rows or two raw blocks, or a summary row is refused with `code: 'raw_unmatched'`, nothing changed. See [session.md](session.md#exchange-deletion-soft-ui--hard-raw). |
|
|
70
70
|
| `command:<name>` | Route through shared `dispatchCommand` (see [command.md](command.md)); `/session compact` handled inline for UI callbacks |
|
|
71
71
|
| `terminal:start` | Spawn a new PTY |
|
|
72
72
|
| `terminal:input` | Send keystrokes. `terminalId` is **required** — an id-less frame is logged and dropped (it used to fall back to the first entry of the process-global terminal map, which with several admin connections open could write into another browser's PTY) |
|
|
@@ -96,6 +96,7 @@ Source: [event-processor.ts](../../../packages/server/src/ws/event-processor.ts)
|
|
|
96
96
|
| `stream_delta` (no taskId) | `chat:stream` | text, agentName, turnId, sessionId |
|
|
97
97
|
| `thinking` (no taskId) | `chat:thinking` | text, agentName, turnId, sessionId — **not forwarded** when `streamed: true` |
|
|
98
98
|
| `stream` (no taskId) | `chat:stream` | text, agentName, turnId, sessionId — **not forwarded** when `streamed: true` |
|
|
99
|
+
| `call_discarded` (no taskId) | `chat:discard` | turnId, sessionId — a failed model call is about to be retried: drop that turn's streamed text / thinking from the live bubble (the UI log drops them too, the model never saw them; the retry re-streams) |
|
|
99
100
|
| `agent_start` | `agent:start` | agentName, task, taskId, sessionId |
|
|
100
101
|
| `agent_done` | `agent:done` | agentName, taskId, sessionId |
|
|
101
102
|
| `tool_call` | `agent:tool_call` | tool, toolUseId, input, agentName, taskId, turnId, sessionId |
|
|
@@ -148,11 +149,11 @@ Server-internal flags on `AgentSessionEvent` that are **not** carried into the W
|
|
|
148
149
|
|
|
149
150
|
### `error` frames: optional `code`
|
|
150
151
|
|
|
151
|
-
`{ type: 'error', error }` normally means "something failed"; the admin prefixes it with `Error:` when rendering. An **expected refusal** the server already phrased for the user adds a `code`
|
|
152
|
+
`{ type: 'error', error }` normally means "something failed"; the admin prefixes it with `Error:` when rendering. An **expected refusal** the server already phrased for the user adds a `code` — both from `exchange:delete`: `'archived'` (the client's `archiveCount` anchor no longer matches the on-disk count) and `'raw_unmatched'` (the turn is a compaction summary, or its model-side copy can't be singled out — a stamped row whose block the model's history never got, an older unstamped row merged with other messages, not found, or a duplicated stamp — nothing deleted) — and the admin renders such a frame verbatim without the prefix. So: add `code` when the message is a complete user-facing sentence, omit it when the client should present it as a fault.
|
|
152
153
|
|
|
153
154
|
### Archive anchor in `state:snapshot`
|
|
154
155
|
|
|
155
|
-
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one
|
|
156
|
+
`archiveCount` rides only the snapshots sent on **subscribe** and **reattach** — one segment count per session open. The per-turn snapshots and the snapshot pushed after `exchange:delete` deliberately omit it: the client's "load older" cursor is an anchor pinned at the moment the session was opened, and a segment written mid-session (a compact while the tab is open) must not move it. The client then requests segments over HTTP (`GET /api/sessions/logs/:id/archive/:n`), not WS — one whole segment per call, cached client-side because segments are immutable. See [session.md](session.md#ui-log-archiving).
|
|
156
157
|
|
|
157
158
|
The same anchor is what the client sends back as `archiveCount` on `exchange:delete`, so the server can tell a stale ordinal from a live one.
|
|
158
159
|
|
|
@@ -149,6 +149,7 @@ export class MyProviderAgent extends AgentLoop {
|
|
|
149
149
|
- **Tool results format.** Anthropic stores tool results as `{ role: 'user', content: [{ type: 'tool_result', ... }] }`. OpenAI-compatible APIs need separate `{ role: 'tool', tool_call_id, content }` messages, and tool-result images ride in a user message right after them. `toChatMessages()` in openai-chat-format.ts does both.
|
|
150
150
|
- **Thinking/reasoning replay.** If the provider returns `reasoning_content` and requires it in follow-up messages (Kimi, DeepSeek with tool calls), it must be stored in `result.assistantBlocks` as a `{ type: 'thinking', thinking: '...' }` block and replayed as `reasoning_content` — `chatCompletionResult()` stores it and `toChatMessages()` replays it.
|
|
151
151
|
- **Reasoning field name varies.** OpenAI o-series/DeepSeek emit reasoning in `message.reasoning_content`; Ollama / llama.cpp's OpenAI-compat layer uses `message.reasoning`. The shared stream accumulator (`ChatCompletionAccumulator` in `openai-chat-stream.ts`) folds both into one reasoning stream (`reasoning_content ?? reasoning`), so every `fetchChatCompletionStream` provider gets it. A vendor-specific class should read whichever its API uses.
|
|
152
|
+
- **The stream must carry a `finish_reason`.** `ChatCompletionAccumulator.finish()` treats a stream that ends without one as cut off: it throws `OpenAIStreamTruncated`, which the turn retries with the network backoff (a usage chunk or `[DONE]` alone doesn't count as the end). Every built-in OpenAI-family provider sends it on the last choices chunk (live-probed 2026-10-17); probe a new endpoint for it before onboarding — one that never sends it would retry every turn and then fail.
|
|
152
153
|
- **Malformed tool arguments.** Parse tool-call arguments with `parseToolInput(json, toolName)` (`tool-input.ts`) — every built-in path does (Anthropic stream accumulator, `chatCompletionResult`, Mantle). It returns `{ input, error? }` and never throws: empty / missing arguments are `{}` with no error; arguments that aren't valid JSON or aren't a plain JSON object (`null`, an array, a scalar) become `{}` plus an `error` and a `[ToolInput]` warn (tool name and the first 200 chars). Pass the error through as `inputError` on the `toolCalls[]` entry (keep `input: {}` in `assistantBlocks`) — the loop then skips that call and returns an `is_error` result asking the model to resend complete JSON, instead of running the tool with `{}`. A `max_tokens` stop never runs any of the reply's tool calls; the loop answers each with an `is_error` result itself.
|
|
153
154
|
- **Cancellation.** The caller passes `signal`; pass it to `fetch()` directly.
|
|
154
155
|
- **Max tokens.** Use `config.maxTokens ?? resolveMaxOutputTokens(config.modelId)` for consistency.
|
package/bundled-docs/dev/api.md
CHANGED
|
@@ -119,10 +119,10 @@ Unified session log API — list + read session files across all agents.
|
|
|
119
119
|
| Method | Path | Purpose |
|
|
120
120
|
|---|---|---|
|
|
121
121
|
| GET | `/api/sessions/logs?projectId=` | List session metadata, keyset-paginated. Default returns each top-level row + all descendants (sidebar tree); `rootOnly=1` returns roots only (chat-panel session tabs). A `projectId` with no `.halo/` (plain or missing directory) returns `{sessions: [], nextCursor: null}` without scaffolding it — a list never turns a directory into a workspace |
|
|
122
|
-
| GET | `/api/sessions/logs/:id?projectId=` | Full session log
|
|
122
|
+
| GET | `/api/sessions/logs/:id?projectId=` | Full session log — the **active** file only; archived history is a separate call. `projectId` is required: the row names the session's directory (404 without it) |
|
|
123
123
|
| GET | `/api/sessions/logs/:id/archive/:n?projectId=` | One archived UI-log segment — see [detail](#get-apisessionslogsidarchivenprojectidabs) |
|
|
124
124
|
| DELETE | `/api/sessions/logs/:id?projectId=` | Permanently delete the session and all its descendants — log files (with archive segments) and `agent_sessions` rows. Returns `{ok, deleted}` |
|
|
125
|
-
| PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only; body `{title}`) — updates the
|
|
125
|
+
| PATCH | `/api/sessions/logs/:id?projectId=` | Rename a session (admin-only; body `{title}`) — updates `agent_sessions.title` only (the row is the title's only home; no file is touched — an internal-agent session's title lives in the global internal-sessions row). 400 without `projectId` / `title`, 404 for an unknown id. Accepts any session id (root or sub-agent); the sidebar exposes the rename affordance on every row |
|
|
126
126
|
| GET | `/api/sessions/goal?projectId=` | Legacy goal binding of the workspace (`{goal: {goalSessionId, workerSessionId, status, round, maxRounds} \| null}`); refresh seed for the goal banner, which only shows when `general.goal_mode_enabled` is on |
|
|
127
127
|
|
|
128
128
|
The list endpoint returns flat metadata (id / agentId / agentName / title / timestamps / exchangeCount / parentSessionId / stoppedAt / archivedAt / contextTokens / totalOutputTokens / status / workingDir / goalSessionId — `workingDir` is the persisted workspace-relative folder, `null` = the root; `goalSessionId` is a retained legacy field, non-null only on a goal-bound worker row; existing bindings are unchanged), served from the mirrored `agent_sessions` columns rather than by parsing each session file (rows predating the columns are backfilled from the file on first read). The frontend builds the tree from `parentSessionId`.
|
|
@@ -210,7 +210,7 @@ No `/search` endpoint: the cron form takes the WeCom `chatId` verbatim (a `useri
|
|
|
210
210
|
|
|
211
211
|
## Cron
|
|
212
212
|
|
|
213
|
-
File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `cron_jobs` + read-only `cron_runs`. Mutations call `
|
|
213
|
+
File: `packages/server/src/routes/cron.ts`. Admin cookie auth. REST CRUD over `cron_jobs` + read-only `cron_runs`. Mutations call `scheduleJob()` / `unscheduleJob()` (`POST /cron/reload` calls `reloadAll()`) so the runner's in-memory schedules — its own fire timers (`armTimer` / `onTimer`), with croner only computing `nextRun` from a paused instance — stay in sync with the db. Each mutation also broadcasts `cron:job_changed`.
|
|
214
214
|
|
|
215
215
|
### Jobs
|
|
216
216
|
|
|
@@ -749,32 +749,35 @@ Archived sessions are excluded by default; pass `?includeArchived=1` to include
|
|
|
749
749
|
|
|
750
750
|
`status` is `'running' | 'idle' | 'stopped'`. A root counts as `running` while any of its sub-sessions runs. Returned since 1.5.3-alpha; the current admin no longer reads it. `parentSessionId` is present only on sub-sessions (omitted for roots). Timestamps are epoch ms.
|
|
751
751
|
|
|
752
|
-
Rows are served from the
|
|
752
|
+
Rows are served from the `agent_sessions` metadata columns (`title` / `exchange_count` / `context_tokens` / `total_output_tokens`) — their only home (for an internal-agent session, the global internal-sessions row); no session file is opened. A row whose `exchangeCount` is `null` is from before 2.0: the route probes that session's UI log once, which migrates a 1.x session and back-fills the row from its header, then reads the row again (still `null` when the session has no files). `exchangeCount` counts **main user turns over the session's lifetime** (kept + archived), so unlike `messageCount` it doesn't shrink when archiving moves history out.
|
|
753
753
|
|
|
754
754
|
### GET `/api/sessions/logs/:id?projectId=<abs>`
|
|
755
755
|
|
|
756
|
-
|
|
756
|
+
The session's `agent_sessions` row (metadata) plus its **active** UI log and model history. Older exchanges that archiving moved out are fetched per-segment via [the archive endpoint](#get-apisessionslogsidarchivenprojectidabs). If the UI log is empty and only raw history exists (sub-agent sessions from SessionManager), `convertRawMessages()` transforms it into the display `messages` array on the fly. `projectId` is required: the row names the session's directory (404 without it, or with no row / no UI log). Opening a 1.x session here migrates it; the response then carries the back-filled row.
|
|
757
757
|
|
|
758
758
|
```json
|
|
759
|
-
// 200
|
|
759
|
+
// 200
|
|
760
760
|
{
|
|
761
|
-
"version": 1,
|
|
762
761
|
"id": "sid_abc",
|
|
763
762
|
"agentId": "default",
|
|
764
763
|
"agentName": "Default",
|
|
765
764
|
"title": "...",
|
|
766
|
-
"source": "explorer",
|
|
765
|
+
"source": "explorer", // "delegated" for a sub-session
|
|
766
|
+
"parentSessionId": "sid_root", // sub-sessions only
|
|
767
767
|
"createdAt": "...", "updatedAt": "...",
|
|
768
768
|
"messageCount": 12,
|
|
769
|
+
"exchangeCount": 30,
|
|
769
770
|
"contextTokens": 5975,
|
|
770
771
|
"totalOutputTokens": 6058,
|
|
771
772
|
"archiveCount": 2,
|
|
772
773
|
"messages": [ /* SessionMessage[] — see design/storage.md */ ],
|
|
773
|
-
"rawMessages": [ /*
|
|
774
|
+
"rawMessages": [ /* model history */ ],
|
|
775
|
+
"output": "...", // when set
|
|
776
|
+
"lastActivityAt": "..." // when set
|
|
774
777
|
}
|
|
775
778
|
```
|
|
776
779
|
|
|
777
|
-
`messageCount`
|
|
780
|
+
`messageCount` is the active UI log's length (shrinks on archive); `exchangeCount` is the row's lifetime count, as in the list endpoint. `archiveCount` = committed segments on disk. The 1.x `version` field is gone.
|
|
778
781
|
|
|
779
782
|
### GET `/api/sessions/logs/:id/archive/:n?projectId=<abs>`
|
|
780
783
|
|
|
@@ -796,9 +799,9 @@ Failure modes, in the order checked:
|
|
|
796
799
|
| 400 | `Invalid session id` | `id` fails `isSafeIdSegment` (path-traversal guard) |
|
|
797
800
|
| 400 | `Invalid segment number` | `n` not a positive integer |
|
|
798
801
|
| 404 | `Session not found` | no such session in this workspace |
|
|
799
|
-
| 404 | `Segment not found` | `n
|
|
802
|
+
| 404 | `Segment not found` | segment `n` isn't committed (crash leftover / nonexistent), or the file can't be read |
|
|
800
803
|
|
|
801
|
-
The
|
|
804
|
+
The commit check is the point, not a formality: the archive is committed by the active log's rewrite, so a segment whose first id is still the active log's first row exists only because a crash interrupted the two-step write and must never be served. See [design/session.md](../design/session.md#ui-log-archiving).
|
|
802
805
|
|
|
803
806
|
### GET `/api/settings/schema?projectId=<abs>`
|
|
804
807
|
|
|
@@ -304,6 +304,8 @@ This installs the `halo` binary on `$PATH`. Subcommands available:
|
|
|
304
304
|
1. `halo upgrade` — bumps the on-disk npm package
|
|
305
305
|
2. `halo server restart` (under a user or system unit alike; `sudo systemctl restart halo` also works for a system unit) — server's startup check sees `~/.halo/global/.template-version` is behind the new bundled `TEMPLATE_VERSION`, runs `ensureHaloHome` automatically, then starts (`halo cli` / `halo tui` run the same check on start, so a CLI-only install refreshes without a server). Refreshes `docs/`, built-in agents, built-in skills, system prompts, and the model registry. User-owned files (USER.md, custom agents/skills, INSTRUCTIONS.md overrides) are left alone. See `init.ts` for the per-category overwrite policy.
|
|
306
306
|
|
|
307
|
+
**1.x → 2.0** (new session store): stop the server → `halo sessions migrate v1 v2 -w <ws>` once per workspace → start it — see [guide/cli.md → Session store migration](../guide/cli.md#session-store-migration-halo-sessions-migrate-v1-v2). On prod, 30 workspaces / 1171 sessions migrated in ~93 s, ~1.6 min of downtime in total. First run `find <ws>/.halo -not -user <service user>`: a root-owned file there fails the migration with `EACCES` (agentcore-workspace did in the 2.0 prod upgrade).
|
|
308
|
+
|
|
307
309
|
### Public release command reference
|
|
308
310
|
|
|
309
311
|
Use only for a new public release; the retained details here are not extra preparation for installing an existing version.
|
package/bundled-docs/dev/env.md
CHANGED
|
@@ -68,7 +68,7 @@ Your deployment scripts should also prepend this line.
|
|
|
68
68
|
## Runtime directories
|
|
69
69
|
|
|
70
70
|
- SQLite: `<workspace>/.halo/halo.db` (per-workspace, auto-created on first use) + global queues `~/.halo/global/evo.db`, `~/.halo/global/cron.db`, `~/.halo/global/runs.db` and `~/.halo/global/a2a.db` + channel accounts `~/.halo/secrets/channels/channels.db`
|
|
71
|
-
- Session files: `.halo/sessions/{agentId}/{
|
|
71
|
+
- Session files: `.halo/sessions/{agentId}/{seg}.ui.jsonl` + `{seg}.raw.jsonl` (+ archive segments)
|
|
72
72
|
- Global config: `~/.halo/global/`
|
|
73
73
|
- Per-project config: `<workspace>/.halo/`
|
|
74
74
|
|
|
@@ -80,6 +80,8 @@ Run a shell command. Full shell access.
|
|
|
80
80
|
|
|
81
81
|
Timeout 120 s (`HALO_SHELL_TIMEOUT`). Max output 5 MB. The tool description surfaces the effective timeout to the agent and advises backgrounding (`nohup … &` + log polling) for longer tasks.
|
|
82
82
|
|
|
83
|
+
**Server shutdown** (server only — the CLI / TUI never enter shutdown). A command killed by a signal halo didn't send while the server is shutting down (a systemd stop signals the whole cgroup, the command with it) returns `[Command cut off by a server restart — it was still running when the server shut down, so it may or may not have taken effect. Check before re-running it.]` followed by `Output so far:` and the partial stdout (`STDERR:` appended when there is any), instead of `Command failed: …`. A command issued after shutdown began is not run: `Error: command not run — the server is shutting down. Safe to re-issue after the restart if still needed.` Normal exits read as before. See [session.md → Shutdown](../design/session.md#boot-reconcile-of-crash-orphans-reconcileorphansonboot).
|
|
84
|
+
|
|
83
85
|
**Windows output encoding.** The Windows path (`sandbox.ts`) prepends `chcp 65001` so cmd built-ins (`echo`, …) emit UTF-8, then captures raw bytes (`encoding: 'buffer'`) and decodes them strict-UTF-8 with a GBK fallback. This is because native Win32 console tools (`ipconfig`, `systeminfo`, …) ignore `chcp` and still emit the OEM code page (GBK/CP936 on zh-CN); decoding such bytes as UTF-8 produced mojibake. The strict-UTF-8 attempt passes genuine UTF-8 through untouched and only falls back to GBK when the bytes aren't valid UTF-8 (GBK double-byte sequences almost always aren't). mac/Linux are unaffected (UTF-8 throughout).
|
|
84
86
|
|
|
85
87
|
### grep
|
|
@@ -330,7 +332,7 @@ Implementation: a turn (one `runAgentTurn`, processing one inbound message) accu
|
|
|
330
332
|
- `session.output` — **all** assistant text of the turn (mid-task filler + wrap-up). This is what `get_session_output` returns and what is persisted to disk.
|
|
331
333
|
- `session.finalOutput` — **only** the wrap-up reply (text emitted when `stopReason !== 'tool_use'`, flagged by the agent-loop `final` event field). This feeds the auto-report to the parent (`tryReportToParent`), falling back to `session.output` when the turn ended without a closing message.
|
|
332
334
|
|
|
333
|
-
In-memory sessions return `session.output` / `session.lastActivityAt`; released sessions read the `output` / `
|
|
335
|
+
In-memory sessions return `session.output` / `session.lastActivityAt`; released sessions read the `output` / `last_activity_at` columns of the session's `agent_sessions` row (both written by `saveAgentState`; mid-turn writes are throttled to one per 5s, and the row is written exactly at release, so it lags only after a hard kill — by at most 5s). (Split introduced in 0.1.5; before that both reads shared one `session.output`.)
|
|
334
336
|
|
|
335
337
|
### query_agent
|
|
336
338
|
|
|
@@ -232,6 +232,22 @@ halo models list # id · copy in effect (bundled | hub) · re
|
|
|
232
232
|
|
|
233
233
|
Normally run for you by `/extension models`, which downloads the hub's newest `models-v*` release (https GitHub / Gitea / Forgejo / GitLab only) and passes the unpacked directory to `halo models install`. Per provider id the copy with the higher `revision` is in effect (tie → the `models.d` copy); an older one is skipped, an identical one reported up to date. Refused per provider: a `runtime:` this halo doesn't know (needs a newer halo), no `revision`, a `secrets[].default` that isn't empty or `<<ENV_NAME>>`. Endpoint changes against the copy in effect are always listed. Consent is needed for any URL the server would now send requests (and credentials) to that it didn't before: a new provider, a changed `defaultEndpoint` / `endpointPresets`, or a URL added to a model's `endpoints:` list that the provider doesn't already use anywhere. A model list that only loses URLs, or gains one already used elsewhere in the provider, is listed but needs no consent; a reorder alone is not a change. When consent is needed, **nothing is written** and the exit code is `3` — re-run with `--yes` to apply. Writes are atomic (tmp + rename); a running server reloads its registry from `models.d/` without a restart and pushes the change to open admin pages. Existing agents keep the model / endpoint in their own `agent.yaml`. Exit codes: `0` ok (refusals listed in the summary line), `1` error, `3` needs `--yes`. To undo, delete the file from `~/.halo/global/models.d/`.
|
|
234
234
|
|
|
235
|
+
## Session store migration (`halo sessions migrate v1 v2`)
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
halo server stop # or: systemctl --user stop halo
|
|
239
|
+
halo sessions migrate v1 v2 -w /path/to/ws # once per workspace root
|
|
240
|
+
halo server start
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
> **After upgrading to 2.0, run this soon — once per workspace, before heavy use.** A 1.x session that is first opened by the server is converted right there, synchronously: a large session holds up the whole server (every chat, channel and admin page) for the length of its conversion. The command does the same work up front, while nobody is waiting.
|
|
244
|
+
|
|
245
|
+
Halo 2.0 stores each session as append-only `.ui.jsonl` / `.raw.jsonl` logs instead of one `.json` file. A server converts a 1.x session the first time it is opened, so running this is not required for correctness: it converts every 1.x session of the workspace plus the global internal sessions (evolution / scoring) in one go, and finishes any conversion that was cut off before its old files were removed. Each session is read back and compared after converting; only then are its 1.x files deleted. Safe to run twice (the second run finds nothing to convert).
|
|
246
|
+
|
|
247
|
+
Output: one `failed <agent>/<session>: <reason>` line on stderr per failure, then `migrated N, cleaned N, skipped N, failed N` (`cleaned` = already converted, old files removed now; `skipped` = 2.0 only). Exit code `1` if anything failed — those sessions keep their 1.x files and stay readable (a server refuses to open such a session, naming this command, rather than showing it empty) — or if a server is running on the workspace. Every conversion, by this command or by any server / CLI process opening a session, takes a per-session lock (`<session>.migrating`), so a conversion running beside a live server (or a dev server the `runtime.lock` check can't see) is safe; the refusal while a server holds the workspace's `runtime.lock` stays as the conservative default — stop it first, as above. Any other argument after `migrate` is refused (`only v1 v2 is supported`).
|
|
248
|
+
|
|
249
|
+
Downgrading to 1.x after converting: converted sessions show up empty there (their old files are gone), as do sessions created on 2.0.
|
|
250
|
+
|
|
235
251
|
## Server Mode (background daemon)
|
|
236
252
|
|
|
237
253
|
`halo server start|stop|restart|status|logs` manages a local server from the
|
|
@@ -73,7 +73,7 @@ Refreshing the browser or a network blip:
|
|
|
73
73
|
|
|
74
74
|
Hover a session and click its trash icon (Sessions page or a chat tab), then confirm:
|
|
75
75
|
- Cascade delete in SQLite (parent + all descendants)
|
|
76
|
-
- `.halo/sessions/{agentId}
|
|
76
|
+
- The session's files under `.halo/sessions/{agentId}/` removed (`{sid}.ui.jsonl`, `.raw.jsonl`, archive segments)
|
|
77
77
|
- Sidebar entry removed
|
|
78
78
|
|
|
79
79
|
**Not recoverable** — be careful.
|
|
@@ -23,7 +23,7 @@ The Explorer's Chat panel is now ready with your agent pre-selected. Send a mess
|
|
|
23
23
|
|
|
24
24
|
- Input appears in the session
|
|
25
25
|
- The agent streams a reply with tool-call cards inline
|
|
26
|
-
-
|
|
26
|
+
- Session files are created at `<workspace>/.halo/sessions/<agentId>/<sid>.ui.jsonl` (+ `.raw.jsonl`)
|
|
27
27
|
- A SQLite row is added to `agent_sessions`
|
|
28
28
|
|
|
29
29
|
**This is a real session.** The agent has its full tool set (whatever is in `agent.yaml tools`), access to the workspace filesystem, and persisted history. Anything it writes to disk is actually written.
|
|
@@ -117,7 +117,7 @@ You might be looking at a turn that was already running when you saved — it fi
|
|
|
117
117
|
|
|
118
118
|
### Deleting a test session
|
|
119
119
|
|
|
120
|
-
Sessions → hover the session → trash icon. Or programmatically: `DELETE /api/sessions/logs/:id`. Deletion cascades to child sessions (sub-agents), their
|
|
120
|
+
Sessions → hover the session → trash icon. Or programmatically: `DELETE /api/sessions/logs/:id`. Deletion cascades to child sessions (sub-agents), their session files, and the SQLite rows. See [dev/api.md#session-logs](../dev/api.md#session-logs-unified).
|
|
121
121
|
|
|
122
122
|
### "Test button does something weird" — the session is orphaned
|
|
123
123
|
|
|
@@ -40,7 +40,7 @@ Actions on user-role turns: the blue sticky user bubble, plus the sub-agent-repo
|
|
|
40
40
|
- An expanded body is capped at 40vh and scrolls inside the bubble, because the bubbles are sticky.
|
|
41
41
|
- The compact-summary callout is collapsed by default and toggles from its own header, as before.
|
|
42
42
|
- **Copy** — copies the prompt text to the clipboard
|
|
43
|
-
- **Delete** (confirm dialog) — removes the whole exchange (the user turn + all responses up to the next user turn) with **two-layer semantics**: the LLM context (`rawMessages`) drops the turn physically — the model never sees it again, freeing context; the UI keeps the messages, rendered greyed-out with a "deleted" tag, as an audit trail. No undo; a deleted exchange loses its Delete button. Rejected with an `Error:` message in the chat while the agent is running or compacting. Root sessions only (sub-session logs don't offer Delete).
|
|
43
|
+
- **Delete** (confirm dialog) — removes the whole exchange (the user turn + all responses up to the next user turn) with **two-layer semantics**: the LLM context (`rawMessages`) drops the turn physically — the model never sees it again, freeing context; the UI keeps the messages, rendered greyed-out with a "deleted" tag, as an audit trail. No undo; a deleted exchange loses its Delete button. Rejected with an `Error:` message in the chat while the agent is running or compacting. Root sessions only (sub-session logs don't offer Delete). Each message is deleted on its own, also when several were sent while the agent was busy and it answered them together (a queued batch, a Stop fold): deleting an earlier one keeps the shared reply; deleting the last one removes the reply too, leaving the earlier ones unanswered for the next turn. A turn from before the last compaction summary only gets the UI marking — unless the model's context still holds its copy (the last turn or two before the summary), which then goes too. A compaction summary row is refused with a plain explanatory notice and nothing changes, as is a turn whose model-side copy can't be singled out (older sessions' merged turns, written before messages carried their own stamp), or a message the model never received (queued while busy and lost to a restart) — the UI never shows a turn as deleted that the model still sees. Known limit: for a message sent while the agent was busy, the greyed-out span in the UI (up to the next user message) and what leaves the model's context (that message and the reply to it) can differ at the edge. If a compact archived history while the panel was open, the action is refused with a plain explanatory notice (not an `Error:` bubble) — the panel's turn positions no longer match the active file; reopening the session re-anchors it and Delete works again. Design details in [design/session.md](../design/session.md#exchange-deletion-soft-ui--hard-raw), protocol in [design/ws.md](../design/ws.md).
|
|
44
44
|
|
|
45
45
|
### Archived history (scroll up to load)
|
|
46
46
|
Long sessions get their older exchanges moved out of the active log on compact, so opening a session stays fast no matter how long it has run. Loading older messages is **two-tiered** — a local, no-network tier first, then the network-backed archive:
|