@saadnvd1/agent-os 0.3.664 → 0.3.672
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/app/api/audits/[kind]/[id]/route.test.ts +2 -2
- package/app/api/mcp-auth/callback/route.test.ts +42 -0
- package/app/api/mcp-auth/callback/route.ts +56 -0
- package/app/api/projects/[id]/route.ts +26 -0
- package/app/api/projects/init/route.test.ts +2 -4
- package/app/api/sessions/[id]/mcp-auth/route.test.ts +86 -0
- package/app/api/sessions/[id]/mcp-auth/route.ts +58 -0
- package/app/api/shares/route.test.ts +3 -4
- package/app/api/workspaces/[id]/route.ts +9 -1
- package/app/api/worktree-sweep/route.test.ts +21 -0
- package/app/api/worktree-sweep/route.ts +24 -4
- package/components/Chat/ChatPanel.tsx +6 -3
- package/components/Chat/McpServers.tsx +61 -24
- package/components/Chat/McpSignIn.tsx +122 -0
- package/components/Chat/session.ts +8 -0
- package/components/Health/SweepRuns.tsx +1 -0
- package/components/Projects/ProjectSettingsDialog.tsx +7 -0
- package/components/Projects/ProjectTierField.tsx +39 -0
- package/components/Workspaces/ProjectTiersForm.tsx +33 -0
- package/components/Workspaces/WorkspaceSettings.tsx +8 -0
- package/data/chat/mcpAuth.ts +42 -0
- package/data/projects/queries.ts +4 -0
- package/data/workspaces/index.ts +6 -0
- package/lib/chat/driver.ts +24 -2
- package/lib/chat/drivers/claude-approvals.ts +7 -0
- package/lib/chat/drivers/claude-mcp-auth.ts +82 -0
- package/lib/chat/drivers/claude.ts +7 -0
- package/lib/chat/events.ts +12 -0
- package/lib/chat/mcp-auth.ts +193 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +18 -0
- package/lib/chat/worker/client.ts +35 -1
- package/lib/chat/worker/host.ts +9 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/mcp-auth.ts +119 -0
- package/lib/chat/worker/protocol.ts +19 -0
- package/lib/db/migrations/20261018T1730_mcp_sign_ins.ts +22 -0
- package/lib/db/migrations/20261018T2100_orch_full_access.ts +16 -0
- package/lib/db/migrations/20261018T2130_project_tier.ts +38 -0
- package/lib/db/types.ts +7 -0
- package/lib/done/testing.ts +2 -2
- package/lib/env-setup.ts +8 -1
- package/lib/health/boots.ts +2 -8
- package/lib/orchestrator/armed.test-helpers.ts +7 -2
- package/lib/orchestrator/armed.ts +14 -1
- package/lib/orchestrator/brief.ts +23 -11
- package/lib/orchestrator/external-gates.ts +62 -1
- package/lib/orchestrator/fast-lane-state.ts +69 -0
- package/lib/orchestrator/fast-lane.ts +96 -0
- package/lib/orchestrator/full-access.ts +21 -0
- package/lib/orchestrator/gates.ts +8 -2
- package/lib/orchestrator/project-tier-tool.ts +33 -0
- package/lib/orchestrator/quick.ts +47 -4
- package/lib/orchestrator/review.ts +3 -0
- package/lib/orchestrator/serve.ts +17 -3
- package/lib/orchestrator/shell-guard.ts +23 -1
- package/lib/orchestrator/signoff.test-helpers.ts +6 -2
- package/lib/orchestrator/signoff.ts +36 -19
- package/lib/orchestrator/sweep-arm.ts +180 -0
- package/lib/orchestrator/tool-names.ts +5 -1
- package/lib/orchestrator/tool-schemas.ts +41 -2
- package/lib/orchestrator/tools.ts +8 -2
- package/lib/orchestrator/watcher.ts +5 -0
- package/lib/orchestrator/write-guard.ts +34 -7
- package/lib/project-config/brief.ts +3 -1
- package/lib/project-config/database.ts +6 -0
- package/lib/project-config/index.ts +108 -21
- package/lib/project-config/schema.ts +10 -4
- package/lib/project-config/session.ts +9 -6
- package/lib/project-tier/getProjectTier.ts +18 -0
- package/lib/project-tier/index.ts +3 -0
- package/lib/project-tier/setProjectTier.ts +14 -0
- package/lib/project-tier/types.ts +3 -0
- package/lib/security/net.ts +7 -4
- package/lib/start-options.ts +8 -1
- package/lib/tasks/finish.ts +2 -1
- package/lib/tasks/index.ts +10 -5
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/state.ts +7 -1
- package/lib/test-temp.ts +30 -0
- package/lib/workspaces.ts +6 -2
- package/lib/worktree-sweep/caches.ts +6 -0
- package/lib/worktree-sweep/extras.ts +16 -8
- package/lib/worktree-sweep/report.ts +1 -0
- package/lib/worktree-sweep/settings.ts +2 -2
- package/lib/worktree-sweep/testTemp.ts +113 -0
- package/lib/worktree-sweep/types.ts +2 -0
- package/package.json +1 -1
- package/scripts/autodeploy.test.ts +2 -2
- package/scripts/check-bundle.test.ts +2 -2
- package/scripts/hooks/banned-terms.test.ts +3 -2
- package/scripts/hooks/staged-secrets.test.ts +2 -2
- package/scripts/lib/build-swap.test.ts +2 -3
- package/scripts/lib/wsl.test.ts +2 -3
- package/scripts/update-run.test.ts +2 -2
|
@@ -132,7 +132,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
132
132
|
start_session:
|
|
133
133
|
'Start an agent session in a project with a prompt, for work that isn\'t a PR task. It runs on the implementation model unless you give model (one "provider/model"; Opus only when the work needs deep reasoning), as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.',
|
|
134
134
|
quick_change:
|
|
135
|
-
"Make a
|
|
135
|
+
"Make a change yourself (without full access, a small low-risk one: a typo, a config value, a one-file fix): cuts a worktree and branch from the project's default branch and returns its path. Its dependencies install in the background (a note says when). You may write there (Write, Edit) and, with Orchestrator full access, run any git there; without it, only `git -C <path> ...` status, diff, add, rm, mv, restore, reset, log, show, and nowhere else in a project. Without full access, anything needing real work, tests or an investigation is a task (start_task).",
|
|
136
136
|
quick_pr:
|
|
137
137
|
"Commit everything in a quick change's worktree, push its branch (never the default branch) and open its PR, or push to the PR it already has. The PR goes through the same gates as a task's; with no task agent to run /do-code-review, its code-review gate is the independent review of its exact head, which starts now.",
|
|
138
138
|
stack:
|
|
@@ -145,6 +145,10 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
145
145
|
"A task whose plan split into pieces: each piece, its repo, what it waits on and its state. With merge or drop (position numbers) it edits the pieces still waiting: a merged piece's slice goes to the piece before it, a dropped one is left out and anything waiting on it is held. It can't add pieces or change reasons.",
|
|
146
146
|
drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
|
|
147
147
|
move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
|
|
148
|
+
sweep_dry_run:
|
|
149
|
+
"Start a dry run of the disk clean-up on this machine or a linked one: it lists what the full clean-up would remove and removes nothing. Takes a few minutes; sweep_arm reads it.",
|
|
150
|
+
sweep_arm:
|
|
151
|
+
"Turn the full disk clean-up (test leftovers, stale task databases, spares, temp folders, build caches) on or off for this machine or a linked one. Only for when Saad has said to turn it on: put what he said in reason. Turning it on is refused unless that machine's latest dry run finished in the last hour (run sweep_dry_run first, and read its totals). Off is always allowed. Logged in the decision log with the dry run's totals.",
|
|
148
152
|
set_priority:
|
|
149
153
|
"Change the priority of a queued task (it re-ranks the line at once) or a running one (it only matters if it is paused and waits to resume). urgent starts first even on a busy machine; background starts last.",
|
|
150
154
|
start_now:
|
|
@@ -161,6 +165,8 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
161
165
|
"Bring a dropped, done or archived session back: the same session and conversation, its worktree (with its uncommitted files and plan) back from what was saved or from its branch, its status as it was (a plan-only task waits for approval again, one with a PR is idle), and a PR a drop closed reopened. Archived sessions just unarchive. Held by Pause and braked like a start. Refused when its conversation is gone, its PR was merged, its project was removed or its branch is in use. Give a reason; it goes in the decision log.",
|
|
162
166
|
add_project:
|
|
163
167
|
"Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
|
|
168
|
+
project_tier:
|
|
169
|
+
"Read or set a project's tier. production: every merge gate applies (CI, independent review, scope). fast: a task's PR merges as soon as it opens, with no CI wait or review. Without a project, lists this workspace's; with a project, reads it; with a tier too, sets it, only when Saad says to.",
|
|
164
170
|
note: "Add a line to this workspace's decision log; it shows in your chat.",
|
|
165
171
|
rule_add:
|
|
166
172
|
'Keep a standing rule for this workspace, in your brief on every start from now on ("never merge #111", "briefs must not name third-party products"). It restricts or informs; it can never approve anything.',
|
|
@@ -183,7 +189,7 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
183
189
|
review:
|
|
184
190
|
"Start an independent read-only review of a PR at its exact head commit, or read the stored verdict for that commit. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL, even one no task owns.",
|
|
185
191
|
sign_off:
|
|
186
|
-
"Merge a PR, with its project's merge method, through the gates: CI green and settled, a passing review of that commit, nothing blocked, in scope, stack parent merged. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL (an external PR). Refuses with the failing gate, or a repository outside the workspace.",
|
|
192
|
+
"Merge a PR, with its project's merge method, through the gates: CI green and settled, a passing review of that commit, nothing blocked, in scope, stack parent merged. Takes a task, or any open PR in a workspace repository by #N, owner/repo#N or URL (an external PR). Refuses with the failing gate, or a repository outside the workspace. With direct true (and a reason) it merges now at the PR's head without the gates; only with Orchestrator full access, and not while paused or while the task waits on Saad.",
|
|
187
193
|
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.",
|
|
188
194
|
disarm:
|
|
189
195
|
"Disarm a task armed to merge: it merges only through sign_off again.",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* every 15s while anything in the workspace is working, every 60s when not.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { autoArmFast, followFastMerges } from "./fast-lane";
|
|
8
9
|
import { chatState, chatStateNow, sendChat } from "../chat/runner";
|
|
9
10
|
import type { ChatState } from "../chat/events";
|
|
10
11
|
import { getWorkspace } from "../workspaces";
|
|
@@ -96,7 +97,11 @@ function mergePipeline(workspaceId: string, facts: SessionFacts[]): void {
|
|
|
96
97
|
console.error(`Judging asked commits in ${workspaceId}:`, error)
|
|
97
98
|
);
|
|
98
99
|
await rebaseConflicting(workspaceId, facts);
|
|
100
|
+
await autoArmFast(workspaceId, facts);
|
|
99
101
|
await mergeArmed(workspaceId, facts);
|
|
102
|
+
await followFastMerges(workspaceId).catch((error) =>
|
|
103
|
+
console.error(`Following fast merges in ${workspaceId}:`, error)
|
|
104
|
+
);
|
|
100
105
|
})()
|
|
101
106
|
.catch((error) =>
|
|
102
107
|
console.error(`Merge pipeline for ${workspaceId}:`, error)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Where an orchestrator may write
|
|
2
|
+
* Where an orchestrator may write (unless its workspace has Orchestrator full
|
|
3
|
+
* access on, the default: orchestratorToolRefusal then checks nothing but
|
|
4
|
+
* warns about stopping AgentOS; full-access.ts). Anywhere on the machine except the
|
|
3
5
|
* workspace's project repositories and their task worktrees (code changes go
|
|
4
6
|
* through tasks, so the merge gates keep meaning something), AgentOS's own
|
|
5
7
|
* install and state (all of ~/.agent-os but its own folder), secret files,
|
|
@@ -18,13 +20,15 @@ import fs from "fs";
|
|
|
18
20
|
import os from "os";
|
|
19
21
|
import path from "path";
|
|
20
22
|
import { db, type Session } from "../db";
|
|
23
|
+
import type { ToolGuardResult } from "../chat/driver";
|
|
21
24
|
import { git, GitError } from "../git-exec";
|
|
22
25
|
import { isOrchestratorSecretPath as isSecretPath } from "../secret-paths";
|
|
23
26
|
import { WORKTREES_DIR } from "../worktrees";
|
|
24
27
|
import { orchestratorDir } from "./home";
|
|
25
28
|
import { LIMITS_FILE } from "./usage";
|
|
26
29
|
import { quickWorktrees } from "./quick-store";
|
|
27
|
-
import { shellRefusal } from "./shell-guard";
|
|
30
|
+
import { shellRefusal, shellWarning, type ShellContext } from "./shell-guard";
|
|
31
|
+
import { orchestratorFullAccess } from "./full-access";
|
|
28
32
|
import { liveCheckout } from "../dev-instance/liveServer";
|
|
29
33
|
|
|
30
34
|
// The tools that write a file, and the argument naming it.
|
|
@@ -366,24 +370,47 @@ export async function orchestratorToolRefusal(
|
|
|
366
370
|
session: Session,
|
|
367
371
|
tool: string,
|
|
368
372
|
input: Record<string, unknown>
|
|
369
|
-
): Promise<
|
|
370
|
-
|
|
373
|
+
): Promise<ToolGuardResult> {
|
|
374
|
+
const full = orchestratorFullAccess(session);
|
|
375
|
+
if (tool === "Bash")
|
|
376
|
+
return full
|
|
377
|
+
? commandWarning(session, input.command)
|
|
378
|
+
: commandRefusal(session, input.command);
|
|
371
379
|
const arg = WRITE_TOOLS[tool];
|
|
372
380
|
if (!arg) return null;
|
|
373
381
|
const filePath = input[arg];
|
|
382
|
+
if (full) return null;
|
|
374
383
|
if (typeof filePath !== "string") return "No file path to check.";
|
|
375
384
|
return writeRefusal(filePath, await orchestratorWriteBounds(session));
|
|
376
385
|
}
|
|
377
386
|
|
|
387
|
+
// With full access: only a warning for stopping AgentOS or its tmux.
|
|
388
|
+
async function commandWarning(
|
|
389
|
+
session: Session,
|
|
390
|
+
command: unknown
|
|
391
|
+
): Promise<ToolGuardResult> {
|
|
392
|
+
const warning = await shellWarning(
|
|
393
|
+
command,
|
|
394
|
+
shellContext(session, await orchestratorWriteBounds(session))
|
|
395
|
+
);
|
|
396
|
+
return warning ? { warning } : null;
|
|
397
|
+
}
|
|
398
|
+
|
|
378
399
|
// A shell command, against the deny list (shell-guard.ts), its writes held
|
|
379
400
|
// to the shell's share of the write bounds.
|
|
380
401
|
async function commandRefusal(
|
|
381
402
|
session: Session,
|
|
382
403
|
command: unknown
|
|
383
404
|
): Promise<string | null> {
|
|
384
|
-
|
|
405
|
+
return shellRefusal(
|
|
406
|
+
command,
|
|
407
|
+
shellContext(session, await orchestratorWriteBounds(session))
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function shellContext(session: Session, bounds: WriteBounds): ShellContext {
|
|
385
412
|
const dbFile = agentOsDbPath();
|
|
386
|
-
return
|
|
413
|
+
return {
|
|
387
414
|
cwd: bounds.cwd,
|
|
388
415
|
writeRefusal: (p) => writeRefusal(p, bounds, { shell: true }),
|
|
389
416
|
isAgentOsState: async (p) => {
|
|
@@ -402,7 +429,7 @@ async function commandRefusal(
|
|
|
402
429
|
},
|
|
403
430
|
checkoutKind: (dir) => checkoutKind(session, dir),
|
|
404
431
|
protectedPids,
|
|
405
|
-
}
|
|
432
|
+
};
|
|
406
433
|
}
|
|
407
434
|
|
|
408
435
|
// Whether a folder is a project checkout of the session's own workspace
|
|
@@ -18,9 +18,11 @@ import { simulatorBrief } from "../simulator/brief";
|
|
|
18
18
|
export function runningBrief(
|
|
19
19
|
config: ProjectConfig,
|
|
20
20
|
ports: Record<string, number>,
|
|
21
|
-
database: SessionDatabase | null = null
|
|
21
|
+
database: SessionDatabase | null = null,
|
|
22
|
+
warning: string | null = null
|
|
22
23
|
): string {
|
|
23
24
|
const lines: string[] = [];
|
|
25
|
+
if (warning) lines.push(`- ${warning}`);
|
|
24
26
|
const names = Object.entries(ports);
|
|
25
27
|
if (names.length)
|
|
26
28
|
lines.push(
|
|
@@ -43,6 +43,12 @@ export type PgRun = (
|
|
|
43
43
|
|
|
44
44
|
export const pgRun: PgRun = (cmd, args, env) =>
|
|
45
45
|
new Promise((resolve) => {
|
|
46
|
+
// A test that reached the real server could list or drop the owner's
|
|
47
|
+
// databases: every test injects its own run.
|
|
48
|
+
if (process.env.VITEST)
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Refusing to run the real Postgres tools under the test runner"
|
|
51
|
+
);
|
|
46
52
|
execFile(
|
|
47
53
|
cmd,
|
|
48
54
|
args,
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one reader of a project's config: `agentos.json
|
|
3
|
-
* else the legacy `.agent-os/worktrees.json` / `.agent-os.json
|
|
4
|
-
*
|
|
5
|
-
* to read the next
|
|
2
|
+
* The one reader of a project's config: `agentos.json` and `.dispatch.json`
|
|
3
|
+
* together, else the legacy `.agent-os/worktrees.json` / `.agent-os.json`
|
|
4
|
+
* (the first of those that exists). A broken file is an error, never a
|
|
5
|
+
* reason to read the next or to ignore it.
|
|
6
|
+
*
|
|
7
|
+
* When both agentos.json and .dispatch.json exist they merge by top-level
|
|
8
|
+
* key: agentos.json wins any key it sets, and its value replaces
|
|
9
|
+
* .dispatch.json's whole (no deep merge, so a partial `browse` never mixes
|
|
10
|
+
* with the old one); keys only .dispatch.json has still apply, after its
|
|
11
|
+
* normalization (`workspace` becomes `cards.workspace`, dispatch-only keys
|
|
12
|
+
* are dropped). `inherited` names those keys and `warning` says to move them.
|
|
13
|
+
* Design: docs/decisions/2026-10-10-agentos-json.md.
|
|
6
14
|
*/
|
|
7
15
|
|
|
8
16
|
import * as fs from "fs";
|
|
9
17
|
import * as path from "path";
|
|
10
18
|
import type { z } from "zod";
|
|
11
19
|
import {
|
|
20
|
+
agentosFields,
|
|
12
21
|
agentosJson,
|
|
22
|
+
dispatchFields,
|
|
13
23
|
dispatchJson,
|
|
14
24
|
legacyJson,
|
|
25
|
+
mergedJson,
|
|
15
26
|
RESERVED_ENV,
|
|
16
27
|
type ProjectConfig,
|
|
17
28
|
} from "./schema";
|
|
@@ -34,9 +45,20 @@ export interface LoadedConfig {
|
|
|
34
45
|
source: string | null;
|
|
35
46
|
// Why the file was refused, naming it and every bad field.
|
|
36
47
|
error: string | null;
|
|
48
|
+
// Top-level keys that came from .dispatch.json because agentos.json lacks
|
|
49
|
+
// them; empty unless both files exist.
|
|
50
|
+
inherited: string[];
|
|
51
|
+
// One line for the brief when both files exist; null otherwise.
|
|
52
|
+
warning: string | null;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
|
-
const NONE: LoadedConfig = {
|
|
55
|
+
export const NONE: LoadedConfig = Object.freeze({
|
|
56
|
+
config: {},
|
|
57
|
+
source: null,
|
|
58
|
+
error: null,
|
|
59
|
+
inherited: Object.freeze([]) as unknown as string[],
|
|
60
|
+
warning: null,
|
|
61
|
+
});
|
|
40
62
|
|
|
41
63
|
function describe(file: string, error: z.ZodError): string {
|
|
42
64
|
const fields = error.issues.map((i) => {
|
|
@@ -61,7 +83,9 @@ function fromLegacy(raw: z.infer<typeof legacyJson>): ProjectConfig {
|
|
|
61
83
|
return config;
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
|
|
86
|
+
// `checked: false` leaves the port-name check to the merge, which can see
|
|
87
|
+
// the ports the other file declares.
|
|
88
|
+
function parse(file: string, text: string, checked = true): LoadedConfig {
|
|
65
89
|
let raw: unknown;
|
|
66
90
|
try {
|
|
67
91
|
raw = JSON.parse(text);
|
|
@@ -73,31 +97,102 @@ function parse(file: string, text: string): LoadedConfig {
|
|
|
73
97
|
};
|
|
74
98
|
}
|
|
75
99
|
if (file === "agentos.json") {
|
|
76
|
-
const got = agentosJson.safeParse(raw);
|
|
100
|
+
const got = (checked ? agentosJson : agentosFields).safeParse(raw);
|
|
77
101
|
return got.success
|
|
78
|
-
? { config: got.data, source: file
|
|
102
|
+
? { ...NONE, config: got.data, source: file }
|
|
79
103
|
: { ...NONE, source: file, error: describe(file, got.error) };
|
|
80
104
|
}
|
|
81
105
|
if (file === ".dispatch.json") {
|
|
82
|
-
const got = dispatchJson.safeParse(raw);
|
|
106
|
+
const got = (checked ? dispatchJson : dispatchFields).safeParse(raw);
|
|
83
107
|
if (!got.success)
|
|
84
108
|
return { ...NONE, source: file, error: describe(file, got.error) };
|
|
85
109
|
const { workspace, ...config } = got.data;
|
|
86
110
|
if (workspace && !config.cards?.workspace)
|
|
87
111
|
config.cards = { ...config.cards, workspace };
|
|
88
|
-
return { config, source: file
|
|
112
|
+
return { ...NONE, config, source: file };
|
|
89
113
|
}
|
|
90
114
|
const got = legacyJson.safeParse(raw);
|
|
91
115
|
return got.success
|
|
92
|
-
? { config: fromLegacy(got.data), source: file
|
|
116
|
+
? { ...NONE, config: fromLegacy(got.data), source: file }
|
|
93
117
|
: { ...NONE, source: file, error: describe(file, got.error) };
|
|
94
118
|
}
|
|
95
119
|
|
|
120
|
+
export const bothFilesWarning = (inherited: string[]): string =>
|
|
121
|
+
`This repo has both agentos.json and .dispatch.json; keys ${inherited.join(", ")} come from .dispatch.json. Move them into agentos.json and delete .dispatch.json.`;
|
|
122
|
+
|
|
123
|
+
function merge(agentos: ProjectConfig, dispatch: ProjectConfig): LoadedConfig {
|
|
124
|
+
const inherited = Object.keys(dispatch).filter((k) => !(k in agentos));
|
|
125
|
+
const config = {
|
|
126
|
+
...Object.fromEntries(
|
|
127
|
+
inherited.map((k) => [k, dispatch[k as keyof ProjectConfig]])
|
|
128
|
+
),
|
|
129
|
+
...agentos,
|
|
130
|
+
} as ProjectConfig;
|
|
131
|
+
const got = mergedJson.safeParse(config);
|
|
132
|
+
if (!got.success)
|
|
133
|
+
return {
|
|
134
|
+
...NONE,
|
|
135
|
+
source: "agentos.json",
|
|
136
|
+
error: describe("agentos.json with .dispatch.json", got.error),
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
...NONE,
|
|
140
|
+
config,
|
|
141
|
+
source: "agentos.json",
|
|
142
|
+
inherited,
|
|
143
|
+
warning: inherited.length ? bothFilesWarning(inherited) : null,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function statOf(projectPath: string, file: string): fs.Stats | null {
|
|
148
|
+
try {
|
|
149
|
+
const stat = fs.statSync(path.join(projectPath, file));
|
|
150
|
+
return stat.isFile() ? stat : null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readFile(projectPath: string, file: string): string | LoadedConfig {
|
|
157
|
+
try {
|
|
158
|
+
return fs.readFileSync(path.join(projectPath, file), "utf-8");
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return {
|
|
161
|
+
...NONE,
|
|
162
|
+
source: file,
|
|
163
|
+
error: `${file} could not be read: ${(e as Error).message}`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Both files, merged. A broken one is the error, whichever it is.
|
|
169
|
+
function loadBoth(projectPath: string): LoadedConfig {
|
|
170
|
+
const parsed: LoadedConfig[] = [];
|
|
171
|
+
for (const file of ["agentos.json", ".dispatch.json"]) {
|
|
172
|
+
const text = readFile(projectPath, file);
|
|
173
|
+
if (typeof text !== "string") return text;
|
|
174
|
+
const got = parse(file, text, false);
|
|
175
|
+
if (got.error) return got;
|
|
176
|
+
parsed.push(got);
|
|
177
|
+
}
|
|
178
|
+
return merge(parsed[0].config, parsed[1].config);
|
|
179
|
+
}
|
|
180
|
+
|
|
96
181
|
// Keyed on the project; reused while the file's mtime and size hold, since
|
|
97
182
|
// agentEnv reads it for every terminal and chat start.
|
|
98
183
|
const cache = new Map<string, { stamp: string; loaded: LoadedConfig }>();
|
|
99
184
|
|
|
100
185
|
export function loadProjectConfig(projectPath: string): LoadedConfig {
|
|
186
|
+
const a = statOf(projectPath, "agentos.json");
|
|
187
|
+
const d = statOf(projectPath, ".dispatch.json");
|
|
188
|
+
if (a && d) {
|
|
189
|
+
const stamp = `both:${a.mtimeMs}:${a.size}:${d.mtimeMs}:${d.size}`;
|
|
190
|
+
const hit = cache.get(projectPath);
|
|
191
|
+
if (hit?.stamp === stamp) return hit.loaded;
|
|
192
|
+
const loaded = loadBoth(projectPath);
|
|
193
|
+
cache.set(projectPath, { stamp, loaded });
|
|
194
|
+
return loaded;
|
|
195
|
+
}
|
|
101
196
|
for (const file of CONFIG_FILES) {
|
|
102
197
|
const full = path.join(projectPath, file);
|
|
103
198
|
let stat: fs.Stats;
|
|
@@ -110,16 +205,8 @@ export function loadProjectConfig(projectPath: string): LoadedConfig {
|
|
|
110
205
|
const stamp = `${file}:${stat.mtimeMs}:${stat.size}`;
|
|
111
206
|
const hit = cache.get(projectPath);
|
|
112
207
|
if (hit?.stamp === stamp) return hit.loaded;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
text = fs.readFileSync(full, "utf-8");
|
|
116
|
-
} catch (e) {
|
|
117
|
-
return {
|
|
118
|
-
...NONE,
|
|
119
|
-
source: file,
|
|
120
|
-
error: `${file} could not be read: ${(e as Error).message}`,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
208
|
+
const text = readFile(projectPath, file);
|
|
209
|
+
if (typeof text !== "string") return text;
|
|
123
210
|
const loaded = parse(file, text);
|
|
124
211
|
cache.set(projectPath, { stamp, loaded });
|
|
125
212
|
return loaded;
|
|
@@ -228,13 +228,19 @@ function portRefs(
|
|
|
228
228
|
|
|
229
229
|
// agentos.json: an unknown key is an error, because it is nearly always a
|
|
230
230
|
// typo that would otherwise be silently ignored.
|
|
231
|
-
export const
|
|
231
|
+
export const agentosFields = z.strictObject(shape);
|
|
232
|
+
export const agentosJson = agentosFields.superRefine(portRefs);
|
|
232
233
|
|
|
233
234
|
// .dispatch.json: the same fields, but dispatch has keys of its own. Its
|
|
234
235
|
// top-level `workspace` is `cards.workspace` here.
|
|
235
|
-
export const
|
|
236
|
-
|
|
237
|
-
.
|
|
236
|
+
export const dispatchFields = z.object({
|
|
237
|
+
...shape,
|
|
238
|
+
workspace: z.string().optional(),
|
|
239
|
+
});
|
|
240
|
+
export const dispatchJson = dispatchFields.superRefine(portRefs);
|
|
241
|
+
|
|
242
|
+
// The cross-key port check on its own, for a config merged from two files.
|
|
243
|
+
export const mergedJson = z.looseObject(shape).superRefine(portRefs);
|
|
238
244
|
|
|
239
245
|
export const legacyJson = z.object({
|
|
240
246
|
setup: z.array(z.string()).optional(),
|
|
@@ -10,7 +10,12 @@ import { sessionTmpDir } from "../sessions/leftovers";
|
|
|
10
10
|
import { db } from "../db";
|
|
11
11
|
import { getProject } from "../projects";
|
|
12
12
|
import { sessionPorts } from "../ports";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
loadProjectConfig,
|
|
15
|
+
NONE,
|
|
16
|
+
projectEnv,
|
|
17
|
+
type LoadedConfig,
|
|
18
|
+
} from "./index";
|
|
14
19
|
import { runningBrief } from "./brief";
|
|
15
20
|
import { databaseEnv, sessionDatabase } from "./database";
|
|
16
21
|
|
|
@@ -31,9 +36,7 @@ export function sessionConfig(
|
|
|
31
36
|
sessionId: string
|
|
32
37
|
): LoadedConfig & { ports: Record<string, number> | null } {
|
|
33
38
|
const projectPath = projectPathOf(sessionId);
|
|
34
|
-
const loaded = projectPath
|
|
35
|
-
? loadProjectConfig(projectPath)
|
|
36
|
-
: { config: {}, source: null, error: null };
|
|
39
|
+
const loaded = projectPath ? loadProjectConfig(projectPath) : NONE;
|
|
37
40
|
return { ...loaded, ports: sessionPorts(sessionId) };
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -70,9 +73,9 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
|
|
|
70
73
|
|
|
71
74
|
export function sessionRunningBrief(sessionId: string): string {
|
|
72
75
|
try {
|
|
73
|
-
const { config, source, ports } = sessionConfig(sessionId);
|
|
76
|
+
const { config, source, warning, ports } = sessionConfig(sessionId);
|
|
74
77
|
return source
|
|
75
|
-
? runningBrief(config, ports ?? {}, sessionDatabase(sessionId))
|
|
78
|
+
? runningBrief(config, ports ?? {}, sessionDatabase(sessionId), warning)
|
|
76
79
|
: "";
|
|
77
80
|
} catch (error) {
|
|
78
81
|
console.error(`[project-config] brief for ${sessionId}:`, error);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { db } from "@/lib/db";
|
|
2
|
+
import type { ProjectTier } from "./types";
|
|
3
|
+
|
|
4
|
+
// An unknown project counts as production: a missing row never opens the
|
|
5
|
+
// fast lane.
|
|
6
|
+
export function projectTier(projectId: string | null): ProjectTier {
|
|
7
|
+
if (!projectId) return "production";
|
|
8
|
+
const row = db
|
|
9
|
+
.prepare(`SELECT tier FROM projects WHERE id = ?`)
|
|
10
|
+
.get(projectId) as { tier: string } | undefined;
|
|
11
|
+
return row?.tier === "fast" ? "fast" : "production";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const taskTier = (task: { project_id: string | null }): ProjectTier =>
|
|
15
|
+
projectTier(task.project_id);
|
|
16
|
+
|
|
17
|
+
export const isFast = (task: { project_id: string | null }): boolean =>
|
|
18
|
+
taskTier(task) === "fast";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getProject } from "@/lib/projects";
|
|
2
|
+
import { db } from "@/lib/db";
|
|
3
|
+
import type { Project } from "@/lib/db/types";
|
|
4
|
+
import type { ProjectTier } from "./types";
|
|
5
|
+
|
|
6
|
+
export function setProjectTier(projectId: string, tier: ProjectTier): Project {
|
|
7
|
+
const project = getProject(projectId);
|
|
8
|
+
if (!project || project.is_uncategorized)
|
|
9
|
+
throw new Error("Project not found or cannot be modified");
|
|
10
|
+
db.prepare(
|
|
11
|
+
`UPDATE projects SET tier = ?, updated_at = datetime('now') WHERE id = ?`
|
|
12
|
+
).run(tier, projectId);
|
|
13
|
+
return getProject(projectId)!;
|
|
14
|
+
}
|
package/lib/security/net.ts
CHANGED
|
@@ -146,10 +146,13 @@ export function originAllowed(
|
|
|
146
146
|
|
|
147
147
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
148
148
|
|
|
149
|
-
// LumifyHub's approve page
|
|
150
|
-
// reached by a cross-site top-level navigation.
|
|
151
|
-
// this instance issued, which a foreign page can't know.
|
|
152
|
-
const CROSS_SITE_NAVIGATIONS = [
|
|
149
|
+
// LumifyHub's approve page and an MCP server's sign-in send the browser back
|
|
150
|
+
// here, so these URLs are reached by a cross-site top-level navigation. Each
|
|
151
|
+
// acts only on a `state` this instance issued, which a foreign page can't know.
|
|
152
|
+
const CROSS_SITE_NAVIGATIONS = [
|
|
153
|
+
"/api/lumifyhub/callback",
|
|
154
|
+
"/api/mcp-auth/callback",
|
|
155
|
+
];
|
|
153
156
|
|
|
154
157
|
function crossSiteNavigationAllowed(req: {
|
|
155
158
|
method?: string;
|
package/lib/start-options.ts
CHANGED
|
@@ -40,7 +40,14 @@ export async function startOptions(
|
|
|
40
40
|
const opts = { workspaceId: project?.workspace_id ?? null };
|
|
41
41
|
roles.plan = resolveRole("plan", opts, models).ref;
|
|
42
42
|
roles.implement = resolveRole("implement", opts, models).ref;
|
|
43
|
-
planDefault = taskRouting(
|
|
43
|
+
planDefault = taskRouting(
|
|
44
|
+
opts.workspaceId,
|
|
45
|
+
"chat",
|
|
46
|
+
undefined,
|
|
47
|
+
undefined,
|
|
48
|
+
undefined,
|
|
49
|
+
project?.tier
|
|
50
|
+
).plan;
|
|
44
51
|
} catch (error) {
|
|
45
52
|
planError = error instanceof Error ? error.message : String(error);
|
|
46
53
|
}
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { sessionEnded } from "../experiments/hooks";
|
|
|
40
40
|
import { snapshotWorktree } from "../resurrect/snapshot";
|
|
41
41
|
import { keepSessionFiles } from "../session-artifacts/collect";
|
|
42
42
|
import { setPr, setTask } from "../db/session-parts";
|
|
43
|
+
import { isFast } from "../project-tier/getProjectTier";
|
|
43
44
|
|
|
44
45
|
export const sessionMergePolicy = (session: Session): MergePolicy =>
|
|
45
46
|
mergePolicy(session.project_id ? getProject(session.project_id) : null);
|
|
@@ -167,7 +168,7 @@ export async function signOffTask(
|
|
|
167
168
|
throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
|
|
168
169
|
const judgedBase = opts.base ?? session.base_branch;
|
|
169
170
|
const pr = await prFor(session, true);
|
|
170
|
-
const verdict = canSignOff(pr);
|
|
171
|
+
const verdict = canSignOff(pr, { ci: !isFast(session) });
|
|
171
172
|
if (!verdict.ok) throw new Error(verdict.reason);
|
|
172
173
|
if (pr!.base && pr!.base !== judgedBase)
|
|
173
174
|
throw new Error(
|
package/lib/tasks/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { effectiveModels } from "../models/config";
|
|
|
15
15
|
import { ANTHROPIC } from "../models/roles";
|
|
16
16
|
import { processTable, runsSomething } from "../process-table";
|
|
17
17
|
import { randomUUID } from "crypto";
|
|
18
|
-
import { db, queries, type Session } from "../db";
|
|
18
|
+
import { db, queries, type ProjectTier, type Session } from "../db";
|
|
19
19
|
import { getProject } from "../projects";
|
|
20
20
|
import {
|
|
21
21
|
createWorktree,
|
|
@@ -267,7 +267,8 @@ async function startTask(
|
|
|
267
267
|
view,
|
|
268
268
|
opts.model,
|
|
269
269
|
opts.plan,
|
|
270
|
-
opts.planOnly
|
|
270
|
+
opts.planOnly,
|
|
271
|
+
project.tier
|
|
271
272
|
).plan;
|
|
272
273
|
const session = await startRemoteTask(hostId, project, {
|
|
273
274
|
id,
|
|
@@ -310,7 +311,8 @@ async function startTask(
|
|
|
310
311
|
view,
|
|
311
312
|
opts.model,
|
|
312
313
|
opts.plan,
|
|
313
|
-
opts.planOnly
|
|
314
|
+
opts.planOnly,
|
|
315
|
+
project.tier
|
|
314
316
|
)
|
|
315
317
|
: otherAgentRouting(opts.model);
|
|
316
318
|
const projectPath = expandHome(project.working_directory);
|
|
@@ -590,7 +592,9 @@ export function taskRouting(
|
|
|
590
592
|
view: StartView,
|
|
591
593
|
model: unknown,
|
|
592
594
|
plan?: boolean,
|
|
593
|
-
planOnly?: boolean
|
|
595
|
+
planOnly?: boolean,
|
|
596
|
+
// A fast project skips the plan step unless the caller asks for it.
|
|
597
|
+
tier: ProjectTier = "production"
|
|
594
598
|
) {
|
|
595
599
|
const override = parseOverride(model);
|
|
596
600
|
const models = effectiveModels();
|
|
@@ -601,7 +605,8 @@ export function taskRouting(
|
|
|
601
605
|
const planStep =
|
|
602
606
|
view === "chat" &&
|
|
603
607
|
(!!planOnly ||
|
|
604
|
-
((plan ??
|
|
608
|
+
((plan ?? (tier === "fast" ? false : models.planStep)) &&
|
|
609
|
+
planning.ref !== implement.ref));
|
|
605
610
|
return {
|
|
606
611
|
override,
|
|
607
612
|
plan: planStep,
|
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
// Tests mock ../worktrees (WORKTREES_DIR), ../env-setup and ../agents/launch.
|
|
4
4
|
|
|
5
5
|
import fs from "fs";
|
|
6
|
-
import os from "os";
|
|
7
6
|
import path from "path";
|
|
8
7
|
import { randomUUID } from "crypto";
|
|
9
8
|
import { db, insertSessionRow, type Session } from "../db";
|
|
10
9
|
import { createProject } from "../projects";
|
|
11
10
|
import { writeTranscript } from "./transcript";
|
|
12
11
|
import { gitSync } from "../git-exec";
|
|
12
|
+
import { tempDir } from "@/lib/test-temp";
|
|
13
13
|
|
|
14
14
|
export const CLAUDE_ID = "11111111-2222-3333-4444-555555555555";
|
|
15
15
|
|
|
@@ -30,7 +30,7 @@ export interface MoveFixture {
|
|
|
30
30
|
/** HOME, Claude's folder and git identity all point into a temp dir. */
|
|
31
31
|
export function setupMoveRepo(): MoveFixture {
|
|
32
32
|
const saved = { HOME: process.env.HOME };
|
|
33
|
-
const tmp =
|
|
33
|
+
const tmp = tempDir("aos-move-");
|
|
34
34
|
process.env.HOME = tmp;
|
|
35
35
|
process.env.CLAUDE_CONFIG_DIR = path.join(tmp, ".claude");
|
|
36
36
|
for (const who of ["AUTHOR", "COMMITTER"]) {
|
package/lib/tasks/state.ts
CHANGED
|
@@ -97,13 +97,19 @@ export function needsHuman(state: TaskState): boolean {
|
|
|
97
97
|
].includes(state);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// `ci: false` (a fast project): checks that are failing or running don't
|
|
101
|
+
// refuse the merge.
|
|
102
|
+
export function canSignOff(
|
|
103
|
+
pr: TaskPR | null,
|
|
104
|
+
opts: { ci?: boolean } = {}
|
|
105
|
+
): {
|
|
101
106
|
ok: boolean;
|
|
102
107
|
reason?: string;
|
|
103
108
|
} {
|
|
104
109
|
if (!pr) return { ok: false, reason: "No pull request yet" };
|
|
105
110
|
if (pr.state !== "OPEN")
|
|
106
111
|
return { ok: false, reason: `PR is ${pr.state.toLowerCase()}` };
|
|
112
|
+
if (opts.ci === false) return { ok: true };
|
|
107
113
|
if (pr.checks === "fail")
|
|
108
114
|
return { ok: false, reason: "CI checks are failing" };
|
|
109
115
|
if (pr.checks === "pending")
|
package/lib/test-temp.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Folders made by tempDir, removed when the test file ends (vitest.setup.ts
|
|
6
|
+
// calls removeTempDirs). Kept on globalThis: vi.resetModules() would
|
|
7
|
+
// otherwise lose the list.
|
|
8
|
+
const KEY = Symbol.for("agentos.testTempDirs");
|
|
9
|
+
const made = (): Set<string> => {
|
|
10
|
+
const g = globalThis as unknown as Record<symbol, Set<string> | undefined>;
|
|
11
|
+
return (g[KEY] ??= new Set<string>());
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A fresh temp folder that is removed when the test file ends. */
|
|
15
|
+
export function tempDir(prefix: string): string {
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
17
|
+
made().add(dir);
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function removeTempDirs(): void {
|
|
22
|
+
for (const dir of made()) {
|
|
23
|
+
try {
|
|
24
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 5 });
|
|
25
|
+
} catch {
|
|
26
|
+
// the run root's removal is the backstop
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
made().clear();
|
|
30
|
+
}
|