@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.
- package/README.md +25 -3
- package/app/api/doctor/route.ts +8 -0
- package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
- package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
- package/app/page.tsx +8 -3
- package/components/Chat/ChatPanel.tsx +96 -8
- package/components/Chat/EventRow.tsx +2 -2
- package/components/Chat/EventTurns.test.ts +56 -0
- package/components/Chat/EventTurns.tsx +141 -0
- package/components/Chat/useFind.ts +5 -3
- package/components/Chat/useGoToItem.ts +71 -0
- package/components/Orchestrator/AsksList.tsx +16 -11
- package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
- package/components/Orchestrator/OrchestratorBar.tsx +27 -2
- package/components/Settings/SettingsDialog.tsx +2 -2
- package/components/Settings/SetupSection.tsx +6 -1
- package/components/Simulator/ScreenshotStrip.test.ts +91 -0
- package/components/Simulator/ScreenshotStrip.tsx +102 -0
- package/components/Simulator/index.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Welcome/DevicesStep.tsx +90 -0
- package/components/Welcome/HealthList.tsx +69 -0
- package/components/Welcome/ProjectStep.tsx +2 -8
- package/components/Welcome/WelcomeDialog.tsx +31 -7
- package/data/doctor/index.ts +29 -0
- package/data/orchestrators/index.ts +59 -0
- package/data/push/topics.ts +2 -0
- package/data/simulator/index.test.ts +14 -0
- package/data/simulator/index.ts +33 -0
- package/data/simulator/keys.ts +5 -0
- package/hooks/useNeedsYou.ts +53 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/find.ts +25 -4
- package/lib/chat/group.ts +50 -2
- package/lib/chat/turns.ts +111 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations.ts +32 -0
- package/lib/doctor/checks.ts +222 -0
- package/lib/orchestrator/ask-view.ts +13 -0
- package/lib/orchestrator/brief.ts +5 -1
- package/lib/orchestrator/deliver.ts +3 -0
- package/lib/orchestrator/events.ts +19 -0
- package/lib/orchestrator/needs-you-items.ts +104 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/orchestrator/questions.ts +120 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +12 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/welcome-url.ts +6 -1
- package/package.json +1 -1
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- 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
|
|
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
|
};
|
package/lib/welcome-url.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
export const WELCOME_PARAM = "welcome";
|
|
5
5
|
|
|
6
|
-
export const WELCOME_STEPS = [
|
|
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
|
@@ -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
|
+
});
|