@saadnvd1/agent-os 0.3.477 → 0.3.481
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 +26 -1
- package/app/api/schedules/route.ts +1 -0
- package/app/api/sessions/[id]/route.test.ts +66 -0
- package/app/api/sessions/[id]/route.ts +10 -7
- package/app/api/sessions/[id]/summarize/route.ts +2 -0
- package/app/api/tmux/kill-all/route.ts +2 -1
- package/components/Orchestrator/OrchestratorBar.tsx +12 -0
- package/components/Schedules/ScheduleForm.tsx +94 -12
- package/components/Schedules/SchedulesPanel.tsx +60 -14
- package/components/SessionList/OrchestratorPinRow.tsx +12 -6
- package/components/SessionList/QuickChangeRow.tsx +58 -0
- package/components/SessionList/SessionRow.tsx +4 -0
- package/components/SessionList/SidebarHeader.tsx +1 -1
- package/components/SessionList/useSchedulesGlance.ts +6 -2
- package/components/Tasks/TaskRow.tsx +2 -17
- package/components/Tasks/task-state-labels.ts +18 -0
- package/lib/away/sweep.ts +2 -0
- package/lib/chat/drivers/demo.ts +2 -1
- package/lib/chat/hold.ts +6 -2
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/test-touch.ts +58 -12
- package/lib/chat/worker/client.ts +2 -3
- package/lib/db/migrations.ts +31 -0
- package/lib/db/types.ts +3 -0
- package/lib/done/index.ts +2 -8
- package/lib/orchestration.ts +4 -5
- package/lib/orchestrator/act.ts +3 -6
- package/lib/orchestrator/brief.ts +8 -3
- package/lib/orchestrator/conditions.ts +2 -1
- package/lib/orchestrator/describe.ts +1 -1
- package/lib/orchestrator/facts.ts +13 -0
- package/lib/orchestrator/git-guard.ts +201 -0
- package/lib/orchestrator/pr-wake.ts +9 -1
- package/lib/orchestrator/quick-store.ts +18 -0
- package/lib/orchestrator/quick.ts +307 -0
- package/lib/orchestrator/rebase.ts +11 -1
- package/lib/orchestrator/reminders.ts +91 -0
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +26 -0
- package/lib/orchestrator/signoff.ts +28 -6
- package/lib/orchestrator/task-state.ts +2 -0
- package/lib/orchestrator/tool-names.ts +12 -2
- package/lib/orchestrator/tool-schemas.ts +43 -0
- package/lib/orchestrator/tools.ts +10 -0
- package/lib/orchestrator/write-guard.ts +80 -3
- package/lib/schedules/badge.ts +11 -1
- package/lib/schedules/cron.ts +1 -1
- package/lib/schedules/index.ts +67 -6
- package/lib/schedules/run.ts +23 -6
- package/lib/schedules/scheduler.ts +12 -2
- package/lib/schedules/start.ts +11 -5
- package/lib/schedules/store.ts +133 -16
- package/lib/schedules/when.ts +166 -0
- package/lib/sessions/end.ts +15 -0
- package/lib/sidebar/shelves.ts +19 -1
- package/lib/tasks/finish.ts +2 -8
- package/lib/tasks/index.ts +18 -6
- package/lib/tasks/move-guard.ts +2 -0
- package/lib/tasks/move.ts +2 -6
- package/lib/tasks/queue.ts +3 -2
- package/lib/tasks/start.ts +2 -4
- package/lib/tmux/end.ts +33 -0
- package/lib/tmux/orphans.ts +146 -0
- package/package.json +1 -1
- package/server.ts +9 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell commands an orchestrator may run, read before the shell sees
|
|
3
|
+
* them. Its permissions allow `aos` commands and `git`; this makes sure each
|
|
4
|
+
* is one plain command (no `;`, `&&`, pipes, redirects, substitutions or
|
|
5
|
+
* globs a shell would act on), and that a git command works only in one of
|
|
6
|
+
* its own quick changes' worktrees (`-C <path>`), does nothing that runs
|
|
7
|
+
* other code or writes outside it, and never pushes: quick_pr pushes, and
|
|
8
|
+
* never to the default branch.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// What git may do in a quick worktree: look, stage, undo. Committing is
|
|
12
|
+
// quick_pr's alone: it runs with hooks off, where a shell commit would run
|
|
13
|
+
// whatever the project (or the orchestrator's own edit) put in its hooks.
|
|
14
|
+
const GIT_ALLOWED = new Set([
|
|
15
|
+
"status",
|
|
16
|
+
"diff",
|
|
17
|
+
"log",
|
|
18
|
+
"show",
|
|
19
|
+
"add",
|
|
20
|
+
"rm",
|
|
21
|
+
"restore",
|
|
22
|
+
"reset",
|
|
23
|
+
"rev-parse",
|
|
24
|
+
"ls-files",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Git config can run programs (core.fsmonitor, diff.external, filter and
|
|
28
|
+
// textconv drivers), so git runs only at the worktree's own root, where it
|
|
29
|
+
// finds the real git dir, never in a folder holding a repository the
|
|
30
|
+
// orchestrator wrote (the write guard refuses HEAD, .git/ and .gitattributes
|
|
31
|
+
// inside quick worktrees). A .gitattributes already committed can name a
|
|
32
|
+
// driver, but a driver's command lives in config, which only a repository
|
|
33
|
+
// the orchestrator can't write defines.
|
|
34
|
+
export const GIT_LAYOUT_FILE =
|
|
35
|
+
/(^|\/)(HEAD|\.gitattributes)$|(^|\/)\.git(\/|$)/i;
|
|
36
|
+
|
|
37
|
+
// `aos` commands carry free text (notify, send): only what a shell would
|
|
38
|
+
// chain, redirect or substitute is refused, not ? ! # * or a plain $.
|
|
39
|
+
export function aosRefusal(command: string): string | null {
|
|
40
|
+
const bad =
|
|
41
|
+
"use one plain aos command: no ;, &&, pipes, redirects, backticks or $( ) outside single quotes";
|
|
42
|
+
let q: string | null = null;
|
|
43
|
+
for (let i = 0; i < command.length; i++) {
|
|
44
|
+
const c = command[i];
|
|
45
|
+
if (q === "'") {
|
|
46
|
+
if (c === "'") q = null;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === "\\") {
|
|
50
|
+
// zsh deletes a backslash-newline before it parses, joining $ to what
|
|
51
|
+
// follows; and its ${(e)...} evaluates what an escaped \$ or \` left.
|
|
52
|
+
const n = command[i + 1] ?? "";
|
|
53
|
+
if (/[\n\r]/.test(n) || (q === '"' && /[$`]/.test(n)))
|
|
54
|
+
return `Refused: ${bad}.`;
|
|
55
|
+
i++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (c === "`") return `Refused: ${bad}.`;
|
|
59
|
+
// After a $ only a plain variable name (or $?, $#, $@, $*, $-, a digit)
|
|
60
|
+
// or a literal $ may follow: ${..}, $[..], $'..' and the like evaluate
|
|
61
|
+
// or quote differently in the shell than here.
|
|
62
|
+
if (c === "$") {
|
|
63
|
+
const n = command[i + 1] ?? "";
|
|
64
|
+
if (!/^[\sA-Za-z0-9_?#@*-]?$/.test(n) && !(q === '"' && n === '"'))
|
|
65
|
+
return `Refused: ${bad}.`;
|
|
66
|
+
}
|
|
67
|
+
if (q === '"') {
|
|
68
|
+
if (c === '"') q = null;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (c === "'" || c === '"') q = c;
|
|
72
|
+
else if (/[;&|<>()\n\r]/.test(c)) return `Refused: ${bad}.`;
|
|
73
|
+
}
|
|
74
|
+
return q ? "Refused: an unclosed quote." : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Options that write a file somewhere else, read a command to run, or read
|
|
78
|
+
// outside the repository. Git accepts any unique prefix of a long option
|
|
79
|
+
// (--ext-di, --textc), so this refuses the prefixes, not the spellings.
|
|
80
|
+
const GIT_REFUSED_ARG = /^--(ou|ex|te|no-i)/;
|
|
81
|
+
|
|
82
|
+
// A shell word, with its quotes taken off, or why the command isn't one
|
|
83
|
+
// plain command.
|
|
84
|
+
export function shellWords(command: string): string[] | string {
|
|
85
|
+
const words: string[] = [];
|
|
86
|
+
let word: string | null = null;
|
|
87
|
+
const plain =
|
|
88
|
+
"use one plain command: no ;, &&, ||, pipes, redirects, $, backticks, globs or comments outside quotes (quote anything with those)";
|
|
89
|
+
for (let i = 0; i < command.length; i++) {
|
|
90
|
+
const c = command[i];
|
|
91
|
+
if (c === " " || c === "\t") {
|
|
92
|
+
if (word !== null) words.push(word);
|
|
93
|
+
word = null;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === "'") {
|
|
97
|
+
const end = command.indexOf("'", i + 1);
|
|
98
|
+
if (end < 0) return "an unclosed ' quote";
|
|
99
|
+
word = (word ?? "") + command.slice(i + 1, end);
|
|
100
|
+
i = end;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === '"') {
|
|
104
|
+
let text = "";
|
|
105
|
+
let j = i + 1;
|
|
106
|
+
for (; j < command.length && command[j] !== '"'; j++) {
|
|
107
|
+
const d = command[j];
|
|
108
|
+
if (d === "$" || d === "`") return plain;
|
|
109
|
+
if (d === "\\" && j + 1 < command.length) {
|
|
110
|
+
const n = command[++j];
|
|
111
|
+
text += /["\\$`\n]/.test(n) ? n : `\\${n}`;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
text += d;
|
|
115
|
+
}
|
|
116
|
+
if (j >= command.length) return 'an unclosed " quote';
|
|
117
|
+
word = (word ?? "") + text;
|
|
118
|
+
i = j;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (c === "\\") {
|
|
122
|
+
if (i + 1 >= command.length || command[i + 1] === "\n") return plain;
|
|
123
|
+
word = (word ?? "") + command[++i];
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (/[;&|<>()$`\n\r*?[\]{}!#]/.test(c)) return plain;
|
|
127
|
+
// zsh expands a word starting with = or ~name; only ~ and ~/ are read
|
|
128
|
+
// (as the home folder), by the caller.
|
|
129
|
+
if (word === null && c === "=") return plain;
|
|
130
|
+
if (word === null && c === "~" && !/^~($|[\s/])/.test(command.slice(i)))
|
|
131
|
+
return plain;
|
|
132
|
+
word = (word ?? "") + c;
|
|
133
|
+
}
|
|
134
|
+
if (word !== null) words.push(word);
|
|
135
|
+
return words;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type GitCommand =
|
|
139
|
+
| { refusal: string }
|
|
140
|
+
| { refusal: null; dir: string | null; sub: string | null };
|
|
141
|
+
|
|
142
|
+
// A shell command read for the guard: why it's refused outright, or, for a
|
|
143
|
+
// git command, the folder it runs in (from -C) to check against its quick
|
|
144
|
+
// worktrees. `dir` and `sub` are null for a command that isn't git.
|
|
145
|
+
export function readCommand(command: unknown): GitCommand {
|
|
146
|
+
if (typeof command !== "string" || !command.trim())
|
|
147
|
+
return { refusal: "No command to check." };
|
|
148
|
+
if (/^aos(\s|$)/.test(command.trim())) {
|
|
149
|
+
const refusal = aosRefusal(command.trim());
|
|
150
|
+
return refusal ? { refusal } : { refusal: null, dir: null, sub: null };
|
|
151
|
+
}
|
|
152
|
+
const words = shellWords(command.trim());
|
|
153
|
+
if (typeof words === "string") return { refusal: `Refused: ${words}.` };
|
|
154
|
+
if (words[0] !== "git") {
|
|
155
|
+
// `VAR=value git …` sets an environment variable the shell would act on
|
|
156
|
+
// (GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE), which can redirect git
|
|
157
|
+
// outside its quick worktree.
|
|
158
|
+
if (words[0].includes("="))
|
|
159
|
+
return {
|
|
160
|
+
refusal:
|
|
161
|
+
"Refused: no inline VAR=value before a command; environment variables can point git outside your worktree.",
|
|
162
|
+
};
|
|
163
|
+
return { refusal: null, dir: null, sub: null };
|
|
164
|
+
}
|
|
165
|
+
const usage =
|
|
166
|
+
"git runs only in one of your quick changes' worktrees, as `git -C <its path> <command>`";
|
|
167
|
+
let dir: string | null = null;
|
|
168
|
+
let i = 1;
|
|
169
|
+
for (; i < words.length; i++) {
|
|
170
|
+
const w = words[i];
|
|
171
|
+
if (w === "-C") {
|
|
172
|
+
if (dir !== null || i + 1 >= words.length)
|
|
173
|
+
return { refusal: `Refused: ${usage}, with one -C.` };
|
|
174
|
+
dir = words[++i];
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (w === "--no-pager" || w === "--no-optional-locks") continue;
|
|
178
|
+
if (w.startsWith("-"))
|
|
179
|
+
return {
|
|
180
|
+
refusal: `Refused: git's ${w} option isn't allowed here; ${usage}.`,
|
|
181
|
+
};
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
if (dir === null) return { refusal: `Refused: ${usage}.` };
|
|
185
|
+
const sub = words[i] ?? null;
|
|
186
|
+
if (sub === "push")
|
|
187
|
+
return {
|
|
188
|
+
refusal:
|
|
189
|
+
"Refused: quick_pr pushes a quick change's branch and opens its PR; nothing pushes from the shell, and nothing ever pushes to the default branch.",
|
|
190
|
+
};
|
|
191
|
+
if (!sub || !GIT_ALLOWED.has(sub))
|
|
192
|
+
return {
|
|
193
|
+
refusal: `Refused: git ${sub ?? ""} isn't allowed here. In a quick worktree git may only ${[...GIT_ALLOWED].join(", ")}; quick_pr commits, pushes and opens the PR.`,
|
|
194
|
+
};
|
|
195
|
+
const bad = words.slice(i + 1).find((w) => GIT_REFUSED_ARG.test(w));
|
|
196
|
+
if (bad)
|
|
197
|
+
return {
|
|
198
|
+
refusal: `Refused: git's ${bad} option writes or runs something outside the worktree.`,
|
|
199
|
+
};
|
|
200
|
+
return { refusal: null, dir, sub };
|
|
201
|
+
}
|
|
@@ -146,7 +146,15 @@ export async function wakeTasks(
|
|
|
146
146
|
const pr = f.task?.pr;
|
|
147
147
|
const task = pr?.state === "OPEN" && pr.head ? sessionRow(f.id) : null;
|
|
148
148
|
const repo = pr && repoOfPR(pr.url);
|
|
149
|
-
|
|
149
|
+
// A quick change has no agent to wake: its PR's events reach the
|
|
150
|
+
// orchestrator that made it, as any task's do.
|
|
151
|
+
if (
|
|
152
|
+
!task ||
|
|
153
|
+
task.task_status !== "running" ||
|
|
154
|
+
task.quick_of ||
|
|
155
|
+
!repo ||
|
|
156
|
+
!pr?.head
|
|
157
|
+
) {
|
|
150
158
|
handled.delete(f.id);
|
|
151
159
|
continue;
|
|
152
160
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The worktrees of an orchestrator's open quick changes, which its write
|
|
2
|
+
// guard lets it edit (quick.ts). Only running ones: a finished or dropped
|
|
3
|
+
// quick change's folder is gone, or about to be.
|
|
4
|
+
|
|
5
|
+
import { db } from "../db";
|
|
6
|
+
import { expandHome } from "../tasks/session";
|
|
7
|
+
|
|
8
|
+
export function quickWorktrees(orchestratorId: string): string[] {
|
|
9
|
+
return (
|
|
10
|
+
db
|
|
11
|
+
.prepare(
|
|
12
|
+
`SELECT worktree_path AS dir FROM sessions
|
|
13
|
+
WHERE quick_of = ? AND task_status = 'running'
|
|
14
|
+
AND worktree_path IS NOT NULL AND worktree_path != ''`
|
|
15
|
+
)
|
|
16
|
+
.all(orchestratorId) as { dir: string }[]
|
|
17
|
+
).map((r) => expandHome(r.dir));
|
|
18
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quick changes: an edit too small for a task (a typo, a config value, a
|
|
3
|
+
* one-file fix) made by the orchestrator itself. quick_change cuts a
|
|
4
|
+
* worktree and branch from the project's default branch, as a task row
|
|
5
|
+
* with no agent (`quick_of` names the orchestrator that owns it); its write
|
|
6
|
+
* guard lets it edit there and nowhere else in a project. quick_pr commits,
|
|
7
|
+
* pushes that branch (never the default one) and opens the PR, which then
|
|
8
|
+
* goes through the same gates as any task's. With no task agent to run
|
|
9
|
+
* /do-code-review, the code-review gate of a PR AgentOS recorded as opened
|
|
10
|
+
* by quick_pr is the independent review of its exact head, never anything
|
|
11
|
+
* its body says. Signed off, dropped or done, it's cleaned up like a task.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "crypto";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import { db, queries, type Session } from "../db";
|
|
17
|
+
import { getDefaultBranch, slugify } from "../git";
|
|
18
|
+
import { currentBranch, git, revParse } from "../git-exec";
|
|
19
|
+
import { isRemoteHost } from "../hosts";
|
|
20
|
+
import { createWorktree, deleteWorktree } from "../worktrees";
|
|
21
|
+
import { PROTECTED_BRANCHES } from "../tasks/finish";
|
|
22
|
+
import { run } from "../tasks/gh";
|
|
23
|
+
import { ATTRIBUTION, renderedLines } from "../tasks/code-review";
|
|
24
|
+
import { expandHome, forgetPR, prFor, projectPathFor } from "../tasks/session";
|
|
25
|
+
import { inBackground } from "../lumifyhub/task-cards";
|
|
26
|
+
import type { CheckRow } from "./checks";
|
|
27
|
+
import { getOrchestrator } from "./home";
|
|
28
|
+
import { addNote } from "./notes";
|
|
29
|
+
import { quickWorktrees } from "./quick-store";
|
|
30
|
+
import { findWorkspaceSession } from "./read";
|
|
31
|
+
import { workspaceProject } from "./targets";
|
|
32
|
+
|
|
33
|
+
// Open quick changes one orchestrator may hold at once.
|
|
34
|
+
export const MAX_OPEN_QUICK = 5;
|
|
35
|
+
|
|
36
|
+
// Git with no hooks: a hook is a script in the project (or one it calls,
|
|
37
|
+
// like node_modules/.bin), which the orchestrator could have edited, so a
|
|
38
|
+
// commit or push it makes runs none.
|
|
39
|
+
const NO_HOOKS = ["-c", "core.hooksPath=/dev/null"];
|
|
40
|
+
|
|
41
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
42
|
+
|
|
43
|
+
export const isQuick = (s: Pick<Session, "quick_of">) => !!s.quick_of;
|
|
44
|
+
|
|
45
|
+
function ownerOf(workspaceId: string): Session {
|
|
46
|
+
const o = getOrchestrator(workspaceId);
|
|
47
|
+
if (!o) throw new Error("This workspace has no orchestrator yet");
|
|
48
|
+
return o;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function quickChange(
|
|
52
|
+
workspaceId: string,
|
|
53
|
+
projectRef: string,
|
|
54
|
+
name: string
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const owner = ownerOf(workspaceId);
|
|
57
|
+
const project = workspaceProject(workspaceId, projectRef);
|
|
58
|
+
if (isRemoteHost(project.host_id))
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${project.name} runs on another machine; quick changes are made on this one only`
|
|
61
|
+
);
|
|
62
|
+
const open = quickWorktrees(owner.id);
|
|
63
|
+
if (open.length >= MAX_OPEN_QUICK)
|
|
64
|
+
throw new Error(
|
|
65
|
+
`You have ${open.length} quick changes open (${MAX_OPEN_QUICK} at most): finish one with quick_pr, or drop it, first`
|
|
66
|
+
);
|
|
67
|
+
const label = name.trim();
|
|
68
|
+
const id = randomUUID();
|
|
69
|
+
const projectPath = expandHome(project.working_directory);
|
|
70
|
+
const feature = `quick-${slugify(label.split(/\s+/).slice(0, 6).join(" "))}-${id.slice(0, 4)}`;
|
|
71
|
+
const baseBranch = await getDefaultBranch(projectPath);
|
|
72
|
+
const wt = await createWorktree({
|
|
73
|
+
projectPath,
|
|
74
|
+
featureName: feature,
|
|
75
|
+
baseBranch,
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
db.transaction(() => {
|
|
79
|
+
queries
|
|
80
|
+
.createSession(db)
|
|
81
|
+
.run(
|
|
82
|
+
id,
|
|
83
|
+
label,
|
|
84
|
+
`quick-${id}`,
|
|
85
|
+
wt.worktreePath,
|
|
86
|
+
null,
|
|
87
|
+
project.default_model,
|
|
88
|
+
null,
|
|
89
|
+
"sessions",
|
|
90
|
+
"claude",
|
|
91
|
+
0,
|
|
92
|
+
project.id,
|
|
93
|
+
"local"
|
|
94
|
+
);
|
|
95
|
+
queries
|
|
96
|
+
.updateSessionWorktree(db)
|
|
97
|
+
.run(wt.worktreePath, wt.branchName, baseBranch, null, id);
|
|
98
|
+
db.prepare(
|
|
99
|
+
`UPDATE sessions SET task_prompt = ?, task_status = 'running', name_source = 'user', view = 'chat', quick_of = ? WHERE id = ?`
|
|
100
|
+
).run(`Quick change: ${label}`, owner.id, id);
|
|
101
|
+
})();
|
|
102
|
+
} catch (e) {
|
|
103
|
+
await deleteWorktree(wt.worktreePath, projectPath, true).catch(() => {});
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
addNote(
|
|
107
|
+
workspaceId,
|
|
108
|
+
`Quick change "${label}" in ${project.name} on ${wt.branchName}.`
|
|
109
|
+
);
|
|
110
|
+
return `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Edit files there with Write and Edit; git there only as \`git -C ${wt.worktreePath} <status|diff|add|...>\`. Then quick_pr commits, pushes and opens its PR.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A quick change of this workspace's orchestrator, by name, id or PR.
|
|
114
|
+
export function workspaceQuick(workspaceId: string, ref: string): Session {
|
|
115
|
+
const owner = ownerOf(workspaceId);
|
|
116
|
+
const s = findWorkspaceSession(workspaceId, ref);
|
|
117
|
+
if (s.quick_of !== owner.id)
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${s.name} isn't one of your quick changes; a task's own agent opens its PR`
|
|
120
|
+
);
|
|
121
|
+
if (s.task_status !== "running")
|
|
122
|
+
throw new Error(`${s.name} is already ${s.task_status}`);
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Why a branch may never be pushed by quick_pr, or null.
|
|
127
|
+
export function pushRefusal(
|
|
128
|
+
branch: string | null,
|
|
129
|
+
defaultBranch: string,
|
|
130
|
+
base: string | null
|
|
131
|
+
): string | null {
|
|
132
|
+
if (!branch) return "it has no branch";
|
|
133
|
+
if (
|
|
134
|
+
branch === defaultBranch ||
|
|
135
|
+
branch === base ||
|
|
136
|
+
PROTECTED_BRANCHES.has(branch)
|
|
137
|
+
)
|
|
138
|
+
return `its branch is ${branch}, the default branch; quick_pr never pushes to it`;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const attributed = (text: string) =>
|
|
143
|
+
renderedLines(text).some((l) => ATTRIBUTION.test(l.trimStart()));
|
|
144
|
+
|
|
145
|
+
export async function quickPR(
|
|
146
|
+
workspaceId: string,
|
|
147
|
+
ref: string,
|
|
148
|
+
title: string,
|
|
149
|
+
body: string
|
|
150
|
+
): Promise<{ id: string; text: string }> {
|
|
151
|
+
const s = workspaceQuick(workspaceId, ref);
|
|
152
|
+
const repo = projectPathFor(s);
|
|
153
|
+
const wt = s.worktree_path ? expandHome(s.worktree_path) : null;
|
|
154
|
+
if (!repo || !wt || !fs.existsSync(wt))
|
|
155
|
+
throw new Error(`${s.name}'s worktree is gone; drop it`);
|
|
156
|
+
if (attributed(title) || attributed(body))
|
|
157
|
+
throw new Error("Take the AI attribution out of the title and body");
|
|
158
|
+
const branch = s.branch_name;
|
|
159
|
+
const refusal = pushRefusal(
|
|
160
|
+
branch,
|
|
161
|
+
await getDefaultBranch(repo),
|
|
162
|
+
s.base_branch
|
|
163
|
+
);
|
|
164
|
+
if (refusal) throw new Error(`Refused: ${refusal}.`);
|
|
165
|
+
const on = await currentBranch(wt);
|
|
166
|
+
if (on !== branch)
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Refused: ${s.name}'s worktree is on ${on || "a detached HEAD"}, not its branch ${branch}`
|
|
169
|
+
);
|
|
170
|
+
await git(wt, [...NO_HOOKS, "add", "-A"]);
|
|
171
|
+
const staged = await git(wt, ["diff", "--cached", "--name-only"]);
|
|
172
|
+
if (staged.trim())
|
|
173
|
+
await git(wt, [...NO_HOOKS, "commit", "-q", "-m", title.trim()]);
|
|
174
|
+
const ahead = (
|
|
175
|
+
await git(wt, [
|
|
176
|
+
"rev-list",
|
|
177
|
+
"--count",
|
|
178
|
+
`refs/remotes/origin/${s.base_branch}..HEAD`,
|
|
179
|
+
]).catch(() => git(wt, ["rev-list", "--count", `${s.base_branch}..HEAD`]))
|
|
180
|
+
).trim();
|
|
181
|
+
if (ahead === "0")
|
|
182
|
+
throw new Error(`${s.name} has nothing to commit: edit its worktree first`);
|
|
183
|
+
// The branch by its full name on both sides, so nothing but it moves.
|
|
184
|
+
await git(
|
|
185
|
+
wt,
|
|
186
|
+
[
|
|
187
|
+
...NO_HOOKS,
|
|
188
|
+
"push",
|
|
189
|
+
"--quiet",
|
|
190
|
+
"-u",
|
|
191
|
+
"origin",
|
|
192
|
+
`refs/heads/${branch}:refs/heads/${branch}`,
|
|
193
|
+
],
|
|
194
|
+
{ timeout: 60_000 }
|
|
195
|
+
);
|
|
196
|
+
const head = (await revParse(wt, "HEAD")).trim();
|
|
197
|
+
const goal = `${title.trim()}\n\n${body.trim()}`;
|
|
198
|
+
db.prepare(`UPDATE sessions SET task_prompt = ? WHERE id = ?`).run(
|
|
199
|
+
goal,
|
|
200
|
+
s.id
|
|
201
|
+
);
|
|
202
|
+
const existing = await prFor(s, true);
|
|
203
|
+
if (existing?.state === "OPEN") {
|
|
204
|
+
forgetPR(s.id);
|
|
205
|
+
return {
|
|
206
|
+
id: s.id,
|
|
207
|
+
text: `Pushed ${short(head)} to ${s.name}'s PR #${existing.number} (${existing.url}).`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const out = await run(
|
|
211
|
+
"gh",
|
|
212
|
+
[
|
|
213
|
+
"pr",
|
|
214
|
+
"create",
|
|
215
|
+
"--base",
|
|
216
|
+
s.base_branch!,
|
|
217
|
+
"--head",
|
|
218
|
+
branch!,
|
|
219
|
+
"--title",
|
|
220
|
+
title.trim(),
|
|
221
|
+
"--body",
|
|
222
|
+
body.trim(),
|
|
223
|
+
],
|
|
224
|
+
wt,
|
|
225
|
+
60_000
|
|
226
|
+
);
|
|
227
|
+
const m = /https:\/\/\S+\/pull\/(\d+)/.exec(out);
|
|
228
|
+
if (m) queries.updateSessionPR(db).run(m[0], Number(m[1]), "open", s.id);
|
|
229
|
+
forgetPR(s.id);
|
|
230
|
+
addNote(
|
|
231
|
+
workspaceId,
|
|
232
|
+
`Quick change "${s.name}" opened ${m ? `PR #${m[1]}` : "its PR"} at ${short(head)}.`
|
|
233
|
+
);
|
|
234
|
+
return {
|
|
235
|
+
id: s.id,
|
|
236
|
+
text: `Opened ${m ? `PR #${m[1]} (${m[0]})` : "the PR"} for quick change "${s.name}" at ${short(head)}. It merges only through the gates, like a task's: CI, the independent review of its exact head (which is also its code-review gate, since no task agent ran /do-code-review), scope, then sign_off or arm.`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const QUICK_FIX =
|
|
241
|
+
"for a quick change, the independent review of its exact head is its code review";
|
|
242
|
+
|
|
243
|
+
// The code-review gate of a PR quick_pr opened (the row's quick_of, never
|
|
244
|
+
// its body): the independent review of this exact head. Until it passes the
|
|
245
|
+
// quick change waits, before any other gate is judged or counted: a CI that
|
|
246
|
+
// wants a Code review section in the body fails until AgentOS writes one
|
|
247
|
+
// after the review (noteQuickReview). A blocking review counts once, as the
|
|
248
|
+
// review gate's failure, when its verdict arrives.
|
|
249
|
+
export function quickCodeReview(
|
|
250
|
+
sha: string,
|
|
251
|
+
review: CheckRow | null,
|
|
252
|
+
rebased = false
|
|
253
|
+
): { refusal: null; wait: string | null } {
|
|
254
|
+
if (review?.sha === sha && review.status === "pass")
|
|
255
|
+
return { refusal: null, wait: null };
|
|
256
|
+
const why =
|
|
257
|
+
review?.sha !== sha
|
|
258
|
+
? rebased
|
|
259
|
+
? `a rebase moved the branch to ${short(sha)}, so it needs a fresh independent review: call review`
|
|
260
|
+
: `no independent review of ${short(sha)} yet: call review`
|
|
261
|
+
: review.status === "running"
|
|
262
|
+
? `the review of ${short(sha)} is still running`
|
|
263
|
+
: review.status === "block"
|
|
264
|
+
? `the review of ${short(sha)} has blocking findings`
|
|
265
|
+
: `the review of ${short(sha)} couldn't run: call review with fresh`;
|
|
266
|
+
return { refusal: null, wait: `${why} (${QUICK_FIX})` };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A PR body section for a repository whose CI wants one (this repo's
|
|
270
|
+
// code-review workflow): written by AgentOS after the independent review
|
|
271
|
+
// of the head passed, never by the orchestrator. The gate doesn't read it.
|
|
272
|
+
const MARK = "<!-- agentos:quick-review -->";
|
|
273
|
+
|
|
274
|
+
export function withQuickReview(body: string, sha: string): string {
|
|
275
|
+
const at = body.indexOf(MARK);
|
|
276
|
+
const kept = (at < 0 ? body : body.slice(0, at)).trimEnd();
|
|
277
|
+
return `${kept}\n\n${MARK}\n## Code review\nReviewed: ${sha}\nAgents: AgentOS's independent review of this exact commit (a quick change, with no task agent to run /do-code-review)\nFixed: none\nDeferred: none\n`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function noteQuickReview(reviewed: Session, sha: string): void {
|
|
281
|
+
if (!isQuick(reviewed)) return;
|
|
282
|
+
const task = queries.getSession(db).get(reviewed.id) as Session | undefined;
|
|
283
|
+
if (!task || task.task_status !== "running" || !task.pr_number) return;
|
|
284
|
+
const repo = projectPathFor(task);
|
|
285
|
+
if (!repo) return;
|
|
286
|
+
inBackground(`code review section on quick change ${task.id}`, async () => {
|
|
287
|
+
const pr = await prFor(task, true);
|
|
288
|
+
if (!pr || pr.state !== "OPEN" || pr.head !== sha) return;
|
|
289
|
+
const body = await run(
|
|
290
|
+
"gh",
|
|
291
|
+
["pr", "view", String(pr.number), "--json", "body", "--jq", ".body"],
|
|
292
|
+
repo
|
|
293
|
+
);
|
|
294
|
+
await run(
|
|
295
|
+
"gh",
|
|
296
|
+
[
|
|
297
|
+
"pr",
|
|
298
|
+
"edit",
|
|
299
|
+
String(pr.number),
|
|
300
|
+
"--body",
|
|
301
|
+
withQuickReview(body.trimEnd(), sha),
|
|
302
|
+
],
|
|
303
|
+
repo
|
|
304
|
+
);
|
|
305
|
+
forgetPR(task.id);
|
|
306
|
+
});
|
|
307
|
+
}
|
|
@@ -241,7 +241,7 @@ async function rebaseOne(
|
|
|
241
241
|
(await deps.idle(task)) &&
|
|
242
242
|
!(await local()) &&
|
|
243
243
|
(await catchUp(git, wt, task.branch_name!, pr.head, to));
|
|
244
|
-
if (!follows)
|
|
244
|
+
if (!follows && !task.quick_of)
|
|
245
245
|
await deps.send(
|
|
246
246
|
task.id,
|
|
247
247
|
`AgentOS rebased your branch ${task.branch_name} onto ${branch} on origin (now ${short(to)}), because PR #${pr.number} conflicted with it. Your worktree is still on the old head: before changing anything, run \`git fetch origin && git reset --keep origin/${task.branch_name}\` (commit your work first). Its review carries over when its own patch didn't change.`
|
|
@@ -381,6 +381,16 @@ async function askToRebase(
|
|
|
381
381
|
pr.head,
|
|
382
382
|
task.id
|
|
383
383
|
);
|
|
384
|
+
// A quick change has no agent to ask: it's the orchestrator's to redo.
|
|
385
|
+
if (task.quick_of) {
|
|
386
|
+
queueEvent(
|
|
387
|
+
workspaceId,
|
|
388
|
+
`conflict:${task.id}:${pr.head}`,
|
|
389
|
+
task.id,
|
|
390
|
+
`quick change ${task.name}: PR #${pr.number} conflicts with ${base} and can't be rebased automatically (${why.slice(0, 300)}). Drop it and make the change again in a new quick change.`
|
|
391
|
+
);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
384
394
|
queueEvent(
|
|
385
395
|
workspaceId,
|
|
386
396
|
`conflict:${task.id}:${pr.head}`,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remind_me, reminders and cancel_reminder: the orchestrator's one-off
|
|
3
|
+
* schedules. A reminder is a schedule that messages the workspace's own
|
|
4
|
+
* orchestrator once, so it arrives as a scheduled prompt and never as an
|
|
5
|
+
* approval. Every call is scoped to the caller's workspace, and only reminders
|
|
6
|
+
* an orchestrator made can be cancelled here: Saad's are his to change.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
archiveSchedule,
|
|
11
|
+
createSchedule,
|
|
12
|
+
getSessionRow,
|
|
13
|
+
listSchedules,
|
|
14
|
+
type Schedule,
|
|
15
|
+
} from "../schedules/store";
|
|
16
|
+
import { reminderName } from "../schedules";
|
|
17
|
+
import { DEFAULT_TIMEZONE, formatRunTime } from "../schedules/cron";
|
|
18
|
+
import { parseWhen, relativeTime } from "../schedules/when";
|
|
19
|
+
import { getOrchestrator } from "./home";
|
|
20
|
+
import { addNote } from "./notes";
|
|
21
|
+
|
|
22
|
+
const SHOW_DONE = 3;
|
|
23
|
+
|
|
24
|
+
const line = (s: Schedule) =>
|
|
25
|
+
`${s.id.slice(0, 8)} ${s.name}: ${formatRunTime(s.run_at ?? 0, s.timezone)} (${relativeTime(s.run_at ?? 0)})`;
|
|
26
|
+
|
|
27
|
+
const madeByOrchestrator = (s: Schedule) =>
|
|
28
|
+
getSessionRow(s.created_by_session_id ?? "")?.role === "orchestrator";
|
|
29
|
+
|
|
30
|
+
export function remindMe(
|
|
31
|
+
w: string,
|
|
32
|
+
at: string,
|
|
33
|
+
prompt: string,
|
|
34
|
+
name?: string
|
|
35
|
+
): string {
|
|
36
|
+
const me = getOrchestrator(w);
|
|
37
|
+
if (!me) throw new Error("This workspace has no orchestrator");
|
|
38
|
+
const runAt = parseWhen(at, DEFAULT_TIMEZONE);
|
|
39
|
+
const schedule = createSchedule({
|
|
40
|
+
workspaceId: w,
|
|
41
|
+
name: name?.trim() || reminderName(w, runAt, DEFAULT_TIMEZONE),
|
|
42
|
+
at: runAt,
|
|
43
|
+
timezone: DEFAULT_TIMEZONE,
|
|
44
|
+
prompt,
|
|
45
|
+
kind: "orchestrator",
|
|
46
|
+
createdBySessionId: me.id,
|
|
47
|
+
});
|
|
48
|
+
addNote(
|
|
49
|
+
w,
|
|
50
|
+
`Reminder set: ${schedule.name}, ${line(schedule).split(": ")[1]}`
|
|
51
|
+
);
|
|
52
|
+
return `Reminder ${schedule.id.slice(0, 8)} set for ${formatRunTime(runAt, DEFAULT_TIMEZONE)} (${relativeTime(runAt)}). It reaches you as a scheduled prompt, not an approval.`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listReminders(w: string): string {
|
|
56
|
+
const all = listSchedules(w).filter((s) => s.run_at != null);
|
|
57
|
+
const upcoming = all
|
|
58
|
+
.filter((s) => s.enabled && !s.done_at)
|
|
59
|
+
.sort((a, b) => (a.run_at ?? 0) - (b.run_at ?? 0));
|
|
60
|
+
const done = all
|
|
61
|
+
.filter((s) => s.done_at)
|
|
62
|
+
.sort((a, b) => (b.done_at ?? 0) - (a.done_at ?? 0))
|
|
63
|
+
.slice(0, SHOW_DONE);
|
|
64
|
+
if (!upcoming.length && !done.length) return "No reminders.";
|
|
65
|
+
return [
|
|
66
|
+
upcoming.length
|
|
67
|
+
? `Upcoming:\n${upcoming.map(line).join("\n")}`
|
|
68
|
+
: "None upcoming.",
|
|
69
|
+
done.length
|
|
70
|
+
? `Recently done:\n${done.map((s) => `${s.id.slice(0, 8)} ${s.name}`).join("\n")}`
|
|
71
|
+
: "",
|
|
72
|
+
]
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join("\n\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function cancelReminder(w: string, id: string): string {
|
|
78
|
+
const ref = id.trim();
|
|
79
|
+
const matches = listSchedules(w).filter(
|
|
80
|
+
(s) =>
|
|
81
|
+
s.run_at != null &&
|
|
82
|
+
!s.done_at &&
|
|
83
|
+
(s.id === ref || (ref.length >= 6 && s.id.startsWith(ref)))
|
|
84
|
+
);
|
|
85
|
+
// Another workspace's, a done one and one Saad made all read the same.
|
|
86
|
+
if (matches.length !== 1 || !madeByOrchestrator(matches[0]))
|
|
87
|
+
throw new Error(`No reminder "${ref}" in this workspace`);
|
|
88
|
+
archiveSchedule(matches[0].id);
|
|
89
|
+
addNote(w, `Reminder cancelled: ${matches[0].name}`);
|
|
90
|
+
return `Reminder ${matches[0].id.slice(0, 8)} cancelled.`;
|
|
91
|
+
}
|
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
checkRunTotals,
|
|
44
44
|
describeCheckRuns,
|
|
45
45
|
} from "../usage/check-runs";
|
|
46
|
+
import { noteQuickReview } from "./quick";
|
|
46
47
|
import { workspaceTask } from "./targets";
|
|
47
48
|
import { untrusted } from "./untrusted";
|
|
48
49
|
|
|
@@ -272,6 +273,8 @@ export async function reviewAt(
|
|
|
272
273
|
})
|
|
273
274
|
)
|
|
274
275
|
.then((done) => {
|
|
276
|
+
if (done.status === "pass" && target.task)
|
|
277
|
+
noteQuickReview(target.task, sha);
|
|
275
278
|
const said =
|
|
276
279
|
done.status === "pass"
|
|
277
280
|
? "passed"
|