@saadnvd1/agent-os 0.3.523 → 0.3.527

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 (69) hide show
  1. package/README.md +82 -3
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/sessions/[id]/simulator/route.ts +32 -0
  4. package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
  5. package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
  6. package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
  7. package/app/page.tsx +8 -3
  8. package/components/Chat/ChatPanel.tsx +96 -8
  9. package/components/Chat/EventRow.tsx +2 -2
  10. package/components/Chat/EventTurns.test.ts +56 -0
  11. package/components/Chat/EventTurns.tsx +141 -0
  12. package/components/Chat/useFind.ts +5 -3
  13. package/components/Chat/useGoToItem.ts +71 -0
  14. package/components/Orchestrator/AsksList.tsx +16 -11
  15. package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
  16. package/components/Orchestrator/OrchestratorBar.tsx +27 -2
  17. package/components/Settings/SettingsDialog.tsx +2 -2
  18. package/components/Settings/SetupSection.tsx +6 -1
  19. package/components/Simulator/ScreenshotStrip.test.ts +91 -0
  20. package/components/Simulator/ScreenshotStrip.tsx +102 -0
  21. package/components/Simulator/index.ts +1 -0
  22. package/components/Tasks/TaskRow.tsx +2 -0
  23. package/components/Welcome/DevicesStep.tsx +90 -0
  24. package/components/Welcome/HealthList.tsx +69 -0
  25. package/components/Welcome/ProjectStep.tsx +2 -8
  26. package/components/Welcome/WelcomeDialog.tsx +31 -7
  27. package/data/doctor/index.ts +29 -0
  28. package/data/orchestrators/index.ts +59 -0
  29. package/data/push/topics.ts +2 -0
  30. package/data/simulator/index.test.ts +14 -0
  31. package/data/simulator/index.ts +33 -0
  32. package/data/simulator/keys.ts +5 -0
  33. package/hooks/useNeedsYou.ts +53 -0
  34. package/lib/chat/events.ts +2 -0
  35. package/lib/chat/find.ts +25 -4
  36. package/lib/chat/group.ts +50 -2
  37. package/lib/chat/turns.ts +111 -0
  38. package/lib/db/changes.ts +6 -0
  39. package/lib/db/migrations.ts +51 -0
  40. package/lib/doctor/checks.ts +222 -0
  41. package/lib/orchestrator/ask-view.ts +13 -0
  42. package/lib/orchestrator/brief.ts +5 -1
  43. package/lib/orchestrator/deliver.ts +3 -0
  44. package/lib/orchestrator/events.ts +19 -0
  45. package/lib/orchestrator/needs-you-items.ts +104 -0
  46. package/lib/orchestrator/overview.ts +3 -0
  47. package/lib/orchestrator/questions.ts +120 -0
  48. package/lib/orchestrator/serve.ts +11 -0
  49. package/lib/orchestrator/tool-names.ts +2 -0
  50. package/lib/orchestrator/tool-schemas.ts +12 -0
  51. package/lib/orchestrator/tools.ts +4 -0
  52. package/lib/project-config/agentos.schema.json +19 -0
  53. package/lib/project-config/brief.ts +3 -0
  54. package/lib/project-config/schema.ts +10 -0
  55. package/lib/sessions/end.ts +3 -0
  56. package/lib/sessions/stop.ts +3 -0
  57. package/lib/simulator/agent-device.ts +258 -0
  58. package/lib/simulator/available.ts +5 -0
  59. package/lib/simulator/brief.ts +18 -0
  60. package/lib/simulator/http.ts +16 -0
  61. package/lib/simulator/index.ts +458 -0
  62. package/lib/simulator/lease.ts +169 -0
  63. package/lib/simulator/simctl.ts +167 -0
  64. package/lib/welcome-url.ts +6 -1
  65. package/package.json +1 -1
  66. package/server.ts +16 -0
  67. package/stores/needsYouFocus.ts +13 -0
  68. package/stores/welcomeUi.test.ts +15 -0
  69. package/stores/welcomeUi.ts +6 -1
@@ -0,0 +1,111 @@
1
+ import type { ChatItem, ChatOrigin } from "./events";
2
+ import { eventParts, originOf } from "./origin";
3
+
4
+ type UserItem = Extract<ChatItem, { kind: "user" }>;
5
+
6
+ // What the orchestrator's reply to an event ends up as in the timeline.
7
+ export interface EventTurn {
8
+ // The event's own item id.
9
+ id: string;
10
+ user: UserItem;
11
+ origin: ChatOrigin;
12
+ // Everything after the event up to the end of the turn.
13
+ replies: ChatItem[];
14
+ // The start of the reply (or of the event, before it has one).
15
+ summary: string;
16
+ // The task or source it is about.
17
+ task: string;
18
+ at: number;
19
+ // Still being answered.
20
+ live: boolean;
21
+ // The reply opened a question for the reader.
22
+ hasQuestion: boolean;
23
+ }
24
+
25
+ const FOLDED_KINDS = new Set(["event", "peer", "schedule", "system"]);
26
+
27
+ // The origin of a user item when it is activity (an event, a task's message,
28
+ // a schedule) rather than something the reader said or decided. Items
29
+ // without a recorded origin are read by their text only if they predate it
30
+ // (originOf).
31
+ export function activityOrigin(item: UserItem): ChatOrigin | null {
32
+ const origin = originOf(item);
33
+ if (!origin || !FOLDED_KINDS.has(origin.kind)) return null;
34
+ // A batch carrying the reader's own answers: the reply is to them.
35
+ if (origin.decided?.length) return null;
36
+ return origin;
37
+ }
38
+
39
+ export const isQuestionTool = (item: ChatItem): boolean =>
40
+ item.kind === "tool" && /(^|__)question$/.test(item.name);
41
+
42
+ export function plainSummary(text: string, max = 140): string {
43
+ const line =
44
+ text
45
+ .split("\n")
46
+ .map((l) => l.trim())
47
+ .find(Boolean) ?? "";
48
+ const plain = line
49
+ .replace(/^(#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "")
50
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
51
+ .replace(/[*_`~]+/g, "")
52
+ .replace(/\s+/g, " ")
53
+ .trim();
54
+ return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
55
+ }
56
+
57
+ function taskName(origin: ChatOrigin): string {
58
+ const names = origin.subjects?.map((s) => s.name) ?? [];
59
+ if (!names.length) return origin.label;
60
+ return names.length > 1 ? `${names[0]} +${names.length - 1}` : names[0];
61
+ }
62
+
63
+ // The turn an activity item starts, or null when it should render in full:
64
+ // anything that needs the reader's eyes (a pending approval, a plan, an
65
+ // error) stays out of the fold. `limit` is where a turn must stop (the next
66
+ // undone range).
67
+ export function eventTurnAt(
68
+ items: ChatItem[],
69
+ start: number,
70
+ limit = items.length
71
+ ): { turn: EventTurn; end: number } | null {
72
+ const user = items[start];
73
+ if (user.kind !== "user") return null;
74
+ const origin = activityOrigin(user);
75
+ if (!origin) return null;
76
+ let end = start + 1;
77
+ let ended = false;
78
+ while (end < limit) {
79
+ const item = items[end];
80
+ if (item.kind === "user" || item.kind === "undo") break;
81
+ end++;
82
+ if (item.kind === "turn_end") {
83
+ ended = true;
84
+ break;
85
+ }
86
+ }
87
+ const replies = items.slice(start + 1, end);
88
+ const needsReader = replies.some(
89
+ (i) =>
90
+ i.kind === "plan" ||
91
+ i.kind === "error" ||
92
+ (i.kind === "approval" && i.status === "pending")
93
+ );
94
+ if (needsReader) return null;
95
+ const reply = replies.find((i) => i.kind === "assistant" && i.text.trim());
96
+ const body = eventParts(user, origin).body;
97
+ return {
98
+ end,
99
+ turn: {
100
+ id: user.id,
101
+ user,
102
+ origin,
103
+ replies,
104
+ summary: plainSummary(reply?.kind === "assistant" ? reply.text : body),
105
+ task: taskName(origin),
106
+ at: user.createdAt,
107
+ live: !ended && end >= items.length,
108
+ hasQuestion: replies.some(isQuestionTool),
109
+ },
110
+ };
111
+ }
package/lib/db/changes.ts CHANGED
@@ -29,12 +29,18 @@ export const TABLES_WATCHED_56 = ["orchestrator_rules"] as const;
29
29
 
30
30
  export const TABLES_WATCHED_58 = ["away_periods"] as const;
31
31
 
32
+ export const TABLES_WATCHED_78 = [
33
+ "orchestrator_questions",
34
+ "needs_you_dismissed",
35
+ ] as const;
36
+
32
37
  // Every table any migration watches, for the browser's topic map to cover.
33
38
  export const WATCHED_TABLES: readonly string[] = [
34
39
  ...TABLES_WATCHED_45,
35
40
  ...TABLES_WATCHED_50,
36
41
  ...TABLES_WATCHED_56,
37
42
  ...TABLES_WATCHED_58,
43
+ ...TABLES_WATCHED_78,
38
44
  ];
39
45
 
40
46
  export function installChangeTriggers(
@@ -4,6 +4,7 @@ import {
4
4
  TABLES_WATCHED_50,
5
5
  TABLES_WATCHED_56,
6
6
  TABLES_WATCHED_58,
7
+ TABLES_WATCHED_78,
7
8
  } from "./changes";
8
9
  import type Database from "better-sqlite3";
9
10
 
@@ -1768,6 +1769,56 @@ const migrations: Migration[] = [
1768
1769
  );
1769
1770
  },
1770
1771
  },
1772
+ {
1773
+ id: 77,
1774
+ name: "simulator_leases",
1775
+ up: (db) => {
1776
+ // One iOS simulator a task holds (lib/simulator): the device AgentOS
1777
+ // made for it, and where it is in coming up.
1778
+ db.exec(
1779
+ `CREATE TABLE IF NOT EXISTS simulator_leases (
1780
+ session_id TEXT PRIMARY KEY,
1781
+ udid TEXT,
1782
+ name TEXT NOT NULL,
1783
+ state TEXT NOT NULL,
1784
+ error TEXT,
1785
+ created_at INTEGER NOT NULL,
1786
+ last_used_at INTEGER NOT NULL
1787
+ )`
1788
+ );
1789
+ },
1790
+ },
1791
+ {
1792
+ id: 78,
1793
+ name: "orchestrator_questions",
1794
+ up: (db) => {
1795
+ // A question the orchestrator put to Saad in chat, open until he
1796
+ // answers it or the orchestrator clears it. needs_you_dismissed: items
1797
+ // he hid from the Needs-you strip (an ask's or question's key); hiding
1798
+ // never resolves anything.
1799
+ db.exec(`
1800
+ CREATE TABLE IF NOT EXISTS orchestrator_questions (
1801
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1802
+ workspace_id TEXT NOT NULL,
1803
+ text TEXT NOT NULL,
1804
+ task TEXT,
1805
+ status TEXT NOT NULL DEFAULT 'open',
1806
+ resolved_by TEXT,
1807
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1808
+ resolved_at TEXT
1809
+ );
1810
+ CREATE INDEX IF NOT EXISTS idx_orchestrator_questions_open
1811
+ ON orchestrator_questions(workspace_id, status);
1812
+ CREATE TABLE IF NOT EXISTS needs_you_dismissed (
1813
+ workspace_id TEXT NOT NULL,
1814
+ key TEXT NOT NULL,
1815
+ dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
1816
+ PRIMARY KEY (workspace_id, key)
1817
+ );
1818
+ `);
1819
+ installChangeTriggers(db, TABLES_WATCHED_78);
1820
+ },
1821
+ },
1771
1822
  ];
1772
1823
 
1773
1824
  // `upTo`: stop after this id (tests that start from an older database).
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Is this machine ready to run AgentOS? Node, git, tmux, ripgrep, lsof and at
3
+ * least one agent CLI, each with what to do when it is not. Read-only: it runs
4
+ * each tool's version and nothing else.
5
+ */
6
+
7
+ import { execFile } from "child_process";
8
+ import { promisify } from "util";
9
+ import { probeAgents, type AgentProbe } from "../agents/probe";
10
+ import { getAllProviderDefinitions } from "../providers/registry";
11
+
12
+ // The oldest Node major AgentOS runs on; scripts/lib/prerequisites.sh must agree
13
+ // (lib/doctor/drift.test.ts fails when they don't).
14
+ export const MIN_NODE_MAJOR = 20;
15
+
16
+ const TOOL_MS = 5_000;
17
+ const CACHE_MS = 60 * 1000;
18
+
19
+ export type CheckStatus = "ok" | "warn" | "fail";
20
+
21
+ export interface DoctorCheck {
22
+ id: string;
23
+ label: string;
24
+ status: CheckStatus;
25
+ detail: string;
26
+ // The line to run, or what to do, when the check is not ok.
27
+ fix?: string;
28
+ }
29
+
30
+ export type ExecFn = (
31
+ file: string,
32
+ args: string[],
33
+ options: { timeout: number }
34
+ ) => Promise<{ stdout: string; stderr: string }>;
35
+
36
+ export interface DoctorDeps {
37
+ exec: ExecFn;
38
+ probe: (fresh: boolean) => Promise<Record<string, AgentProbe>>;
39
+ nodeVersion: string;
40
+ }
41
+
42
+ const defaultDeps: DoctorDeps = {
43
+ exec: promisify(execFile) as ExecFn,
44
+ probe: probeAgents,
45
+ nodeVersion: process.versions.node,
46
+ };
47
+
48
+ const INSTALL = {
49
+ mac: "brew install",
50
+ linux: "sudo apt-get install -y",
51
+ };
52
+ const installLine = (pkg: string) =>
53
+ process.platform === "darwin"
54
+ ? `${INSTALL.mac} ${pkg}`
55
+ : `${INSTALL.linux} ${pkg}`;
56
+
57
+ interface Tool {
58
+ id: string;
59
+ label: string;
60
+ bin: string;
61
+ args: string[];
62
+ pkg: string;
63
+ // Missing is a fail for what AgentOS cannot run without, a warn otherwise.
64
+ required: boolean;
65
+ why: string;
66
+ }
67
+
68
+ const TOOLS: Tool[] = [
69
+ {
70
+ id: "git",
71
+ label: "git",
72
+ bin: "git",
73
+ args: ["--version"],
74
+ pkg: "git",
75
+ required: true,
76
+ why: "Sessions work in git worktrees.",
77
+ },
78
+ {
79
+ id: "tmux",
80
+ label: "tmux",
81
+ bin: "tmux",
82
+ args: ["-V"],
83
+ pkg: "tmux",
84
+ required: true,
85
+ why: "Terminal sessions run inside tmux.",
86
+ },
87
+ {
88
+ id: "ripgrep",
89
+ label: "ripgrep",
90
+ bin: "rg",
91
+ args: ["--version"],
92
+ pkg: "ripgrep",
93
+ required: false,
94
+ why: "File search is slower without it.",
95
+ },
96
+ {
97
+ id: "lsof",
98
+ label: "lsof",
99
+ bin: "lsof",
100
+ args: ["-v"],
101
+ pkg: "lsof",
102
+ required: false,
103
+ why: "Finding what holds a port needs it.",
104
+ },
105
+ ];
106
+
107
+ const firstLine = (s: string) =>
108
+ s
109
+ .split("\n")
110
+ .find((l) => l.trim())
111
+ ?.trim();
112
+
113
+ export function checkNode(version: string): DoctorCheck {
114
+ const major = Number.parseInt(version.split(".")[0], 10);
115
+ const ok = major >= MIN_NODE_MAJOR;
116
+ return {
117
+ id: "node",
118
+ label: "Node.js",
119
+ status: ok ? "ok" : "fail",
120
+ detail: ok
121
+ ? `v${version}`
122
+ : `v${version}; AgentOS needs ${MIN_NODE_MAJOR} or newer`,
123
+ ...(ok
124
+ ? {}
125
+ : { fix: `Install Node.js ${MIN_NODE_MAJOR}+ from nodejs.org` }),
126
+ };
127
+ }
128
+
129
+ async function checkTool(tool: Tool, exec: ExecFn): Promise<DoctorCheck> {
130
+ const base = { id: tool.id, label: tool.label };
131
+ try {
132
+ const { stdout, stderr } = await exec(tool.bin, tool.args, {
133
+ timeout: TOOL_MS,
134
+ });
135
+ return {
136
+ ...base,
137
+ status: "ok",
138
+ detail: firstLine(`${stdout}\n${stderr}`) ?? "installed",
139
+ };
140
+ } catch (error) {
141
+ const timedOut = (error as { killed?: boolean }).killed;
142
+ // lsof -v exits non-zero on some builds while printing its version; a
143
+ // tool that ran is installed, only one that could not start is missing.
144
+ const e = error as { code?: unknown; stdout?: string; stderr?: string };
145
+ const ran = typeof e.code === "number" && !timedOut;
146
+ if (tool.id === "lsof" && ran)
147
+ return { ...base, status: "ok", detail: "installed" };
148
+ return {
149
+ ...base,
150
+ status: tool.required ? "fail" : "warn",
151
+ detail: timedOut
152
+ ? `${tool.bin} did not answer`
153
+ : `Not found. ${tool.why}`,
154
+ fix: installLine(tool.pkg),
155
+ };
156
+ }
157
+ }
158
+
159
+ export function checkAgents(probes: Record<string, AgentProbe>): DoctorCheck {
160
+ const clis = getAllProviderDefinitions().filter((d) => d.cli);
161
+ const names = new Map(clis.map((d) => [d.id, d.name]));
162
+ // Providers without a CLI (a plain terminal) are always "installed".
163
+ const found = Object.entries(probes).filter(
164
+ ([id, p]) => names.has(id as never) && p.installed
165
+ );
166
+ const ready = found.filter(([, p]) => p.auth !== "needs-login");
167
+ const list = (rows: typeof found) =>
168
+ rows.map(([id]) => names.get(id as never) ?? id).join(", ");
169
+ if (ready.length > 0)
170
+ return {
171
+ id: "agents",
172
+ label: "Agent CLI",
173
+ status: "ok",
174
+ detail: `${list(ready)} ready`,
175
+ };
176
+ if (found.length > 0)
177
+ return {
178
+ id: "agents",
179
+ label: "Agent CLI",
180
+ status: "warn",
181
+ detail: `${list(found)} installed but needs sign-in`,
182
+ fix: "Sign in below",
183
+ };
184
+ return {
185
+ id: "agents",
186
+ label: "Agent CLI",
187
+ status: "fail",
188
+ detail: "No agent CLI installed",
189
+ fix: "Install one below",
190
+ };
191
+ }
192
+
193
+ let cache: { at: number; checks: Promise<DoctorCheck[]> } | null = null;
194
+
195
+ /** Every check, in parallel, cached for a minute. `fresh` runs them again. */
196
+ export function runDoctor(
197
+ fresh = false,
198
+ deps: Partial<DoctorDeps> = {}
199
+ ): Promise<DoctorCheck[]> {
200
+ const custom = Object.keys(deps).length > 0;
201
+ if (!custom && !fresh && cache && Date.now() - cache.at < CACHE_MS)
202
+ return cache.checks;
203
+ const d = { ...defaultDeps, ...deps };
204
+ const checks = Promise.all([
205
+ Promise.resolve(checkNode(d.nodeVersion)),
206
+ ...TOOLS.map((t) => checkTool(t, d.exec)),
207
+ d
208
+ .probe(fresh)
209
+ .then(checkAgents)
210
+ .catch(
211
+ (): DoctorCheck => ({
212
+ id: "agents",
213
+ label: "Agent CLI",
214
+ status: "warn",
215
+ detail: "Couldn't check the agent CLIs",
216
+ fix: "Check again",
217
+ })
218
+ ),
219
+ ]);
220
+ if (!custom) cache = { at: Date.now(), checks };
221
+ return checks;
222
+ }
@@ -30,10 +30,23 @@ export interface AskView {
30
30
  createdAt: string;
31
31
  }
32
32
 
33
+ // A question the orchestrator put to Saad in chat, open until answered.
34
+ export interface QuestionView {
35
+ id: number;
36
+ text: string;
37
+ // The task it is about, when it names one.
38
+ task: string | null;
39
+ taskName: string | null;
40
+ createdAt: string;
41
+ }
42
+
33
43
  export interface OrchestratorOverview {
34
44
  workspaceId: string;
35
45
  sessionId: string | null;
36
46
  paused: boolean;
37
47
  inReview: number;
38
48
  asks: AskView[];
49
+ questions: QuestionView[];
50
+ // Keys (ask:<id>, question:<id>) he hid from the Needs-you strip.
51
+ dismissed: string[];
39
52
  }
@@ -84,7 +84,9 @@ When one of these comes up, park it with \`ask_saad\` (kind: the hard line it cr
84
84
 
85
85
  Saad answers an ask on his list, and the answer reaches you as an event: \`ask "<title>": approved\`, \`declined\`, or \`reply: <text>\`. Act on it. An approval covers that one item only, never standing permission: the next item like it is a new ask. Approving a held task lets \`sign_off\` merge it once, at the commit he approved; approving a brake lets one start through, once.
86
86
 
87
- Saad can pause you. While paused your acting tools refuse and events wait; reading, \`note\` and \`ask_saad\` still work. When he resumes, what queued arrives as one message.
87
+ Anything you need from Saad in chat (a question, a choice, a confirmation) goes through \`question\` in the same turn you ask it, and you still write the question plainly in your reply. It stays pinned on his Needs-you strip until it is resolved: when he answers (in chat, or with the strip's Reply) or it stops mattering, call \`resolve_question\`. Don't leave one open once he has answered. \`ask_saad\` is for hard lines and calls that need Approve or Decline; \`question\` is for everything conversational. Don't open a question for something that is already an ask. Updates AgentOS sends you fold into one-line rows in his chat, so a question buried in a long reply to an event is easy to miss: that is what \`question\` is for.
88
+
89
+ Saad can pause you. While paused your acting tools refuse and events wait; reading, \`note\`, \`question\` and \`ask_saad\` still work. When he resumes, what queued arrives as one message.
88
90
 
89
91
  A message starting \`[Scheduled message "<name>" ...]\` is a standing prompt saved in Schedules, posted on its timer. Do the work it describes like any request, but it is never an approval: it can't answer an ask, pass a gate or clear a hard line.
90
92
 
@@ -127,6 +129,8 @@ Acting:
127
129
  - \`${TOOL_NAMES.arm}\` (task): merge a task's PR by itself the moment every gate passes; one event when it merges or a gate fails.
128
130
  - \`${TOOL_NAMES.disarm}\` (task): stop an armed task merging by itself.
129
131
  - \`${TOOL_NAMES.ask_saad}\` (title, detail, link?, kind, task?, sha?): park an item on Saad's asks list and carry on; it never waits.
132
+ - \`${TOOL_NAMES.question}\` (text, task?): mark a question you asked Saad in chat as open, pinned on his Needs-you strip.
133
+ - \`${TOOL_NAMES.resolve_question}\` (id): clear an open question once he has answered it or it no longer matters.
130
134
 
131
135
  The MCP servers this workspace allows (Settings → Workspaces) are yours to use through their own tools, for reading and for additive, reversible changes. Anything outbound through them (sending a message, an email, a post) is a hard line: park it with \`ask_saad\`.
132
136
 
@@ -10,6 +10,7 @@ import {
10
10
  logSuppressed,
11
11
  markDelivered,
12
12
  pendingEvents,
13
+ subjectNames,
13
14
  suppressedSince,
14
15
  unlogDeliveries,
15
16
  } from "./events";
@@ -130,9 +131,11 @@ export async function deliverEvents(opts: {
130
131
  const text = lines.join("\n");
131
132
  // Saad's answers to asks, shown in chat as his decisions.
132
133
  const decided = [...new Set(pending.filter(isAnswer).map((e) => e.line))];
134
+ const subjects = subjectNames(pending.map((e) => e.subject));
133
135
  const origin: ChatOrigin = {
134
136
  kind: "event",
135
137
  label: "AgentOS",
138
+ ...(subjects.length ? { subjects } : {}),
136
139
  ...(decided.length ? { decided } : {}),
137
140
  ...(pending.every((e) => isAwayEvent(e)) ? { away: true as const } : {}),
138
141
  };
@@ -299,3 +299,22 @@ export function deliveriesSince(
299
299
  .get(workspaceId, subject, iso(since)) as { n: number }
300
300
  ).n;
301
301
  }
302
+
303
+ // The sessions an event batch is about, by name, for chat to say what each
304
+ // update was.
305
+ export function subjectNames(
306
+ subjects: (string | null)[]
307
+ ): { id: string; name: string }[] {
308
+ const ids = [...new Set(subjects.filter((s): s is string => !!s))];
309
+ if (!ids.length) return [];
310
+ const rows = db
311
+ .prepare(
312
+ `SELECT id, name FROM sessions WHERE id IN (${ids.map(() => "?").join(",")})`
313
+ )
314
+ .all(...ids) as { id: string; name: string }[];
315
+ const byId = new Map(rows.map((r) => [r.id, r.name]));
316
+ return ids.flatMap((id) => {
317
+ const name = byId.get(id);
318
+ return name ? [{ id, name }] : [];
319
+ });
320
+ }
@@ -0,0 +1,104 @@
1
+ // What the Needs-you strip lists: everything waiting on Saad, newest first.
2
+ // Client-safe and pure, so the strip and its tests share one rule.
3
+
4
+ import { NEED_LABEL, type SessionNeed } from "../sidebar/shelves";
5
+ import type { AskView, QuestionView } from "./ask-view";
6
+
7
+ export type NeedsYouItem =
8
+ | {
9
+ key: string;
10
+ kind: "ask";
11
+ title: string;
12
+ sub: string;
13
+ at: number;
14
+ ask: AskView;
15
+ }
16
+ | {
17
+ key: string;
18
+ kind: "question";
19
+ title: string;
20
+ sub: string;
21
+ at: number;
22
+ question: QuestionView;
23
+ }
24
+ // The orchestrator's own chat waiting on an approval or an answer.
25
+ | { key: string; kind: "chat"; title: string; sub: string; at: number }
26
+ | {
27
+ key: string;
28
+ kind: "task";
29
+ title: string;
30
+ sub: string;
31
+ at: number;
32
+ sessionId: string;
33
+ };
34
+
35
+ export interface TaskNeed {
36
+ sessionId: string;
37
+ name: string;
38
+ need: SessionNeed;
39
+ }
40
+
41
+ // SQLite datetime('now') is UTC without a zone marker.
42
+ export const sqliteMs = (t: string) =>
43
+ Date.parse(`${t.replace(" ", "T")}Z`) || 0;
44
+
45
+ // Those he can act on from the strip. A failed or unsent task isn't a
46
+ // question to him.
47
+ const TASK_NEEDS: readonly SessionNeed[] = [
48
+ "approve",
49
+ "answer",
50
+ "signin",
51
+ "input",
52
+ ];
53
+
54
+ export function needsYouItems(input: {
55
+ asks: AskView[];
56
+ questions: QuestionView[];
57
+ dismissed: readonly string[];
58
+ tasks?: TaskNeed[];
59
+ chatWaiting?: boolean;
60
+ chatKey?: string;
61
+ }): NeedsYouItem[] {
62
+ const hidden = new Set(input.dismissed);
63
+ const items: NeedsYouItem[] = [
64
+ ...input.asks.map(
65
+ (ask): NeedsYouItem => ({
66
+ key: `ask:${ask.id}`,
67
+ kind: "ask",
68
+ title: ask.title,
69
+ sub: ask.kind,
70
+ at: sqliteMs(ask.createdAt),
71
+ ask,
72
+ })
73
+ ),
74
+ ...input.questions.map(
75
+ (question): NeedsYouItem => ({
76
+ key: `question:${question.id}`,
77
+ kind: "question",
78
+ title: question.text,
79
+ sub: question.taskName ?? "Question",
80
+ at: sqliteMs(question.createdAt),
81
+ question,
82
+ })
83
+ ),
84
+ ];
85
+ if (input.chatWaiting)
86
+ items.push({
87
+ key: input.chatKey ?? "approval:chat",
88
+ kind: "chat",
89
+ title: "Waiting for your answer in chat",
90
+ sub: "Answer",
91
+ at: Date.now(),
92
+ });
93
+ for (const t of input.tasks ?? [])
94
+ if (TASK_NEEDS.includes(t.need))
95
+ items.push({
96
+ key: `task:${t.sessionId}`,
97
+ kind: "task",
98
+ title: t.name,
99
+ sub: NEED_LABEL[t.need],
100
+ at: 0,
101
+ sessionId: t.sessionId,
102
+ });
103
+ return items.filter((i) => !hidden.has(i.key)).sort((a, b) => b.at - a.at);
104
+ }
@@ -11,6 +11,7 @@ import { askBinding, openAsks, PRESENCE_KINDS } from "./asks";
11
11
  import type { OrchestratorOverview } from "./ask-view";
12
12
  import { getOrchestrator } from "./home";
13
13
  import { resolveStaleAsks } from "./ask-settle";
14
+ import { dismissedKeys, openQuestions } from "./questions";
14
15
 
15
16
  export type { AskView, OrchestratorOverview } from "./ask-view";
16
17
 
@@ -57,6 +58,8 @@ export function orchestratorOverview(): OrchestratorOverview[] {
57
58
  createdAt: a.created_at,
58
59
  };
59
60
  }),
61
+ questions: openQuestions(w.id),
62
+ dismissed: dismissedKeys(w.id),
60
63
  };
61
64
  });
62
65
  }