@saadnvd1/agent-os 0.3.524 → 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 (54) hide show
  1. package/README.md +25 -3
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
  4. package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
  5. package/app/page.tsx +8 -3
  6. package/components/Chat/ChatPanel.tsx +96 -8
  7. package/components/Chat/EventRow.tsx +2 -2
  8. package/components/Chat/EventTurns.test.ts +56 -0
  9. package/components/Chat/EventTurns.tsx +141 -0
  10. package/components/Chat/useFind.ts +5 -3
  11. package/components/Chat/useGoToItem.ts +71 -0
  12. package/components/Orchestrator/AsksList.tsx +16 -11
  13. package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
  14. package/components/Orchestrator/OrchestratorBar.tsx +27 -2
  15. package/components/Settings/SettingsDialog.tsx +2 -2
  16. package/components/Settings/SetupSection.tsx +6 -1
  17. package/components/Simulator/ScreenshotStrip.test.ts +91 -0
  18. package/components/Simulator/ScreenshotStrip.tsx +102 -0
  19. package/components/Simulator/index.ts +1 -0
  20. package/components/Tasks/TaskRow.tsx +2 -0
  21. package/components/Welcome/DevicesStep.tsx +90 -0
  22. package/components/Welcome/HealthList.tsx +69 -0
  23. package/components/Welcome/ProjectStep.tsx +2 -8
  24. package/components/Welcome/WelcomeDialog.tsx +31 -7
  25. package/data/doctor/index.ts +29 -0
  26. package/data/orchestrators/index.ts +59 -0
  27. package/data/push/topics.ts +2 -0
  28. package/data/simulator/index.test.ts +14 -0
  29. package/data/simulator/index.ts +33 -0
  30. package/data/simulator/keys.ts +5 -0
  31. package/hooks/useNeedsYou.ts +53 -0
  32. package/lib/chat/events.ts +2 -0
  33. package/lib/chat/find.ts +25 -4
  34. package/lib/chat/group.ts +50 -2
  35. package/lib/chat/turns.ts +111 -0
  36. package/lib/db/changes.ts +6 -0
  37. package/lib/db/migrations.ts +32 -0
  38. package/lib/doctor/checks.ts +222 -0
  39. package/lib/orchestrator/ask-view.ts +13 -0
  40. package/lib/orchestrator/brief.ts +5 -1
  41. package/lib/orchestrator/deliver.ts +3 -0
  42. package/lib/orchestrator/events.ts +19 -0
  43. package/lib/orchestrator/needs-you-items.ts +104 -0
  44. package/lib/orchestrator/overview.ts +3 -0
  45. package/lib/orchestrator/questions.ts +120 -0
  46. package/lib/orchestrator/serve.ts +11 -0
  47. package/lib/orchestrator/tool-names.ts +2 -0
  48. package/lib/orchestrator/tool-schemas.ts +12 -0
  49. package/lib/orchestrator/tools.ts +4 -0
  50. package/lib/welcome-url.ts +6 -1
  51. package/package.json +1 -1
  52. package/stores/needsYouFocus.ts +13 -0
  53. package/stores/welcomeUi.test.ts +15 -0
  54. package/stores/welcomeUi.ts +6 -1
@@ -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
  }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Questions the orchestrator puts to Saad in chat. Marking one open pins it
3
+ * on his Needs-you strip until he answers it (the strip's Reply) or the
4
+ * orchestrator clears it. Unlike an ask it has no Approve or Decline: it is
5
+ * just words that need an answer. Dismissing an item from the strip only
6
+ * hides it, for asks and questions alike.
7
+ */
8
+
9
+ import { db } from "../db";
10
+ import type { QuestionView } from "./ask-view";
11
+ import { workspaceTask } from "./targets";
12
+
13
+ export const MAX_OPEN_QUESTIONS = 10;
14
+
15
+ interface QuestionRow {
16
+ id: number;
17
+ text: string;
18
+ task: string | null;
19
+ task_name: string | null;
20
+ created_at: string;
21
+ }
22
+
23
+ export function openQuestions(workspaceId: string): QuestionView[] {
24
+ settleQuestions(workspaceId);
25
+ return (
26
+ db
27
+ .prepare(
28
+ `SELECT q.id, q.text, q.task, s.name AS task_name, q.created_at
29
+ FROM orchestrator_questions q LEFT JOIN sessions s ON s.id = q.task
30
+ WHERE q.workspace_id = ? AND q.status = 'open'
31
+ ORDER BY q.id DESC`
32
+ )
33
+ .all(workspaceId) as QuestionRow[]
34
+ ).map((r) => ({
35
+ id: r.id,
36
+ text: r.text,
37
+ task: r.task,
38
+ taskName: r.task_name,
39
+ createdAt: r.created_at,
40
+ }));
41
+ }
42
+
43
+ // A question about work that has finished no longer needs an answer.
44
+ function settleQuestions(workspaceId: string): void {
45
+ db.prepare(
46
+ `UPDATE orchestrator_questions
47
+ SET status = 'cleared', resolved_by = 'orchestrator', resolved_at = datetime('now')
48
+ WHERE workspace_id = ? AND status = 'open' AND task IN (
49
+ SELECT id FROM sessions WHERE task_status IN ('merged', 'dropped', 'done'))`
50
+ ).run(workspaceId);
51
+ }
52
+
53
+ // The same words while open are the same question.
54
+ export function askQuestion(
55
+ workspaceId: string,
56
+ text: string,
57
+ taskRef?: string
58
+ ): string {
59
+ const task = taskRef ? workspaceTask(workspaceId, taskRef).id : null;
60
+ const open = db
61
+ .prepare(
62
+ `SELECT id FROM orchestrator_questions
63
+ WHERE workspace_id = ? AND status = 'open' AND text = ?`
64
+ )
65
+ .get(workspaceId, text) as { id: number } | undefined;
66
+ if (open) return `Question ${open.id} is already open; left as it is.`;
67
+ const count = (
68
+ db
69
+ .prepare(
70
+ `SELECT COUNT(*) AS n FROM orchestrator_questions
71
+ WHERE workspace_id = ? AND status = 'open'`
72
+ )
73
+ .get(workspaceId) as { n: number }
74
+ ).n;
75
+ if (count >= MAX_OPEN_QUESTIONS)
76
+ return `Not opened: ${count} questions are already open. Clear the ones that no longer matter with resolve_question first.`;
77
+ const { lastInsertRowid } = db
78
+ .prepare(
79
+ `INSERT INTO orchestrator_questions (workspace_id, text, task) VALUES (?, ?, ?)`
80
+ )
81
+ .run(workspaceId, text, task);
82
+ return `Question ${lastInsertRowid} is open on Saad's Needs-you strip. Call resolve_question ${lastInsertRowid} once he has answered or it no longer matters.`;
83
+ }
84
+
85
+ export function resolveQuestion(
86
+ workspaceId: string,
87
+ id: number,
88
+ by: "saad" | "orchestrator"
89
+ ): boolean {
90
+ return (
91
+ db
92
+ .prepare(
93
+ `UPDATE orchestrator_questions
94
+ SET status = ?, resolved_by = ?, resolved_at = datetime('now')
95
+ WHERE id = ? AND workspace_id = ? AND status = 'open'`
96
+ )
97
+ .run(by === "saad" ? "answered" : "cleared", by, id, workspaceId)
98
+ .changes > 0
99
+ );
100
+ }
101
+
102
+ export function dismissItem(workspaceId: string, key: string): void {
103
+ db.prepare(
104
+ `INSERT OR IGNORE INTO needs_you_dismissed (workspace_id, key) VALUES (?, ?)`
105
+ ).run(workspaceId, key);
106
+ }
107
+
108
+ export function undismissItem(workspaceId: string, key: string): void {
109
+ db.prepare(
110
+ `DELETE FROM needs_you_dismissed WHERE workspace_id = ? AND key = ?`
111
+ ).run(workspaceId, key);
112
+ }
113
+
114
+ export function dismissedKeys(workspaceId: string): string[] {
115
+ return (
116
+ db
117
+ .prepare(`SELECT key FROM needs_you_dismissed WHERE workspace_id = ?`)
118
+ .all(workspaceId) as { key: string }[]
119
+ ).map((r) => r.key);
120
+ }
@@ -23,6 +23,7 @@ import { addNote } from "./notes";
23
23
  import { addProject } from "./add-project";
24
24
  import { addRule, removeRule } from "./rules";
25
25
  import { askSaad } from "./ask-tool";
26
+ import { askQuestion, resolveQuestion } from "./questions";
26
27
  import { listOrchestratorPeers, messageOrchestrator } from "./cross";
27
28
  import { PAUSED_REFUSAL } from "./pause";
28
29
  import { readSession } from "./read";
@@ -211,5 +212,15 @@ export async function runTool(
211
212
  return disarm(w, p(tool, raw).task);
212
213
  case "ask_saad":
213
214
  return askSaad(w, p(tool, raw));
215
+ case "question": {
216
+ const a = p(tool, raw);
217
+ return askQuestion(w, a.text, a.task);
218
+ }
219
+ case "resolve_question": {
220
+ const id = p(tool, raw).id;
221
+ return resolveQuestion(w, id, "orchestrator")
222
+ ? `Question ${id} cleared.`
223
+ : `No open question ${id}.`;
224
+ }
214
225
  }
215
226
  }
@@ -29,6 +29,8 @@ export const TOOL_NAMES = {
29
29
  done: name("done"),
30
30
  add_project: name("add_project"),
31
31
  note: name("note"),
32
+ question: name("question"),
33
+ resolve_question: name("resolve_question"),
32
34
  rule_add: name("rule_add"),
33
35
  rule_remove: name("rule_remove"),
34
36
  review: name("review"),
@@ -239,6 +239,18 @@ export const TOOL_SHAPES = {
239
239
  .describe("A new project's name (default: the folder's)"),
240
240
  },
241
241
  note: { text: z.string().trim().min(1).max(2000) },
242
+ question: {
243
+ text: z
244
+ .string()
245
+ .trim()
246
+ .min(1)
247
+ .max(300)
248
+ .describe("The question as you asked it in chat, one or two sentences"),
249
+ task: ref("The task it is about, when it names one").optional(),
250
+ },
251
+ resolve_question: {
252
+ id: z.number().int().positive().describe("The question's id"),
253
+ },
242
254
  rule_add: {
243
255
  text: z
244
256
  .string()
@@ -101,6 +101,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
101
101
  arm: "Arm a task's PR to merge: AgentOS merges it, through the same gates as sign_off, the moment they all pass at its head, and tells you with one event. A gate failure comes as one event and it stays armed for the fix; one that goes to Saad disarms it. Survives restarts. Use it instead of calling sign_off until it passes.",
102
102
  disarm:
103
103
  "Disarm a task armed to merge: it merges only through sign_off again.",
104
+ question:
105
+ "Mark a question you asked Saad in chat as open: it stays pinned on his Needs-you strip until he answers or you clear it. Use it for anything you need from him that isn't an ask_saad item. The same text while open is the same question.",
106
+ resolve_question:
107
+ "Clear an open question once Saad has answered it in chat, or it no longer matters.",
104
108
  ask_saad:
105
109
  "Park an item on Saad's asks list (a decision, or anything crossing a hard line) and carry on; it never waits. One open ask per title. His answer reaches you as an event. To ask whether to merge a PR the gates refuse, give task and sha (its head): his approval lets sign_off merge it once, at that commit.",
106
110
  };
@@ -3,7 +3,12 @@
3
3
 
4
4
  export const WELCOME_PARAM = "welcome";
5
5
 
6
- export const WELCOME_STEPS = ["agents", "project", "finish"] as const;
6
+ export const WELCOME_STEPS = [
7
+ "agents",
8
+ "project",
9
+ "devices",
10
+ "finish",
11
+ ] as const;
7
12
 
8
13
  export type WelcomeStep = (typeof WELCOME_STEPS)[number];
9
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.524",
3
+ "version": "0.3.527",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,13 @@
1
+ import { proxy } from "valtio";
2
+
3
+ // "Show this ask", raised by the Needs-you strip and handled by the asks list
4
+ // above the chat, which opens itself and scrolls the card into view.
5
+ export const needsYouFocus = proxy<{
6
+ ask: { id: number; at: number } | null;
7
+ }>({ ask: null });
8
+
9
+ export const needsYouFocusActions = {
10
+ ask: (id: number) => {
11
+ needsYouFocus.ask = { id, at: Date.now() };
12
+ },
13
+ };
@@ -0,0 +1,15 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { welcomeUi, welcomeUiActions } from "./welcomeUi";
3
+
4
+ describe("choosing a project in the welcome", () => {
5
+ beforeEach(() => {
6
+ welcomeUiActions.go("project");
7
+ welcomeUiActions.setProject(null);
8
+ });
9
+
10
+ it("remembers it and lands on the devices step, not the finish", () => {
11
+ welcomeUiActions.chooseProject("p1");
12
+ expect(welcomeUi.projectId).toBe("p1");
13
+ expect(welcomeUi.step).toBe("devices");
14
+ });
15
+ });