@saadnvd1/agent-os 0.3.524 → 0.3.528

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 (72) hide show
  1. package/README.md +52 -19
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/tasks/route.test.ts +22 -0
  4. package/app/api/tasks/route.ts +14 -2
  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/agent-options.ts +1 -0
  35. package/lib/agents/probe.ts +21 -0
  36. package/lib/agents/setup-commands.ts +4 -0
  37. package/lib/chat/capabilities.ts +7 -1
  38. package/lib/chat/drivers/copilot-approvals.ts +99 -0
  39. package/lib/chat/drivers/copilot-mapper.ts +203 -0
  40. package/lib/chat/drivers/copilot-rpc.ts +126 -0
  41. package/lib/chat/drivers/copilot.ts +174 -0
  42. package/lib/chat/drivers/index.ts +2 -0
  43. package/lib/chat/events.ts +2 -0
  44. package/lib/chat/find.ts +25 -4
  45. package/lib/chat/group.ts +50 -2
  46. package/lib/chat/turns.ts +111 -0
  47. package/lib/db/changes.ts +6 -0
  48. package/lib/db/migrations.ts +43 -0
  49. package/lib/doctor/checks.ts +222 -0
  50. package/lib/orchestrator/ask-view.ts +13 -0
  51. package/lib/orchestrator/brief.ts +5 -1
  52. package/lib/orchestrator/deliver.ts +3 -0
  53. package/lib/orchestrator/events.ts +19 -0
  54. package/lib/orchestrator/needs-you-items.ts +104 -0
  55. package/lib/orchestrator/overview.ts +3 -0
  56. package/lib/orchestrator/questions.ts +120 -0
  57. package/lib/orchestrator/serve.ts +11 -0
  58. package/lib/orchestrator/tool-names.ts +2 -0
  59. package/lib/orchestrator/tool-schemas.ts +12 -0
  60. package/lib/orchestrator/tools.ts +4 -0
  61. package/lib/providers/registry.ts +19 -0
  62. package/lib/providers/resume-ids.ts +30 -0
  63. package/lib/tasks/code-review.ts +5 -2
  64. package/lib/tasks/index.ts +45 -10
  65. package/lib/tasks/queue.ts +7 -2
  66. package/lib/welcome-url.ts +6 -1
  67. package/lib/worktree-hooks.ts +1 -1
  68. package/package.json +1 -1
  69. package/scripts/setup.sh +1 -1
  70. package/stores/needsYouFocus.ts +13 -0
  71. package/stores/welcomeUi.test.ts +15 -0
  72. package/stores/welcomeUi.ts +6 -1
@@ -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
  };
@@ -15,6 +15,7 @@ export const PROVIDER_IDS = [
15
15
  "amp",
16
16
  "pi",
17
17
  "omp",
18
+ "copilot",
18
19
  "shell",
19
20
  ] as const;
20
21
 
@@ -57,6 +58,11 @@ export interface ProviderDefinition {
57
58
  // The model value that means "whatever the agent itself defaults to".
58
59
  export const AGENT_DEFAULT_MODEL = "default";
59
60
 
61
+ // A model name handed to another agent's CLI as an argument: printable, no
62
+ // leading dash (it would read as a flag), bounded.
63
+ export const isAgentModelName = (v: unknown): v is string =>
64
+ typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,99}$/.test(v);
65
+
60
66
  /**
61
67
  * Provider Registry
62
68
  * All supported agent providers with their configurations
@@ -192,6 +198,19 @@ export const PROVIDERS: ProviderDefinition[] = [
192
198
  modelFlag: "--model",
193
199
  initialPromptFlag: "", // Positional argument
194
200
  },
201
+ {
202
+ id: "copilot",
203
+ name: "GitHub Copilot",
204
+ description: "GitHub's Copilot CLI",
205
+ cli: "copilot",
206
+ configDir: "~/.copilot",
207
+ autoApproveFlag: "--allow-all",
208
+ supportsResume: true,
209
+ supportsFork: false,
210
+ resumeArgs: (id) => [`--resume=${id}`],
211
+ modelFlag: "--model",
212
+ initialPromptFlag: "-i",
213
+ },
195
214
  {
196
215
  id: "shell",
197
216
  name: "Terminal",
@@ -154,6 +154,35 @@ export function opencodeResumeId(cwd: string, now = Date.now()): string | null {
154
154
  }
155
155
  }
156
156
 
157
+ // ~/.copilot/session-state/<id>/workspace.yaml, whose `cwd:` line names the
158
+ // folder the conversation ran in.
159
+ export function copilotResumeId(cwd: string, now = Date.now()): string | null {
160
+ const root = path.join(
161
+ process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"),
162
+ "session-state"
163
+ );
164
+ let ids: string[];
165
+ try {
166
+ ids = fs.readdirSync(root);
167
+ } catch {
168
+ return null;
169
+ }
170
+ let best: { id: string; mtimeMs: number } | null = null;
171
+ for (const id of ids) {
172
+ if (!/^[0-9a-f-]{36}$/.test(id)) continue;
173
+ const file = path.join(root, id, "workspace.yaml");
174
+ try {
175
+ const { mtimeMs } = fs.statSync(file);
176
+ if (!recent(mtimeMs, now) || (best && best.mtimeMs >= mtimeMs)) continue;
177
+ const folder = fs.readFileSync(file, "utf8").match(/^cwd: (.+)$/m)?.[1];
178
+ if (folder?.trim() === cwd) best = { id, mtimeMs };
179
+ } catch {
180
+ // Gone since the listing.
181
+ }
182
+ }
183
+ return best?.id ?? null;
184
+ }
185
+
157
186
  const FINDERS: Partial<
158
187
  Record<ProviderId, (cwd: string, now?: number) => string | null>
159
188
  > = {
@@ -161,6 +190,7 @@ const FINDERS: Partial<
161
190
  codex: codexResumeId,
162
191
  pi: piResumeId,
163
192
  opencode: opencodeResumeId,
193
+ copilot: copilotResumeId,
164
194
  };
165
195
 
166
196
  export function findResumeId(agent: ProviderId, cwd: string): string | null {
@@ -19,15 +19,18 @@ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
19
19
  // prose that quotes the footer ("the 'Generated with Claude Code' footer")
20
20
  // isn't one.
21
21
  export const ATTRIBUTION =
22
- /^(?:co-authored-by:.*(?:claude|anthropic)|claude-session:|🤖 generated with)|(?:^|[^\p{L}\p{N}"'`])generated (?:with|by) \[?claude code/iu;
22
+ /^(?:co-authored-by:.*(?:claude|anthropic|copilot)|claude-session:|🤖 generated with)|(?:^|[^\p{L}\p{N}"'`])generated (?:with|by) \[?claude code/iu;
23
23
  export type AttributionKind =
24
24
  | "a Co-Authored-By trailer naming Claude"
25
+ | "a Co-Authored-By trailer naming GitHub Copilot"
25
26
  | "a Claude-Session link"
26
27
  | 'a "Generated with" AI footer';
27
28
  function attributionKind(line: string): AttributionKind | undefined {
28
29
  if (!ATTRIBUTION.test(line)) return;
29
30
  if (/^co-authored-by:/i.test(line))
30
- return "a Co-Authored-By trailer naming Claude";
31
+ return /^co-authored-by:.*(?:claude|anthropic)/i.test(line)
32
+ ? "a Co-Authored-By trailer naming Claude"
33
+ : "a Co-Authored-By trailer naming GitHub Copilot";
31
34
  if (/^claude-session:/i.test(line)) return "a Claude-Session link";
32
35
  return 'a "Generated with" AI footer';
33
36
  }
@@ -28,7 +28,10 @@ import {
28
28
  isBranchName,
29
29
  slugify,
30
30
  } from "../git";
31
- import { getProvider } from "../providers";
31
+ import { getProvider, type AgentType } from "../providers";
32
+ import { agentLabel } from "../agent-options";
33
+ import { supportsChat } from "../chat/capabilities";
34
+ import { AGENT_DEFAULT_MODEL } from "../providers/registry";
32
35
  import { statusDetector } from "../status-detector";
33
36
  import { buildTaskBrief, type StackedOn } from "./brief";
34
37
  import {
@@ -140,6 +143,11 @@ export async function createTask(opts: {
140
143
  base?: { branch: string; tip: string; stack: StackedOn };
141
144
  // Cut from this branch instead of the default one.
142
145
  baseBranch?: string;
146
+ // The agent CLI that does the work (Claude Code unless a person picks
147
+ // another). Another agent runs as a chat on this machine, on its own
148
+ // default model unless `model` names one, without plan-first or role
149
+ // models: those are Claude Code's.
150
+ agent?: AgentType;
143
151
  // Called once the session row exists, before the agent launches.
144
152
  onCreated?: (sessionId: string) => void;
145
153
  // Run it on this machine (another one's own AgentOS); default: the project's.
@@ -201,6 +209,17 @@ async function startTask(
201
209
  if (!project || project.is_uncategorized) throw new Error("Pick a project");
202
210
  const hostId = opts.hostId ?? project.host_id;
203
211
  const view = opts.view ?? DEFAULT_START_VIEW;
212
+ const agent = opts.agent ?? "claude";
213
+ if (agent !== "claude") {
214
+ if (!supportsChat(agent))
215
+ throw new Error(`${agentLabel(agent)} can't run a task yet`);
216
+ if (view !== "chat")
217
+ throw new Error(`${agentLabel(agent)} runs a task as a chat`);
218
+ if (opts.plan || opts.planOnly)
219
+ throw new Error(`${agentLabel(agent)} doesn't plan first yet`);
220
+ if (isRemoteHost(hostId))
221
+ throw new Error(`${agentLabel(agent)} tasks run on this machine for now`);
222
+ }
204
223
  if (isRemoteHost(hostId)) {
205
224
  if (opts.planOnly)
206
225
  throw new Error("A plan-only task runs on this machine for now");
@@ -228,13 +247,16 @@ async function startTask(
228
247
  );
229
248
  if (opts.planOnly && view !== "chat")
230
249
  throw new Error("A plan-only task needs a chat, not a terminal");
231
- const routing = taskRouting(
232
- project.workspace_id,
233
- view,
234
- opts.model,
235
- opts.plan,
236
- opts.planOnly
237
- );
250
+ const routing =
251
+ agent === "claude"
252
+ ? taskRouting(
253
+ project.workspace_id,
254
+ view,
255
+ opts.model,
256
+ opts.plan,
257
+ opts.planOnly
258
+ )
259
+ : otherAgentRouting(opts.model);
238
260
  const projectPath = expandHome(project.working_directory);
239
261
  const naming = await nameFor(prompt, projectPath, opts.name);
240
262
  const feature = `${slugify(naming.name.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
@@ -253,7 +275,7 @@ async function startTask(
253
275
  startPoint: opts.base?.tip,
254
276
  });
255
277
 
256
- const provider = getProvider("claude");
278
+ const provider = getProvider(agent);
257
279
  const model = routing.first.model;
258
280
  const tmuxName = `${provider.id}-${id}`;
259
281
 
@@ -268,7 +290,7 @@ async function startTask(
268
290
  model,
269
291
  null,
270
292
  "sessions",
271
- "claude",
293
+ agent,
272
294
  1,
273
295
  project.id,
274
296
  "local"
@@ -453,6 +475,19 @@ export async function listTasks(): Promise<TaskView[]> {
453
475
  );
454
476
  }
455
477
 
478
+ // Another agent's task: its own model (or its default), never planned first.
479
+ function otherAgentRouting(model: unknown) {
480
+ return {
481
+ override: null,
482
+ plan: false,
483
+ planOnly: false,
484
+ first: {
485
+ model: typeof model === "string" && model ? model : AGENT_DEFAULT_MODEL,
486
+ provider: { id: ANTHROPIC },
487
+ },
488
+ };
489
+ }
490
+
456
491
  // The model a task starts on, and whether it plans first: only as a chat,
457
492
  // where the handoff can follow the end of its plan turn, and only when
458
493
  // planning and implementing are on different models. Both roles resolve
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { ModelOverride } from "../models/resolve";
15
+ import type { AgentType } from "../providers";
15
16
  import { randomUUID } from "crypto";
16
17
  import { db, type Session } from "../db";
17
18
  import { getProject } from "../projects";
@@ -51,6 +52,7 @@ export interface QueueRow {
51
52
  plan_only: number | null;
52
53
  started_by: string | null;
53
54
  view: "chat" | "terminal" | null;
55
+ agent_type: string | null;
54
56
  base_branch: string | null;
55
57
  host_id: string | null;
56
58
  // A task (or queued task) it waits on, by id.
@@ -226,6 +228,7 @@ export interface QueueRequest {
226
228
  planOnly?: boolean;
227
229
  startedBy?: string | null;
228
230
  view?: "chat" | "terminal";
231
+ agent?: AgentType;
229
232
  baseBranch?: string;
230
233
  hostId?: string;
231
234
  // A task's id or name, or "any" for whichever running task finishes first.
@@ -279,9 +282,9 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
279
282
  if (!req.prompt.trim()) throw new Error("Describe the task");
280
283
  const id = randomUUID();
281
284
  db.prepare(
282
- `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, base_branch,
285
+ `INSERT INTO task_queue (id, project_id, prompt, name, model, model_overrides, plan, plan_reason, plan_only, started_by, view, agent_type, base_branch,
283
286
  host_id, after_task, after_any, origin_workspace_id, position)
284
- VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @base_branch,
287
+ VALUES (@id, @project_id, @prompt, @name, @model, @model_overrides, @plan, @plan_reason, @plan_only, @started_by, @view, @agent_type, @base_branch,
285
288
  @host_id, @after_task, @after_any, @origin,
286
289
  (SELECT COALESCE(MAX(position), 0) + 1 FROM task_queue))`
287
290
  ).run({
@@ -299,6 +302,7 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
299
302
  plan_only: req.planOnly ? 1 : null,
300
303
  started_by: req.startedBy ?? null,
301
304
  view: req.view ?? null,
305
+ agent_type: req.agent ?? null,
302
306
  base_branch: req.baseBranch || null,
303
307
  host_id: req.hostId || null,
304
308
  after_task: afterTask,
@@ -478,6 +482,7 @@ async function startRow(
478
482
  planOnly: !!r.plan_only,
479
483
  startedBy: r.started_by,
480
484
  view: r.view ?? undefined,
485
+ agent: (r.agent_type as AgentType | null) ?? undefined,
481
486
  baseBranch: r.base_branch ?? undefined,
482
487
  hostId: r.host_id ?? undefined,
483
488
  reclaim: true,
@@ -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
 
@@ -28,7 +28,7 @@ const git = (cwd: string, args: string[]) =>
28
28
  // same rule as lib/tasks/code-review.ts's ATTRIBUTION for PR bodies; a test
29
29
  // keeps the two agreeing.
30
30
  export const ATTRIBUTION_ERE =
31
- "^[[:space:]]*(co-authored-by:.*(claude|anthropic)|claude-session:|🤖 generated with)|(^|[^[:alnum:]\"'`])generated (with|by) \\[?claude code";
31
+ "^[[:space:]]*(co-authored-by:.*(claude|anthropic|copilot)|claude-session:|🤖 generated with)|(^|[^[:alnum:]\"'`])generated (with|by) \\[?claude code";
32
32
 
33
33
  // The hooks husky installs; a project's own hook of any of these keeps
34
34
  // running. Not post-index-change or reference-transaction: those run on
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.528",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/scripts/setup.sh CHANGED
@@ -29,7 +29,7 @@ echo "tmux: $(tmux -V)"
29
29
 
30
30
  # Check AI coding CLI
31
31
  AI_CLI_FOUND=""
32
- for cli in claude codex opencode kilo gemini aider cursor-agent amp pi omp; do
32
+ for cli in claude codex opencode kilo gemini aider cursor-agent amp pi omp copilot; do
33
33
  if command -v "$cli" &> /dev/null; then
34
34
  AI_CLI_FOUND="$AI_CLI_FOUND $cli"
35
35
  fi
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { proxy } from "valtio";
2
- import type { WelcomeStep } from "@/lib/welcome-url";
2
+ import { WELCOME_STEPS, type WelcomeStep } from "@/lib/welcome-url";
3
3
 
4
4
  // The first-run welcome: whether it is on show, the step, and the project
5
5
  // the user made (or picked) to start in.
@@ -20,6 +20,11 @@ export const welcomeUiActions = {
20
20
  setProject: (projectId: string | null) => {
21
21
  welcomeUi.projectId = projectId;
22
22
  },
23
+ // A project picked or added: remember it and move to the step after "project".
24
+ chooseProject: (projectId: string) => {
25
+ welcomeUi.projectId = projectId;
26
+ welcomeUi.step = WELCOME_STEPS[WELCOME_STEPS.indexOf("project") + 1];
27
+ },
23
28
  close: () => {
24
29
  welcomeUi.open = false;
25
30
  },