@saadnvd1/agent-os 0.3.510 → 0.3.513

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.
@@ -13,13 +13,7 @@ export async function GET() {
13
13
  // Merged and dropped tasks live in the Tasks panel (a moved one lives on
14
14
  // the machine it moved to), archived sessions
15
15
  // in the Archived view: neither is in the sidebar.
16
- const sessions = (queries.getAllSessions(db).all() as Session[]).filter(
17
- (s) =>
18
- !s.archived_at &&
19
- s.task_status !== "merged" &&
20
- s.task_status !== "dropped" &&
21
- s.task_status !== "moved"
22
- );
16
+ const sessions = queries.getSidebarSessions(db).all() as Session[];
23
17
  const groups = queries.getAllGroups(db).all() as Group[];
24
18
 
25
19
  // Convert expanded from 0/1 to boolean
@@ -305,7 +305,7 @@ export const Pane = memo(function Pane({
305
305
  sessionName={chat.name}
306
306
  accessLocked={chat.role === "orchestrator"}
307
307
  freshStart={
308
- (chat.role === "orchestrator" || !!chat.task_brief) &&
308
+ (chat.role === "orchestrator" || !!chat.has_brief) &&
309
309
  !chat.archived_at
310
310
  }
311
311
  orchestratorOf={
@@ -175,7 +175,7 @@ export function SessionRowMenu({
175
175
  {summarizing ? "Summarizing..." : "Fresh start"}
176
176
  </Item>
177
177
  )}
178
- {(session.role === "orchestrator" || session.task_brief) &&
178
+ {(session.role === "orchestrator" || session.has_brief) &&
179
179
  session.view === "chat" &&
180
180
  !session.archived_at &&
181
181
  !blocked && (
@@ -116,6 +116,33 @@ function chatTurn(sessionId: string): number {
116
116
  );
117
117
  }
118
118
 
119
+ // How recently Saad must have messaged an orchestrator for turning Away on
120
+ // to tell it.
121
+ export const SAAD_MESSAGE_WINDOW_MS = 24 * 60 * 60_000;
122
+
123
+ // When Saad last typed to a chat himself: a user item with no origin, so
124
+ // not an event, a peer, a schedule or AgentOS, or his own decision made
125
+ // outside the composer. 0 when he never has.
126
+ function saadMessageAt(sessionId: string): number {
127
+ const row = db
128
+ .prepare(
129
+ `SELECT json_extract(data, '$.createdAt') AS at FROM chat_items
130
+ WHERE session_id = ? AND kind = 'user'
131
+ AND (json_extract(data, '$.origin') IS NULL
132
+ OR json_extract(data, '$.origin.kind') = 'decision')
133
+ ORDER BY seq DESC LIMIT 1`
134
+ )
135
+ .get(sessionId) as { at: number | null } | undefined;
136
+ return row?.at ?? 0;
137
+ }
138
+
139
+ // Whether Saad has been talking to this session lately. A terminal keeps
140
+ // no record of who typed, so its last turn stands in.
141
+ export function saadMessagedRecently(s: Session, now = Date.now()): boolean {
142
+ const at = s.view === "chat" ? saadMessageAt(s.id) : terminalTurn(s);
143
+ return at > 0 && now - at <= SAAD_MESSAGE_WINDOW_MS;
144
+ }
145
+
119
146
  // A terminal's Claude conversation file is written on every turn and on
120
147
  // nothing else; other agents fall back to tmux's output clock, which a
121
148
  // status poll (capture-pane) doesn't move.
package/lib/away/brief.ts CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  activeAway,
8
8
  clockTime,
9
9
  fromSql,
10
- reachedWorkspaces,
11
10
  untilText,
12
11
  type AwayRow,
13
12
  } from "./state";
@@ -18,14 +17,9 @@ export function awayOnLine(row: AwayRow, now = Date.now()): string {
18
17
  return `Saad is away (Away mode, since ${clockTime(fromSql(row.started_at), now)}, ${untilText(row, now)}). Follow work through rather than only reacting to events: keep each task moving to merged and deployed, sign off what passes its gates, and pick up what stalled. Every ${SWEEP_EVERY_MS / 60000} minutes AgentOS sweeps this workspace without messaging any session: it runs lost reviews again, checks the deploy after each merge, and sends you one digest of what's new (stalled tasks, idle ones with uncommitted work and no PR, Saad's own sessions idle with uncommitted work). You decide what needs work and act with your tools. Only sessions with real activity in the last ${awayWindowMinutes()} minutes are listed: leave the rest alone, since a message wakes an agent and re-reads its whole context. Interactive sessions (not tasks) are Saad's: ask a listed one at most once to wrap up, never drive them further. Hard lines still go to ask_saad; while he's away his asks reach his phone in one batch per sweep, so raise them as usual. \`aos notify\` is held for that batch too, so don't send one per item.`;
19
18
  }
20
19
 
21
- // For the digest: the same line while Away is on and has reached this
22
- // workspace, else nothing.
23
- export function awayDigestLine(
24
- workspaceId: string,
25
- now = Date.now()
26
- ): string | null {
20
+ // For the digest: the same line whenever Away is on, messaged or not, so an
21
+ // orchestrator skipped at the toggle still knows when it next starts.
22
+ export function awayDigestLine(now = Date.now()): string | null {
27
23
  const row = activeAway();
28
- return row && reachedWorkspaces(row.id).has(workspaceId)
29
- ? awayOnLine(row, now)
30
- : null;
24
+ return row ? awayOnLine(row, now) : null;
31
25
  }
package/lib/away/run.ts CHANGED
@@ -45,7 +45,12 @@ import {
45
45
  SWEEP_EVERY_MS,
46
46
  type Candidate,
47
47
  } from "./sweep";
48
- import { agentLive, awayWindowMs, lastTurnAt } from "./activity";
48
+ import {
49
+ agentLive,
50
+ awayWindowMs,
51
+ lastTurnAt,
52
+ saadMessagedRecently,
53
+ } from "./activity";
49
54
  import { checkDeploy } from "./deploy";
50
55
  import { buildAwayReport, phoneSummary, reportMarkdown } from "./report";
51
56
  import { awayOnLine } from "./brief";
@@ -92,6 +97,14 @@ function tellAway(
92
97
  // keep an orchestrator going that nothing else has.
93
98
  if (!orchestratorActive(orchestratorId, now)) return false;
94
99
  const told = reachedWorkspaces(away.id).has(workspaceId);
100
+ // A first message goes only to one Saad has messaged lately: waking the
101
+ // rest re-reads their whole context for nothing, and they read the away
102
+ // state in their brief when they next start. One already told keeps its
103
+ // sweeps for the rest of the period.
104
+ if (!told) {
105
+ const s = sessionRow(orchestratorId);
106
+ if (!s || !saadMessagedRecently(s, now)) return false;
107
+ }
95
108
  // One transaction: a crash can't leave the marker set with the event
96
109
  // never queued.
97
110
  db.transaction(() => {
@@ -133,8 +146,9 @@ export function turnAwayOn(
133
146
  ): AwayRow {
134
147
  const { row, started } = startAway(input, now);
135
148
  // Sent again when the end moves, so the orchestrator has the new one.
136
- for (const { workspaceId, orchestratorId } of orchestrated())
149
+ for (const { workspaceId, orchestratorId } of orchestrated()) {
137
150
  tellAway(row, workspaceId, orchestratorId, now, !started);
151
+ }
138
152
  return row;
139
153
  }
140
154
 
@@ -3,7 +3,7 @@ import { formatTokens } from "./context";
3
3
  import { formatElapsed } from "./elapsed";
4
4
  import { registry, type Live } from "./registry";
5
5
  import { stallThresholdMs } from "./stall";
6
- import { listItems } from "./store";
6
+ import { lastItems, runningItems } from "./store";
7
7
  import type { WorkerEvent } from "./worker/protocol";
8
8
 
9
9
  const lastWhat = (i: ChatItem): string =>
@@ -55,23 +55,17 @@ export function track(live: Live, e: WorkerEvent): void {
55
55
 
56
56
  // After a reattach, what's still running comes from the saved items.
57
57
  export function restoreActivity(sessionId: string, state: ChatState) {
58
- const items = listItems(sessionId);
59
58
  const activity: Live["activity"] = { tools: new Map(), tasks: new Set() };
60
- if (state !== "idle")
61
- activity.turnStartedAt = items.findLast(
62
- (i) => i.kind === "user"
63
- )?.createdAt;
64
59
  if (state !== "idle") {
65
- const last = items.at(-1);
60
+ activity.turnStartedAt = lastItems(sessionId, 1, "user")[0]?.createdAt;
61
+ const last = lastItems(sessionId, 1)[0];
66
62
  activity.lastAt = last?.createdAt ?? activity.turnStartedAt;
67
63
  activity.lastWhat = last ? lastWhat(last) : undefined;
68
- }
69
- for (const i of items) {
70
- if (i.kind === "tool" && i.status === "running" && state !== "idle")
64
+ for (const i of runningItems(sessionId, "tool"))
71
65
  activity.tools.set(i.id, { label: i.title, since: i.createdAt });
72
- if (i.kind === "task" && i.status === "running" && !i.ambient)
73
- activity.tasks.add(i.taskId);
74
66
  }
67
+ for (const i of runningItems(sessionId, "task"))
68
+ if (!i.ambient) activity.tasks.add(i.taskId);
75
69
  return activity;
76
70
  }
77
71
 
@@ -311,7 +311,7 @@ export interface ToolBody {
311
311
  export type ChatServerMessage =
312
312
  | {
313
313
  type: "snapshot";
314
- // Every item, or the latest page for a client that asked (?paged=1).
314
+ // The latest page; older ones come with `history`.
315
315
  items: ChatItem[];
316
316
  // Where older items start, and whether there are any.
317
317
  cursor: number | null;
@@ -37,7 +37,7 @@ import {
37
37
  getItem,
38
38
  hasItem,
39
39
  itemsOfKind,
40
- listItems,
40
+ recentTasks,
41
41
  saveItem,
42
42
  settle,
43
43
  } from "./store";
@@ -850,26 +850,16 @@ function present(sessionId: string, items: ChatItem[]): ChatItem[] {
850
850
  return runningWorkers().includes(sessionId) ? items : settle(items);
851
851
  }
852
852
 
853
- // Snapshot then live updates. Returns the unsubscribe function. `paged`
854
- // clients get the latest page and ask for the rest; others get everything.
855
- export function watchChat(
856
- sessionId: string,
857
- listener: Listener,
858
- paged = false
859
- ): () => void {
853
+ // Snapshot then live updates. Returns the unsubscribe function. Every
854
+ // client gets the latest page and asks for older ones.
855
+ export function watchChat(sessionId: string, listener: Listener): () => void {
860
856
  const live = registry.live.get(sessionId);
861
- const page = paged
862
- ? readPage(sessionId)
863
- : { items: listItems(sessionId), cursor: null, hasMore: false };
857
+ const page = readPage(sessionId);
864
858
  const onPage = new Set(page.items.map((i) => i.id));
865
859
  // The composer lists background work still going or just done, even when
866
860
  // it started before the page.
867
861
  const recent = Date.now() - 60 * 60 * 1000;
868
- const tasks = itemsOfKind(sessionId, "task").filter(
869
- (t) =>
870
- !onPage.has(t.id) &&
871
- (t.status === "running" || (t.endedAt ?? t.createdAt) > recent)
872
- );
862
+ const tasks = recentTasks(sessionId, recent).filter((t) => !onPage.has(t.id));
873
863
  listener({
874
864
  type: "snapshot",
875
865
  items: present(sessionId, page.items),
@@ -102,17 +102,16 @@ export function serveChatSocket(
102
102
  // A linked machine's session: its own AgentOS serves the chat.
103
103
  const link = demo ? null : peerChatLink(sessionId);
104
104
  if (link) {
105
- const paged = params.get("paged");
106
105
  return relayChatSocket(
107
106
  ws,
108
107
  link,
109
- { session: sessionId, ...(paged && { paged }) },
108
+ { session: sessionId, paged: "1" },
110
109
  send,
111
110
  (message) => reply(errorItem(message))
112
111
  );
113
112
  }
114
113
  try {
115
- unwatch = watchChat(sessionId, reply, params.get("paged") === "1");
114
+ unwatch = watchChat(sessionId, reply);
116
115
  } catch (err) {
117
116
  fail(err);
118
117
  if (!closed) ws.close();
package/lib/chat/store.ts CHANGED
@@ -176,22 +176,49 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
176
176
  ).map((r) => JSON.parse(r.data));
177
177
  }
178
178
 
179
- // Tool calls saved as still running.
180
- export function runningTools(
181
- sessionId: string
182
- ): Extract<ChatItem, { kind: "tool" }>[] {
179
+ // A chat's background tasks still running, or ended after `since` (ms): what
180
+ // the composer lists, read without parsing the rest of a long conversation.
181
+ export function recentTasks(
182
+ sessionId: string,
183
+ since: number
184
+ ): Extract<ChatItem, { kind: "task" }>[] {
185
+ flushItems();
186
+ return (
187
+ db
188
+ .prepare(
189
+ `SELECT data FROM chat_items WHERE session_id = ? AND kind = 'task'
190
+ AND (json_extract(data, '$.status') = 'running'
191
+ OR COALESCE(json_extract(data, '$.endedAt'),
192
+ json_extract(data, '$.createdAt')) > ?)
193
+ ORDER BY seq DESC LIMIT 200`
194
+ )
195
+ .all(sessionId, since) as { data: string }[]
196
+ )
197
+ .reverse()
198
+ .map((r) => JSON.parse(r.data));
199
+ }
200
+
201
+ // Items of a kind saved as still running, read from the running index.
202
+ export function runningItems<K extends "tool" | "task">(
203
+ sessionId: string,
204
+ kind: K
205
+ ): Extract<ChatItem, { kind: K }>[] {
183
206
  flushItems();
184
207
  return (
185
208
  db
186
209
  .prepare(
187
210
  `SELECT data FROM chat_items WHERE session_id = ?
188
- AND kind = 'tool'
189
- AND json_extract(data, '$.status') = 'running'`
211
+ AND kind = ? AND json_extract(data, '$.status') = 'running'
212
+ ORDER BY seq`
190
213
  )
191
- .all(sessionId) as { data: string }[]
214
+ .all(sessionId, kind) as { data: string }[]
192
215
  ).map((r) => JSON.parse(r.data));
193
216
  }
194
217
 
218
+ // Tool calls saved as still running.
219
+ export const runningTools = (sessionId: string) =>
220
+ runningItems(sessionId, "tool");
221
+
195
222
  export function deleteItems(sessionId: string): void {
196
223
  pending.delete(sessionId);
197
224
  seqs.delete(sessionId);
package/lib/db/index.ts CHANGED
@@ -6,7 +6,7 @@ import { runMigrations } from "./migrations";
6
6
 
7
7
  // Re-export types and queries
8
8
  export * from "./types";
9
- export { queries } from "./queries";
9
+ export { queries, sessionListColumns } from "./queries";
10
10
  export * from "./lumifyhub";
11
11
  export * from "./lumifyhub-docs";
12
12
  export * from "./stacks";
@@ -1617,6 +1617,19 @@ const migrations: Migration[] = [
1617
1617
  `);
1618
1618
  },
1619
1619
  },
1620
+ {
1621
+ id: 70,
1622
+ name: "chat_items_running",
1623
+ up: (db) => {
1624
+ // What is still running (tools, background tasks) is read on every
1625
+ // attach; the index holds only those rows, so it's a lookup, not a
1626
+ // scan that parses every tool output of a long conversation.
1627
+ db.exec(
1628
+ `CREATE INDEX IF NOT EXISTS idx_chat_items_running ON chat_items(session_id, kind, seq)
1629
+ WHERE json_extract(data, '$.status') = 'running'`
1630
+ );
1631
+ },
1632
+ },
1620
1633
  ];
1621
1634
 
1622
1635
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/queries.ts CHANGED
@@ -13,6 +13,28 @@ function getStmt(db: Database.Database, sql: string): Database.Statement {
13
13
  return stmt;
14
14
  }
15
15
 
16
+ // Every sessions column but the two large text ones, which only the single
17
+ // session paths read; a list gets has_brief for whether a brief exists.
18
+ const listColumns = new WeakMap<Database.Database, string>();
19
+ export function sessionListColumns(db: Database.Database, alias = ""): string {
20
+ let cols = listColumns.get(db);
21
+ if (!cols) {
22
+ cols = (
23
+ db.prepare(`PRAGMA table_info(sessions)`).all() as { name: string }[]
24
+ )
25
+ .map((c) => c.name)
26
+ .filter((n) => n !== "task_brief" && n !== "system_prompt")
27
+ .join(",");
28
+ listColumns.set(db, cols);
29
+ }
30
+ const prefix = alias ? `${alias}.` : "";
31
+ const list = cols
32
+ .split(",")
33
+ .map((c) => `${prefix}${c}`)
34
+ .join(", ");
35
+ return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief`;
36
+ }
37
+
16
38
  export const queries = {
17
39
  // Sessions
18
40
  createSession: (db: Database.Database) =>
@@ -26,7 +48,22 @@ export const queries = {
26
48
  getStmt(db, `SELECT * FROM sessions WHERE id = ?`),
27
49
 
28
50
  getAllSessions: (db: Database.Database) =>
29
- getStmt(db, `SELECT * FROM sessions ORDER BY updated_at DESC`),
51
+ getStmt(
52
+ db,
53
+ `SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
54
+ ),
55
+
56
+ // The sidebar's sessions: not archived, and not a task that is over or
57
+ // lives on another machine.
58
+ getSidebarSessions: (db: Database.Database) =>
59
+ getStmt(
60
+ db,
61
+ `SELECT ${sessionListColumns(db)} FROM sessions
62
+ WHERE archived_at IS NULL
63
+ AND (task_status IS NULL
64
+ OR task_status NOT IN ('merged', 'dropped', 'moved'))
65
+ ORDER BY updated_at DESC`
66
+ ),
30
67
 
31
68
  // The tmux names AgentOS owns, for telling discovered sessions apart.
32
69
  getManagedTmuxNames: (db: Database.Database) =>
package/lib/db/types.ts CHANGED
@@ -51,6 +51,8 @@ export interface Session {
51
51
  setup_ms?: number | null;
52
52
  setup_error?: string | null;
53
53
  task_brief?: string | null;
54
+ // Lists carry this instead of the brief itself (0/1).
55
+ has_brief?: number;
54
56
  // How the session is shown: a chat conversation or a terminal.
55
57
  view: "chat" | "terminal";
56
58
  chat_access: ChatAccess;
@@ -44,7 +44,8 @@ export function unarchiveSession(id: string): Session {
44
44
  export function listArchived(workspaceId?: string): ArchivedView[] {
45
45
  const rows = db
46
46
  .prepare(
47
- `SELECT s.*, p.name AS project_name, p.workspace_id AS project_workspace
47
+ `SELECT s.id, s.name, s.project_id, s.view, s.task_status, s.branch_name,
48
+ s.pr_url, s.worktree_path, s.archived_at, p.name AS project_name, p.workspace_id AS project_workspace
48
49
  FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
49
50
  WHERE s.archived_at IS NOT NULL
50
51
  AND (? IS NULL OR p.workspace_id = ?)
package/lib/needs-you.ts CHANGED
@@ -56,7 +56,7 @@ function pendingApproval(
56
56
  const row = getDb()
57
57
  .prepare(
58
58
  `SELECT data FROM chat_items WHERE session_id = ?
59
- AND json_extract(data, '$.kind') = 'approval'
59
+ AND kind = 'approval'
60
60
  AND json_extract(data, '$.status') = 'pending'
61
61
  ORDER BY seq DESC LIMIT 1`
62
62
  )
@@ -324,7 +324,7 @@ export function buildDigest(
324
324
  ): string {
325
325
  const checks = checksBySession(workspaceId);
326
326
  const failures = failuresBySession(workspaceId);
327
- const away = awayDigestLine(workspaceId);
327
+ const away = awayDigestLine();
328
328
  const head = `## Where things stand (read from AgentOS's database as you started)${away ? `\n\n${away}` : ""}`;
329
329
  const body = renderDigest(
330
330
  [
@@ -410,7 +410,7 @@ export function movedTasks(): { id: string; movedTo: string | null }[] {
410
410
 
411
411
  // Archived tasks aren't listed.
412
412
  export async function listTasks(): Promise<TaskView[]> {
413
- const sessions = taskSessions().filter((s) => !s.archived_at);
413
+ const sessions = taskSessions(false);
414
414
  const [local, remote] = await Promise.all([
415
415
  Promise.all(sessions.filter((s) => !isMirror(s)).map(taskView)),
416
416
  remoteTaskViews(sessions.filter(isMirror)),
@@ -1,6 +1,12 @@
1
1
  import fs from "fs";
2
2
  import os from "os";
3
- import { db, queries, stackQueries, type Session } from "../db";
3
+ import {
4
+ db,
5
+ queries,
6
+ sessionListColumns,
7
+ stackQueries,
8
+ type Session,
9
+ } from "../db";
4
10
  import { getDefaultBranch, isBranchName } from "../git";
5
11
  import { parentBranchOf } from "../stacks/guard";
6
12
  import { voidApprovalsOn } from "../orchestrator/ask-approvals";
@@ -15,10 +21,14 @@ import type { TaskPR } from "./state";
15
21
 
16
22
  export const expandHome = (p: string) => p.replace(/^~/, os.homedir());
17
23
 
18
- export function taskSessions(): Session[] {
24
+ // Without the brief and system prompt: a list doesn't need them.
25
+ export function taskSessions(includeArchived = true): Session[] {
19
26
  return db
20
27
  .prepare(
21
- `SELECT * FROM sessions WHERE task_status IS NOT NULL AND task_status != 'moved' ORDER BY created_at DESC`
28
+ `SELECT ${sessionListColumns(db)} FROM sessions
29
+ WHERE task_status IS NOT NULL AND task_status != 'moved'
30
+ ${includeArchived ? "" : "AND archived_at IS NULL"}
31
+ ORDER BY created_at DESC`
22
32
  )
23
33
  .all() as Session[];
24
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.510",
3
+ "version": "0.3.513",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"