@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.
- package/README.md +52 -19
- package/app/api/doctor/route.ts +8 -0
- package/app/api/tasks/route.test.ts +22 -0
- package/app/api/tasks/route.ts +14 -2
- 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/agent-options.ts +1 -0
- package/lib/agents/probe.ts +21 -0
- package/lib/agents/setup-commands.ts +4 -0
- package/lib/chat/capabilities.ts +7 -1
- package/lib/chat/drivers/copilot-approvals.ts +99 -0
- package/lib/chat/drivers/copilot-mapper.ts +203 -0
- package/lib/chat/drivers/copilot-rpc.ts +126 -0
- package/lib/chat/drivers/copilot.ts +174 -0
- package/lib/chat/drivers/index.ts +2 -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 +43 -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/providers/registry.ts +19 -0
- package/lib/providers/resume-ids.ts +30 -0
- package/lib/tasks/code-review.ts +5 -2
- package/lib/tasks/index.ts +45 -10
- package/lib/tasks/queue.ts +7 -2
- package/lib/welcome-url.ts +6 -1
- package/lib/worktree-hooks.ts +1 -1
- package/package.json +1 -1
- package/scripts/setup.sh +1 -1
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- 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 {
|
package/lib/tasks/code-review.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/tasks/index.ts
CHANGED
|
@@ -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 =
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
package/lib/tasks/queue.ts
CHANGED
|
@@ -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,
|
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/lib/worktree-hooks.ts
CHANGED
|
@@ -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
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
|
+
});
|
package/stores/welcomeUi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { proxy } from "valtio";
|
|
2
|
-
import type
|
|
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
|
},
|