Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ new version heading in the same commit.

## [Unreleased]

## [0.292.4] — 2026-08-03
### Changed
- **`GET /api/sessions` clips the `task` prompt IN THE QUERY** instead of `SELECT *`-ing the full text
and throwing it away. The list has always shipped `task` clipped to `LIST_CLIP` (240) — but the server
still pulled every session's *complete* prompt out of SQLite first (**up to 53 KB/row on instawp; 2.1 MB
materialised per poll**) only for `server.ts` to clip it. `listSessions`/`listArchivedSessions` now take
an optional `taskClip`; when set (the list endpoint only) the SELECT projects `substr(task,1,241) AS task`
via a schema-derived column list, so SQLite stops materialising the overflow text. Measured on a live
instawp snapshot (950 rows): task bytes **2.10 MB → 201 KB**, the raw query **5.23 → 3.53 ms (−33%)**, and
full `listSessions(owner)` **13.3 → 11.1 ms (−17%)** per poll, plus ~1.9 MB less string allocation each
1.5 s tick. Byte-identical output — the existing `clipText` still runs as the ellipsis-preserving finisher
on the ≤241-char string (verified across all 950 rows, 746 of them >240 chars). Internal callers that read
the whole prompt (`sessionsForAgent`, the Cockpit context) pass no clip and keep the full `SELECT *`.
`src/terminal.ts`, `src/server.ts`. Follow-on to #530/#532/#533; the structural fix (pagination) is still open.

## [0.292.3] — 2026-08-03
### Fixed
- **Cockpit `ask`: "which agent can help me build a feature?" dumped the whole roster instead of
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-os",
"version": "0.292.3",
"version": "0.292.4",
"description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.",
"license": "MIT",
"type": "commonjs",
Expand Down
6 changes: 5 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2513,8 +2513,12 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req:
// a view that never renders it in full. The console uses `task` in exactly two places: the client-side
// search haystack, and a `line-clamp-2` fallback caption when `title` is empty. `LIST_CLIP` chars
// serve both. Anything needing the whole prompt reads the session detail, not the list.
// The clip now happens IN THE QUERY (`listSessions(me, LIST_CLIP)` → `substr(task,1,241)`), so SQLite
// stops materialising the full prompt — up to ~53 KB/row — only for us to throw it away: 2.1 MB → 0.2 MB
// per poll, ~33% off the query. `clipText` below is kept as the ellipsis-preserving finisher (it now
// operates on the ≤241-char string, so the wire output is byte-identical to before).
if (method === 'GET' && p === '/api/sessions') {
const rows = url.searchParams.get('archived') === '1' ? tm.listArchivedSessions(me) : tm.listSessions(me);
const rows = url.searchParams.get('archived') === '1' ? tm.listArchivedSessions(me, LIST_CLIP) : tm.listSessions(me, LIST_CLIP);
return sendJson(res, 200, rows.map((s) => (s.task ? { ...s, task: clipText(s.task, LIST_CLIP) } : s)));
}
if (method === 'POST' && p === '/api/sessions') {
Expand Down
29 changes: 23 additions & 6 deletions src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,19 +684,36 @@ export class TerminalManager {
}
}

/** term_sessions column names, read once (PRAGMA at boot-stable schema) for the clipped projection. */
private termCols?: string[];
/**
* SELECT column list for a sessions query. `undefined` clip → `*` (verbatim, full `task`, for callers
* that read the whole prompt — e.g. `sessionsForAgent`). A numeric clip → every column verbatim EXCEPT
* `task`, which becomes `substr(task,1,clip+1)` so the LIST path stops materialising the full prompt
* (up to ~53 KB/row on instawp; 2.1 MB → 0.2 MB per poll, ~33% off the query) out of SQLite just for
* `server.ts` to clip it to 240. The `+1` lets the downstream `clipText()` still detect truncation and
* keep the ellipsis, so the wire output is byte-identical.
*/
private sessionSelectCols(taskClip?: number): string {
if (!taskClip) return '*';
const cols = (this.termCols ??= this.db.prepare('PRAGMA table_info(term_sessions)').all<{ name: string }>().map((c) => c.name));
const n = Math.max(1, Math.floor(taskClip)) + 1; // integer, in-code constant — safe to inline
return cols.map((c) => (c === 'task' ? `substr(task,1,${n}) AS task` : `"${c}"`)).join(', ');
}
/**
* Sessions visible to `viewer`. owner/admin (or an omitted viewer — internal callers) see all; a
* regular member sees only sessions they spawned, plus sessions fired by an automation they created.
* `taskClip` (list endpoint only) fetches `task` pre-truncated to that many chars — see sessionSelectCols.
*/
listSessions(viewer?: Member): Session[] {
listSessions(viewer?: Member, taskClip?: number): Session[] {
// Memoize the member/automation lookups for this call — the per-row helpers below would otherwise
// re-query them ~2x per row. See withRowCache().
return this.withRowCache(() => this.listSessionsUncached(viewer));
return this.withRowCache(() => this.listSessionsUncached(viewer, taskClip));
}
private listSessionsUncached(viewer?: Member): Session[] {
private listSessionsUncached(viewer?: Member, taskClip?: number): Session[] {
// Archived sessions are hidden from the list (reversible soft-archive via the Insights declutter tile);
// their rows survive for every by-id reference (task-reconcile, audit, cost).
const rows = this.db.prepare('SELECT * FROM term_sessions WHERE archived_at IS NULL ORDER BY created_at DESC').all<SessionRow>();
const rows = this.db.prepare(`SELECT ${this.sessionSelectCols(taskClip)} FROM term_sessions WHERE archived_at IS NULL ORDER BY created_at DESC`).all<SessionRow>();
// Lazy liveness: a row stays 'running' until its tmux session is gone. A running row whose pane
// vanished with NO end signal (no `report`/`markEnded`/`stopSession`) died abruptly — kill/OOM/
// reboot — so it's a `crashed`, not a clean end. Grace-period new rows (tmux may not have finished
Expand Down Expand Up @@ -741,9 +758,9 @@ export class TerminalManager {

/** The soft-archived sessions (hidden from `listSessions`) — for the "show archived / restore" view.
* Same viewer-visibility rule as the live list; no liveness/cost work (they're terminal + settled). */
listArchivedSessions(viewer?: Member): Session[] {
listArchivedSessions(viewer?: Member, taskClip?: number): Session[] {
return this.withRowCache(() => {
const rows = this.db.prepare('SELECT * FROM term_sessions WHERE archived_at IS NOT NULL ORDER BY archived_at DESC').all<SessionRow>();
const rows = this.db.prepare(`SELECT ${this.sessionSelectCols(taskClip)} FROM term_sessions WHERE archived_at IS NOT NULL ORDER BY archived_at DESC`).all<SessionRow>();
const visible = viewer ? rows.filter((r) => this.canViewRow(r.spawned_by, r.run_as, viewer)) : rows;
return visible.map((r) => ({ ...toSession(r), spawnedByLabel: this.spawnedByLabel(r.spawned_by, r.run_as), sourceKind: this.sourceKind(r.spawned_by), runAsLabel: this.runAsLabel(r.run_as), ratedByLabel: this.runAsLabel(r.rated_by) }));
});
Expand Down
Loading