@saadnvd1/agent-os 0.3.673 → 0.3.680

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.
Files changed (52) hide show
  1. package/README.md +2 -1
  2. package/app/api/bus/read/route.ts +34 -0
  3. package/app/api/search/conversations/context/route.ts +26 -0
  4. package/app/api/search/conversations/route.test.ts +139 -0
  5. package/app/api/search/conversations/route.ts +25 -0
  6. package/app/globals.css +25 -0
  7. package/components/Bookmarks/BookmarksDialog.tsx +2 -8
  8. package/components/Chat/useGoToItem.ts +11 -0
  9. package/components/CommandPalette/CommandPalette.tsx +23 -156
  10. package/components/CommandPalette/CommandsBody.tsx +216 -0
  11. package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
  12. package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
  13. package/components/CommandPalette/conversations/Preview.tsx +129 -0
  14. package/components/CommandPalette/conversations/ResultList.tsx +91 -0
  15. package/components/CommandPalette/conversations/Snippet.tsx +24 -0
  16. package/components/CommandPalette/conversations/format.ts +30 -0
  17. package/components/CommandPalette/conversations/recent.test.ts +20 -0
  18. package/components/CommandPalette/conversations/recent.ts +35 -0
  19. package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
  20. package/data/search/index.ts +65 -0
  21. package/data/search/keys.ts +7 -0
  22. package/hooks/useDebouncedValue.ts +11 -0
  23. package/lib/agents/brief.ts +8 -0
  24. package/lib/bus/condense.ts +242 -0
  25. package/lib/bus/read-scope.ts +109 -0
  26. package/lib/bus/read.ts +181 -0
  27. package/lib/chat/worker/host.ts +3 -1
  28. package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
  29. package/lib/demo-reseed.ts +4 -0
  30. package/lib/health/facts.ts +13 -3
  31. package/lib/health/spin-tick.ts +293 -0
  32. package/lib/health/spin.ts +158 -0
  33. package/lib/health/tick.ts +10 -2
  34. package/lib/health/types.ts +2 -0
  35. package/lib/orchestrator/read.ts +18 -4
  36. package/lib/palette/registry.ts +4 -0
  37. package/lib/resurrect/index.ts +2 -3
  38. package/lib/search/conversations/backfill.ts +117 -0
  39. package/lib/search/conversations/buildMatchQuery.ts +31 -0
  40. package/lib/search/conversations/index.ts +9 -0
  41. package/lib/search/conversations/messageContext.ts +45 -0
  42. package/lib/search/conversations/schema.ts +71 -0
  43. package/lib/search/conversations/searchConversations.ts +151 -0
  44. package/lib/search/conversations/types.ts +45 -0
  45. package/lib/sidebar/row-actions.ts +19 -8
  46. package/lib/sidebar/row-menu.ts +1 -1
  47. package/lib/tasks/restorable.ts +16 -0
  48. package/package.json +1 -1
  49. package/scripts/bench-conversation-search.ts +73 -0
  50. package/server.ts +3 -0
  51. package/stores/chatJump.ts +10 -0
  52. package/stores/palette.ts +22 -4
@@ -0,0 +1,109 @@
1
+ import { db } from "../db";
2
+ import type { Session } from "../db";
3
+ import { getProject } from "../projects";
4
+ import { getWorkspace } from "../workspaces";
5
+ import { previousNames } from "../session-names";
6
+ import { resolveRef, type Candidate } from "./resolve";
7
+
8
+ export type ReadableSession = Session & { project_name: string | null };
9
+
10
+ export interface ReaderScope {
11
+ workspaceId: string | null;
12
+ projectId: string | null;
13
+ label: string;
14
+ }
15
+
16
+ // What the asking session may read: its workspace, or its project when it
17
+ // has none. The boundary the orchestrator's tools use.
18
+ export function readerScope(callerId: string): ReaderScope {
19
+ const caller = db
20
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
21
+ .get(callerId) as Session | undefined;
22
+ if (!caller) throw new Error("aos read runs from inside an AgentOS session");
23
+ const project = caller.project_id ? getProject(caller.project_id) : null;
24
+ const workspaceId =
25
+ caller.workspace_id ?? (project && project.workspace_id) ?? null;
26
+ if (workspaceId)
27
+ return {
28
+ workspaceId,
29
+ projectId: null,
30
+ label: `workspace ${getWorkspace(workspaceId)?.name ?? workspaceId}`,
31
+ };
32
+ if (!project || project.is_uncategorized)
33
+ throw new Error(`${caller.name} isn't in a project`);
34
+ return {
35
+ workspaceId: null,
36
+ projectId: project.id,
37
+ label: `project ${project.name}`,
38
+ };
39
+ }
40
+
41
+ const SELECT = `SELECT s.*, p.name AS project_name FROM session_rows s
42
+ LEFT JOIN projects p ON p.id = s.project_id
43
+ WHERE s.peer_mirror = 0 AND s.held_by_device IS NULL`;
44
+
45
+ const isLive = (s: Session) =>
46
+ !s.archived_at && (!s.task_status || s.task_status === "running");
47
+
48
+ function candidates(rows: ReadableSession[]): Candidate[] {
49
+ const old = previousNames();
50
+ return rows.map((s) => ({
51
+ id: s.id,
52
+ name: s.name,
53
+ projectName: s.project_name,
54
+ tmuxName: s.tmux_name,
55
+ previousNames: old.get(s.id) ?? [],
56
+ }));
57
+ }
58
+
59
+ function inScope(s: ReadableSession, scope: ReaderScope): boolean {
60
+ if (s.role) return false;
61
+ if (scope.workspaceId) {
62
+ const p = s.project_id ? getProject(s.project_id) : null;
63
+ return !!p && p.workspace_id === scope.workspaceId;
64
+ }
65
+ return s.project_id === scope.projectId;
66
+ }
67
+
68
+ export type Readable =
69
+ | { ok: true; session: ReadableSession; note?: string }
70
+ | { ok: false; external: boolean; error: string };
71
+
72
+ // A session in the caller's scope by name, old name, project/name, id or id
73
+ // prefix. Done, dropped and archived ones count, but a live session wins a
74
+ // name over a finished one.
75
+ export function resolveReadable(callerId: string, ref: string): Readable {
76
+ const scope = readerScope(callerId);
77
+ const all = (db.prepare(SELECT).all() as ReadableSession[]).sort((a, b) =>
78
+ a.updated_at < b.updated_at ? 1 : -1
79
+ );
80
+ const mine = all.filter((s) => inScope(s, scope));
81
+ let none = "";
82
+ for (const group of [mine.filter(isLive), mine.filter((s) => !isLive(s))]) {
83
+ const r = resolveRef(ref, candidates(group));
84
+ if (r.ok) {
85
+ const session = group.find((s) => s.id === r.id)!;
86
+ if (session.id === callerId)
87
+ return { ok: false, external: false, error: "that's this session" };
88
+ return { ok: true, session, note: r.note };
89
+ }
90
+ if (r.reason === "ambiguous")
91
+ return { ok: false, external: false, error: r.error };
92
+ none = r.error;
93
+ }
94
+ const other = resolveRef(
95
+ ref,
96
+ candidates(all.filter((s) => !mine.includes(s)))
97
+ );
98
+ if (other.ok) {
99
+ const s = all.find((x) => x.id === other.id)!;
100
+ return {
101
+ ok: false,
102
+ external: false,
103
+ error: s.role
104
+ ? `${s.name} is an orchestrator: it isn't readable. Message it with aos send`
105
+ : `"${ref}" isn't in your ${scope.label}; aos read reaches only sessions there`,
106
+ };
107
+ }
108
+ return { ok: false, external: true, error: `${none}. Run: aos peers` };
109
+ }
@@ -0,0 +1,181 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { db } from "../db";
4
+ import type { Session } from "../db";
5
+ import { lastItems, listItems } from "../chat/store";
6
+ import { untrusted } from "../orchestrator/untrusted";
7
+ import { readExternal, readResolved } from "../orchestrator/read";
8
+ import { findExternalSession } from "../orchestrator/external-sessions";
9
+ import { pieceOfSession, planPathOf } from "../plans/features";
10
+ import { hostLink } from "../hosts/remote-api";
11
+ import {
12
+ readerScope,
13
+ resolveReadable,
14
+ type ReadableSession,
15
+ } from "./read-scope";
16
+ import {
17
+ READ_DEFAULT_TURNS,
18
+ TERMINAL_LINES,
19
+ TERMINAL_LINES_ALL,
20
+ condenseItems,
21
+ grepTurns,
22
+ renderTurns,
23
+ type Rendered,
24
+ } from "./condense";
25
+
26
+ export interface ReadOpts {
27
+ turns?: number;
28
+ lines?: number;
29
+ all?: boolean;
30
+ grep?: string;
31
+ }
32
+
33
+ export interface ReadResult {
34
+ // Ours, not another session's text: who, what state, how much.
35
+ header: string;
36
+ // Another session's text, fenced and redacted.
37
+ text: string;
38
+ session: { id: string | null; name: string; project: string | null };
39
+ task: { prompt: string | null; plan: string | null } | null;
40
+ turns: { from: number; to: number; total: number } | null;
41
+ truncated: boolean;
42
+ }
43
+
44
+ const PLAN_FILE = ".agentos/plan.md";
45
+ const PROMPT_CAP = 1500;
46
+ // Items fetched per turn asked for, when the whole chat isn't needed.
47
+ const ITEMS_PER_TURN = 400;
48
+
49
+ export class ReadError extends Error {
50
+ constructor(
51
+ message: string,
52
+ readonly status: number
53
+ ) {
54
+ super(message);
55
+ }
56
+ }
57
+
58
+ function stateOf(s: Session): string {
59
+ if (s.archived_at) return "archived";
60
+ return s.task_status ?? "running";
61
+ }
62
+
63
+ function planOf(s: Session): string | null {
64
+ if (s.worktree_path) {
65
+ const own = path.join(s.worktree_path, PLAN_FILE);
66
+ if (fs.existsSync(own)) return own;
67
+ }
68
+ const piece = pieceOfSession(s.id);
69
+ const fromFeature = piece ? planPathOf(piece.feature_id) : null;
70
+ return fromFeature && fs.existsSync(fromFeature) ? fromFeature : null;
71
+ }
72
+
73
+ function taskHeader(s: Session, all: boolean) {
74
+ if (!s.task_prompt) return null;
75
+ const p = s.task_prompt.trim();
76
+ return {
77
+ prompt: !all && p.length > PROMPT_CAP ? `${p.slice(0, PROMPT_CAP)}…` : p,
78
+ plan: planOf(s),
79
+ };
80
+ }
81
+
82
+ function userCount(sessionId: string): number {
83
+ return (
84
+ db
85
+ .prepare(
86
+ `SELECT COUNT(*) AS n FROM chat_items WHERE session_id = ? AND kind = 'user'`
87
+ )
88
+ .get(sessionId) as { n: number }
89
+ ).n;
90
+ }
91
+
92
+ function chatTurns(s: Session, o: ReadOpts) {
93
+ const total = userCount(s.id);
94
+ if (o.all || o.grep) return { turns: condenseItems(listItems(s.id)), total };
95
+ const asked = o.lines
96
+ ? Math.ceil(o.lines / 4) + 1
97
+ : (o.turns ?? READ_DEFAULT_TURNS);
98
+ const items = lastItems(s.id, Math.min(asked * ITEMS_PER_TURN, 4000));
99
+ const inWindow = items.filter((i) => i.kind === "user").length;
100
+ return { turns: condenseItems(items, Math.max(total - inWindow, 0)), total };
101
+ }
102
+
103
+ // What a session in the caller's workspace said and did, condensed.
104
+ export async function readPeer(
105
+ callerId: string,
106
+ ref: string,
107
+ o: ReadOpts = {}
108
+ ): Promise<ReadResult> {
109
+ let found;
110
+ try {
111
+ found = resolveReadable(callerId, ref);
112
+ } catch (error) {
113
+ throw new ReadError(
114
+ error instanceof Error ? error.message : String(error),
115
+ 403
116
+ );
117
+ }
118
+ if (!found.ok) {
119
+ const scope = readerScope(callerId);
120
+ const external =
121
+ found.external && scope.workspaceId
122
+ ? await findExternalSession(scope.workspaceId, ref).catch(() => null)
123
+ : null;
124
+ if (external) {
125
+ const n = o.lines ?? (o.all ? TERMINAL_LINES_ALL : TERMINAL_LINES);
126
+ return {
127
+ header: `${external.ref} (external terminal)`,
128
+ text: await readExternal(external, n),
129
+ session: { id: null, name: external.ref, project: null },
130
+ task: null,
131
+ turns: null,
132
+ truncated: false,
133
+ };
134
+ }
135
+ throw new ReadError(found.error, found.external ? 404 : 403);
136
+ }
137
+ const s: ReadableSession = found.session;
138
+ const who = { id: s.id, name: s.name, project: s.project_name };
139
+ const label = `${s.project_name ? `${s.project_name}/` : ""}${s.name}`;
140
+ const task = taskHeader(s, !!o.all);
141
+ const note = found.note ? ` (${found.note})` : "";
142
+
143
+ if (s.view !== "chat" || hostLink(s.host_id)) {
144
+ const n =
145
+ o.lines ?? (o.all || o.grep ? TERMINAL_LINES_ALL : TERMINAL_LINES);
146
+ return {
147
+ header: `${label}${note}: ${s.view}, ${stateOf(s)}`,
148
+ text: await readResolved(s, n, o.grep),
149
+ session: who,
150
+ task,
151
+ turns: null,
152
+ truncated: false,
153
+ };
154
+ }
155
+
156
+ const { turns, total } = chatTurns(s, o);
157
+ const r: Rendered = o.grep
158
+ ? grepTurns(turns, o.grep, { all: o.all })
159
+ : renderTurns(turns, o);
160
+ const taskText = task
161
+ ? `Task brief:\n${task.prompt}${task.plan ? `\nPlan: ${task.plan}` : ""}\n\n`
162
+ : "";
163
+ const body = r.text
164
+ ? r.text
165
+ : o.grep
166
+ ? `no turns match "${o.grep}"`
167
+ : "(no messages yet)";
168
+ const range = r.to
169
+ ? `turns ${r.from === r.to ? r.to : `${r.from}-${r.to}`} of ${total}`
170
+ : `${total} turns`;
171
+ const more =
172
+ r.truncated && !o.all ? "; more with --turns N, --grep or --all" : "";
173
+ return {
174
+ header: `${label}${note} (chat, ${stateOf(s)}), ${o.grep ? `matches for "${o.grep}", ` : ""}${range}${more}`,
175
+ text: untrusted(s.name, `${taskText}${body}`),
176
+ session: who,
177
+ task,
178
+ turns: { from: r.from, to: r.to, total },
179
+ truncated: r.truncated,
180
+ };
181
+ }
@@ -596,7 +596,9 @@ export class ChatHost {
596
596
  if (/^\/compact\b/.test(m.text.trimStart())) this.setCompacting(true);
597
597
  const checkpoint = now
598
598
  ? this.conversation.send(text, m.images, { now, files: m.files })
599
- : this.conversation.send(text, m.images, { files: m.files });
599
+ : m.files?.length
600
+ ? this.conversation.send(text, m.images, { files: m.files })
601
+ : this.conversation.send(text, m.images);
600
602
  const user: ChatItem = {
601
603
  id: m.id,
602
604
  kind: "user",
@@ -0,0 +1,12 @@
1
+ import type { Migration } from "../migration-types";
2
+ import { createConversationSearch } from "../../search/conversations/schema";
3
+
4
+ export default {
5
+ key: "20261019T1500_conversation_search",
6
+ up: (db) => {
7
+ // The index and the triggers that keep it current. Existing chats are
8
+ // indexed in the background after startup (lib/search/conversations/backfill.ts),
9
+ // never in this migration, so a big database doesn't hold up the boot.
10
+ createConversationSearch(db);
11
+ },
12
+ } satisfies Migration;
@@ -57,6 +57,10 @@ export function restoreDemo(db: Database.Database, file: string): void {
57
57
  db.transaction(() => {
58
58
  for (const { name } of tables) {
59
59
  if (KEEP_TABLES.has(name)) continue;
60
+ // The search index (an FTS table, its shadow tables, its keys) is
61
+ // kept up to date by triggers on chat_items; copying it would
62
+ // fight them, and shadow tables can't be written at all.
63
+ if (name.startsWith("chat_search")) continue;
60
64
  // Stored columns only: a generated one can't be written.
61
65
  const cols = (
62
66
  db.prepare(`SELECT * FROM pragma_table_xinfo(?)`).all(name) as {
@@ -76,7 +76,12 @@ function queue(): QueueFact[] {
76
76
 
77
77
  // Background jobs still running in a chat whose agent is alive, that have
78
78
  // been going long enough to be worth a look at their output.
79
- function backgroundJobs(now: number, c: HealthConfig): BgFact[] {
79
+ export interface BgTask extends BgFact {
80
+ outputFile?: string;
81
+ taskType?: string;
82
+ }
83
+
84
+ export function runningBgTasks(now: number, minAgeMs: number): BgTask[] {
80
85
  const live = new Set(runningWorkers());
81
86
  if (!live.size) return [];
82
87
  const sessions = db
@@ -86,12 +91,12 @@ function backgroundJobs(now: number, c: HealthConfig): BgFact[] {
86
91
  WHERE s.archived_at IS NULL AND s.view = 'chat'`
87
92
  )
88
93
  .all() as { id: string; name: string; workspace_id: string | null }[];
89
- const out: BgFact[] = [];
94
+ const out: BgTask[] = [];
90
95
  for (const s of sessions) {
91
96
  if (!live.has(s.id)) continue;
92
97
  for (const t of itemsOfKind(s.id, "task")) {
93
98
  if (t.status !== "running" || t.ambient) continue;
94
- if (now - t.createdAt < c.bgQuietMinutes * MIN) continue;
99
+ if (now - t.createdAt < minAgeMs) continue;
95
100
  const tail = taskOutputTail(t.taskId, t.outputFile);
96
101
  out.push({
97
102
  sessionId: s.id,
@@ -101,12 +106,17 @@ function backgroundJobs(now: number, c: HealthConfig): BgFact[] {
101
106
  description: t.description,
102
107
  startedAt: t.createdAt,
103
108
  outputAt: tail?.updatedAt ?? null,
109
+ outputFile: t.outputFile,
110
+ taskType: t.taskType,
104
111
  });
105
112
  }
106
113
  }
107
114
  return out;
108
115
  }
109
116
 
117
+ const backgroundJobs = (now: number, c: HealthConfig): BgFact[] =>
118
+ runningBgTasks(now, c.bgQuietMinutes * MIN);
119
+
110
120
  // Every running background job, for the heartbeat a restart reads back.
111
121
  export function liveBackgroundJobs(): string[] {
112
122
  const live = new Set(runningWorkers());
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The I/O half of bg-spin (spin.ts decides): once a minute, for each old
3
+ * enough background job of a live chat, find its process tree, sample the CPU
4
+ * of its shells, and stop a job that spins. A stop is by PID, the job's own
5
+ * tree only: SIGTERM, then SIGKILL for what is still the same process after a
6
+ * grace. Never a pattern, never a group, never AgentOS, tmux or the agent.
7
+ * With no old background job there is no process I/O at all.
8
+ */
9
+
10
+ import { execFile } from "child_process";
11
+ import fs from "fs";
12
+ import { sendMessage } from "../bus";
13
+ import { findOutputFile } from "../chat/task-output";
14
+ import { queueEventOnce } from "../orchestrator/events";
15
+ import { untrusted } from "../orchestrator/untrusted";
16
+ import { processTable, type ProcRow } from "../process-table";
17
+ import { runningBgTasks, type BgTask } from "./facts";
18
+ import { close, recordTrips } from "./store";
19
+ import {
20
+ KILL_GRACE_MS,
21
+ SPIN_KEEP_MS,
22
+ SPIN_MIN_AGE_MS,
23
+ baseOf,
24
+ classifySpin,
25
+ jobTree,
26
+ killPlan,
27
+ parseCpuTime,
28
+ pickRoot,
29
+ safeToKill,
30
+ shellPids,
31
+ spinEventLine,
32
+ spinNote,
33
+ type SpinSample,
34
+ } from "./spin";
35
+ import type { Trip } from "./types";
36
+
37
+ export interface SpinDeps {
38
+ tasks: (now: number) => BgTask[];
39
+ // Output file -> the PIDs holding it open.
40
+ holders: (files: string[]) => Promise<Map<string, number[]>>;
41
+ table: () => Promise<ProcRow[] | null>;
42
+ cpuSeconds: (pids: number[]) => Promise<Map<number, number>>;
43
+ kill: (pid: number, signal: "SIGTERM" | "SIGKILL") => void;
44
+ sleep: (ms: number) => Promise<void>;
45
+ tell: (sessionId: string, body: string) => Promise<void>;
46
+ event: (
47
+ workspaceId: string,
48
+ key: string,
49
+ subject: string,
50
+ line: string,
51
+ now: number
52
+ ) => void;
53
+ record: (trip: Trip, now: number) => void;
54
+ }
55
+
56
+ const run = (cmd: string, args: string[]): Promise<string> =>
57
+ new Promise((resolve) =>
58
+ execFile(cmd, args, { timeout: 5000, maxBuffer: 4 << 20 }, (_err, out) =>
59
+ resolve(out ?? "")
60
+ )
61
+ );
62
+
63
+ // `lsof -F pn`: a `p<pid>` line, then `n<name>` for each file it holds.
64
+ export function parseHolders(out: string): Map<string, number[]> {
65
+ const map = new Map<string, number[]>();
66
+ let pid = 0;
67
+ for (const line of out.split("\n")) {
68
+ if (line.startsWith("p")) pid = Number(line.slice(1)) || 0;
69
+ else if (line.startsWith("n") && pid) {
70
+ const list = map.get(line.slice(1)) ?? [];
71
+ if (!list.includes(pid)) list.push(pid);
72
+ map.set(line.slice(1), list);
73
+ }
74
+ }
75
+ return map;
76
+ }
77
+
78
+ // Linux counts reaped children in /proc; macOS ps's TIME already includes them.
79
+ function procCpu(pid: number): number | null {
80
+ try {
81
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
82
+ const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
83
+ return (
84
+ (Number(f[11]) + Number(f[12]) + Number(f[13]) + Number(f[14])) / 100
85
+ );
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async function cpuSeconds(pids: number[]): Promise<Map<number, number>> {
92
+ const out = new Map<number, number>();
93
+ if (!pids.length) return out;
94
+ if (process.platform === "linux") {
95
+ for (const pid of pids) {
96
+ const sec = procCpu(pid);
97
+ if (sec !== null) out.set(pid, sec);
98
+ }
99
+ return out;
100
+ }
101
+ const text = await run("ps", ["-o", "pid=,time=", "-p", pids.join(",")]);
102
+ for (const line of text.split("\n")) {
103
+ const m = /^\s*(\d+)\s+(\S+)\s*$/.exec(line);
104
+ const sec = m ? parseCpuTime(m[2]) : null;
105
+ if (m && sec !== null) out.set(Number(m[1]), sec);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ export const realSpin: SpinDeps = {
111
+ tasks: (now) =>
112
+ runningBgTasks(now, SPIN_MIN_AGE_MS).map((t) => ({
113
+ ...t,
114
+ outputFile: t.outputFile ?? findOutputFile(t.taskId) ?? undefined,
115
+ })),
116
+ holders: async (files) =>
117
+ parseHolders(await run("lsof", ["-F", "pn", "--", ...files])),
118
+ table: () => processTable(0),
119
+ cpuSeconds,
120
+ kill: (pid, signal) => process.kill(pid, signal),
121
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
122
+ tell: async (sessionId, body) => {
123
+ await sendMessage({
124
+ fromId: null,
125
+ to: sessionId,
126
+ body,
127
+ fromLabel: "AgentOS health",
128
+ origin: { kind: "system", label: "Health", body },
129
+ });
130
+ },
131
+ event: (ws, key, subject, line, now) => {
132
+ queueEventOnce(ws, key, subject, line, now);
133
+ },
134
+ record: (trip, now) => {
135
+ const { opened } = recordTrips([trip], [], now);
136
+ // History only: nothing to wake a watcher for.
137
+ for (const row of opened) close(row.id, "stopped", now);
138
+ },
139
+ };
140
+
141
+ interface Track {
142
+ root: number;
143
+ comm: string;
144
+ samples: SpinSample[];
145
+ }
146
+
147
+ const tracks = new Map<string, Track>();
148
+ let running = false;
149
+
150
+ export function resetSpin(): void {
151
+ tracks.clear();
152
+ running = false;
153
+ }
154
+
155
+ const keyOf = (t: BgTask) => `${t.sessionId}:${t.taskId}`;
156
+
157
+ // SIGTERM the whole job, root first; SIGKILL what is still the same process.
158
+ async function stopTree(
159
+ root: number,
160
+ table: ProcRow[],
161
+ deps: SpinDeps
162
+ ): Promise<number[]> {
163
+ const plan = killPlan(root, table);
164
+ const comm = new Map(table.map((r) => [r.pid, r.comm]));
165
+ const signal = (pid: number, sig: "SIGTERM" | "SIGKILL") => {
166
+ try {
167
+ deps.kill(pid, sig);
168
+ } catch {
169
+ // Already gone.
170
+ }
171
+ };
172
+ for (const pid of plan) signal(pid, "SIGTERM");
173
+ await deps.sleep(KILL_GRACE_MS);
174
+ const after = new Map((await deps.table())?.map((r) => [r.pid, r.comm]));
175
+ for (const pid of plan)
176
+ if (after.has(pid) && after.get(pid) === comm.get(pid))
177
+ signal(pid, "SIGKILL");
178
+ return plan;
179
+ }
180
+
181
+ async function sampleAll(
182
+ jobs: BgTask[],
183
+ now: number,
184
+ deps: SpinDeps
185
+ ): Promise<{ job: BgTask; track: Track; table: ProcRow[] }[]> {
186
+ const table = await deps.table();
187
+ if (!table) return [];
188
+ const need = jobs.filter((j) => !tracks.get(keyOf(j)) && j.outputFile);
189
+ if (need.length) {
190
+ const held = await deps.holders(need.map((j) => j.outputFile as string));
191
+ for (const j of need) {
192
+ const root = pickRoot(held.get(j.outputFile as string) ?? [], table);
193
+ const row = table.find((r) => r.pid === root);
194
+ if (row)
195
+ tracks.set(keyOf(j), { root: row.pid, comm: row.comm, samples: [] });
196
+ }
197
+ }
198
+ const live: { job: BgTask; track: Track; table: ProcRow[] }[] = [];
199
+ for (const j of jobs) {
200
+ const track = tracks.get(keyOf(j));
201
+ if (!track) continue;
202
+ const row = table.find((r) => r.pid === track.root);
203
+ // Gone, or the PID is someone else's now.
204
+ if (!row || row.comm !== track.comm) {
205
+ tracks.delete(keyOf(j));
206
+ continue;
207
+ }
208
+ live.push({ job: j, track, table });
209
+ }
210
+ const pids = live.flatMap((l) => shellPids(jobTree(l.track.root, table)));
211
+ const cpu = await deps.cpuSeconds(pids);
212
+ for (const { job, track } of live) {
213
+ const sec = shellPids(jobTree(track.root, table)).reduce(
214
+ (sum, pid) => sum + (cpu.get(pid) ?? 0),
215
+ 0
216
+ );
217
+ track.samples.push({ at: now, cpuSec: sec, outputAt: job.outputAt });
218
+ track.samples = track.samples.filter((s) => now - s.at <= SPIN_KEEP_MS);
219
+ }
220
+ return live;
221
+ }
222
+
223
+ export async function spinTick(
224
+ now = Date.now(),
225
+ deps: SpinDeps = realSpin
226
+ ): Promise<void> {
227
+ if (running) return;
228
+ running = true;
229
+ try {
230
+ // Shell jobs only: a subagent task has no shell to spin.
231
+ const jobs = deps
232
+ .tasks(now)
233
+ .filter((t) => !t.taskType || t.taskType === "local_bash");
234
+ const names = new Set(jobs.map(keyOf));
235
+ for (const k of tracks.keys()) if (!names.has(k)) tracks.delete(k);
236
+ if (!jobs.length) return;
237
+ for (const { job, track, table } of await sampleAll(jobs, now, deps)) {
238
+ if (classifySpin(track.samples) !== "spinning") continue;
239
+ if (!safeToKill(track.root, table)) {
240
+ console.warn(
241
+ `[health] bg-spin: not stopping pid ${track.root} (${baseOf(track.comm)}) in ${job.sessionName}: it is not a plain job of the agent`
242
+ );
243
+ continue;
244
+ }
245
+ const pids = await stopTree(track.root, table, deps);
246
+ tracks.delete(keyOf(job));
247
+ console.warn(
248
+ `[health] bg-spin: stopped "${job.description}" in ${job.sessionName} (session ${job.sessionId}): pid ${track.root}, tree ${pids.join(",")}`
249
+ );
250
+ await announce(job, track.root, now, deps);
251
+ }
252
+ } finally {
253
+ running = false;
254
+ }
255
+ }
256
+
257
+ async function announce(
258
+ job: BgTask,
259
+ pid: number,
260
+ now: number,
261
+ deps: SpinDeps
262
+ ): Promise<void> {
263
+ const line = spinEventLine(job.sessionName, job.description, pid);
264
+ try {
265
+ deps.record(
266
+ {
267
+ check: "bg-spin",
268
+ subject: keyOf(job),
269
+ severity: "high",
270
+ line,
271
+ detail: { sessionId: job.sessionId, taskId: job.taskId, pid },
272
+ workspaceId: job.workspaceId,
273
+ },
274
+ now
275
+ );
276
+ } catch (error) {
277
+ console.error("[health] bg-spin record:", error);
278
+ }
279
+ if (job.workspaceId)
280
+ deps.event(
281
+ job.workspaceId,
282
+ `bg-spin:${keyOf(job)}`,
283
+ job.sessionId,
284
+ // The description is the agent's own text.
285
+ `Health (tasks): ${untrusted("health detector", line)}`,
286
+ now
287
+ );
288
+ try {
289
+ await deps.tell(job.sessionId, spinNote(job.description, pid));
290
+ } catch (error) {
291
+ console.error("[health] bg-spin note:", error);
292
+ }
293
+ }