@kendoo.agentdesk/agentdesk 0.28.3 → 0.28.4
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/CHANGELOG.md +19 -6
- package/README.md +38 -0
- package/bin/agentdesk.mjs +11 -4
- package/cli/config.mjs +7 -2
- package/cli/daemon.mjs +99 -51
- package/cli/engine/cancellation.mjs +90 -0
- package/cli/engine/env.mjs +3 -0
- package/cli/engine/hooks.mjs +2 -1
- package/cli/engine/outcome.mjs +115 -0
- package/cli/engine/query.mjs +2 -1
- package/cli/engine/session.mjs +104 -13
- package/cli/engine/spawn.mjs +8 -3
- package/cli/session-isolation.mjs +10 -5
- package/cli/session-queue.mjs +57 -0
- package/cli/team.mjs +11 -3
- package/cli/worktree-git.mjs +85 -0
- package/cli/worktree-options.mjs +18 -0
- package/cli/worktrees.mjs +295 -0
- package/package.json +3 -2
- package/shared/outcomes.mjs +41 -0
- package/shared/session-status.mjs +6 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { normalizeOutcome } from "../../shared/outcomes.mjs";
|
|
3
|
+
|
|
4
|
+
export function githubRepository(cwd) {
|
|
5
|
+
try {
|
|
6
|
+
const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], { cwd, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
7
|
+
return remote.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([\w.-]+\/[\w.-]+?)(?:\.git)?$/)?.[1] || null;
|
|
8
|
+
} catch { return null; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const replyMarker = sessionId => `[AgentDesk reply:${sessionId}]`;
|
|
12
|
+
|
|
13
|
+
export function currentBranch(cwd) {
|
|
14
|
+
try { return execFileSync("git", ["symbolic-ref", "--short", "HEAD"], { cwd, encoding: "utf8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
15
|
+
catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Recognize single, literal commands only. Never execute/expand shell text.
|
|
19
|
+
// Pipelines, substitutions, redirects, command chains and file payloads are
|
|
20
|
+
// intentionally unsupported rather than guessed at.
|
|
21
|
+
export function commandWords(command) {
|
|
22
|
+
if (typeof command !== "string") return null;
|
|
23
|
+
const words = []; let word = "", quote = null, started = false;
|
|
24
|
+
for (let i = 0; i < String(command).length; i++) {
|
|
25
|
+
const ch = command[i];
|
|
26
|
+
if (quote === "'") { if (ch === "'") quote = null; else word += ch; started = true; continue; }
|
|
27
|
+
if (ch === "`" || (ch === "$" && command[i + 1] === "(")) return null;
|
|
28
|
+
if (ch === "\\") { if (i + 1 >= command.length) return null; word += command[++i]; started = true; continue; }
|
|
29
|
+
if (quote === '"') { if (ch === '"') quote = null; else word += ch; started = true; continue; }
|
|
30
|
+
if (ch === "'" || ch === '"') { quote = ch; started = true; continue; }
|
|
31
|
+
if (/[;&|<>\n\r()]/.test(ch)) return null;
|
|
32
|
+
if (/\s/.test(ch)) { if (started) words.push(word); word = ""; started = false; }
|
|
33
|
+
else { word += ch; started = true; }
|
|
34
|
+
}
|
|
35
|
+
if (quote) return null;
|
|
36
|
+
if (started) words.push(word);
|
|
37
|
+
return words;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function flags(words, valued, switches = []) {
|
|
41
|
+
const values = {};
|
|
42
|
+
for (let i = 0; i < words.length; i++) {
|
|
43
|
+
const flag = words[i];
|
|
44
|
+
if (switches.includes(flag)) continue;
|
|
45
|
+
const key = valued[flag];
|
|
46
|
+
if (!key || i + 1 >= words.length || (values[key] !== undefined && key !== "header")) return null;
|
|
47
|
+
values[key] = words[++i];
|
|
48
|
+
}
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function captureOutcome({ tool, input, response, actionId, phase, sessionId, taskId, repo, branch, tracker, config = {}, observedAt = Date.now() }) {
|
|
53
|
+
try {
|
|
54
|
+
return captureReceipt({ tool, input, response, actionId, phase, sessionId, taskId, repo, branch, tracker, config, observedAt });
|
|
55
|
+
} catch {
|
|
56
|
+
// Optional reporting must never turn a tool result into a hook failure.
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function captureReceipt({ tool, input, response, actionId, phase, sessionId, taskId, repo, branch, tracker, config, observedAt }) {
|
|
62
|
+
if (tool !== "Bash" || !actionId || !sessionId || !response || response.interrupted || response.is_error ||
|
|
63
|
+
(response.exitCode != null && response.exitCode !== 0)) return null;
|
|
64
|
+
const words = commandWords(input?.command);
|
|
65
|
+
if (!words) return null;
|
|
66
|
+
const stdout = typeof response.stdout === "string" ? response.stdout.trim() : "";
|
|
67
|
+
const common = { actionId, taskId: String(taskId || ""), observedAt, branch };
|
|
68
|
+
const marker = replyMarker(sessionId);
|
|
69
|
+
if (words.slice(0, 3).join(" ") === "gh pr create") {
|
|
70
|
+
const opts = flags(words.slice(3), { "--repo": "repo", "-R": "repo", "--head": "head", "-H": "head", "--base": "base", "-B": "base", "--title": "title", "-t": "title", "--body": "body", "-b": "body", "--body-file": "file", "-F": "file" }, ["--draft", "-d", "--fill", "--fill-first", "--fill-verbose"]);
|
|
71
|
+
if (!opts || !repo || opts.repo !== repo || !branch || opts.head !== branch) return null;
|
|
72
|
+
const id = stdout.match(/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+)$/)?.[1];
|
|
73
|
+
return normalizeOutcome({ ...common, kind: "pr_created", provider: "github", container: repo, externalId: id, url: stdout });
|
|
74
|
+
}
|
|
75
|
+
if (!["SUMMARY", "SOLO"].includes(phase)) return null;
|
|
76
|
+
if (tracker === "github" && words.slice(0, 3).join(" ") === "gh issue comment" && words[3] === String(taskId)) {
|
|
77
|
+
const opts = flags(words.slice(4), { "--repo": "repo", "-R": "repo", "--body": "body", "-b": "body" });
|
|
78
|
+
if (!opts || !repo || opts.repo !== repo || !opts.body?.includes(marker)) return null;
|
|
79
|
+
const id = stdout.match(/#issuecomment-(\d+)$/)?.[1];
|
|
80
|
+
return normalizeOutcome({ ...common, kind: "replied", provider: "github", container: repo, externalId: id, url: stdout });
|
|
81
|
+
}
|
|
82
|
+
if (words[0] !== "curl") return null;
|
|
83
|
+
const urls = words.slice(1).filter(word => /^https:\/\//.test(word));
|
|
84
|
+
if (urls.length !== 1) return null;
|
|
85
|
+
const opts = flags(words.slice(1).filter(word => word !== urls[0]), {
|
|
86
|
+
"-X": "method", "--request": "method", "-H": "header", "--header": "header", "-u": "auth", "--user": "auth",
|
|
87
|
+
"--data-raw": "data", "--data": "data", "-d": "data",
|
|
88
|
+
}, ["-s", "-S", "-sS", "-fsS", "-f", "--silent", "--show-error", "--fail", "--fail-with-body"]);
|
|
89
|
+
if (!opts || (opts.method && opts.method !== "POST") || !opts.data || opts.data.startsWith("@")) return null;
|
|
90
|
+
let payload, result;
|
|
91
|
+
try { payload = JSON.parse(opts.data); result = JSON.parse(stdout); } catch { return null; }
|
|
92
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload) ||
|
|
93
|
+
!result || typeof result !== "object" || Array.isArray(result)) return null;
|
|
94
|
+
if (result.errors || result.errorMessages || result.error) return null;
|
|
95
|
+
if (tracker === "jira") {
|
|
96
|
+
let origin;
|
|
97
|
+
try { origin = new URL(config.jira?.baseUrl).origin; } catch { return null; }
|
|
98
|
+
if (urls[0] !== `${origin}/rest/api/3/issue/${taskId}/comment` || !JSON.stringify(payload.body || {}).includes(marker) ||
|
|
99
|
+
!JSON.stringify(result.body || {}).includes(marker) || !result.id ||
|
|
100
|
+
typeof result.self !== "string" || !result.self.startsWith(`${origin}/rest/api/3/issue/`) ||
|
|
101
|
+
!result.self.endsWith(`/comment/${result.id}`)) return null;
|
|
102
|
+
return normalizeOutcome({ ...common, kind: "replied", provider: "jira", container: origin, externalId: String(result.id), url: `${origin}/browse/${taskId}?focusedCommentId=${result.id}` });
|
|
103
|
+
}
|
|
104
|
+
if (tracker === "linear" && urls[0] === "https://api.linear.app/graphql") {
|
|
105
|
+
const receipt = result.data?.commentCreate;
|
|
106
|
+
const comment = receipt?.comment;
|
|
107
|
+
if (receipt?.success !== true || !comment?.id || comment.issue?.identifier !== taskId ||
|
|
108
|
+
typeof comment.body !== "string" || !comment.body.includes(marker) || !JSON.stringify(payload).includes(marker) ||
|
|
109
|
+
typeof payload.query !== "string" || !/^\s*mutation\b/.test(payload.query) || !/\bcommentCreate\s*\(/.test(payload.query)) return null;
|
|
110
|
+
let url;
|
|
111
|
+
try { url = new URL(comment.url); } catch { return null; }
|
|
112
|
+
return normalizeOutcome({ ...common, kind: "replied", provider: "linear", container: url.pathname.split("/")[1], externalId: comment.id, url: comment.url });
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
package/cli/engine/query.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export function maxTurnsFor(phase, config = {}) {
|
|
|
34
34
|
export function buildQueryOptions({
|
|
35
35
|
phase, cwd, env, model, agents, allowedTools, lead, state, config = {},
|
|
36
36
|
abortController, sandbox, onChild, onIsolation, hookCallbacks = {},
|
|
37
|
+
extraWritePaths = [],
|
|
37
38
|
claudePath = process.env.AGENTDESK_CLAUDE_PATH,
|
|
38
39
|
}) {
|
|
39
40
|
const options = {
|
|
@@ -50,7 +51,7 @@ export function buildQueryOptions({
|
|
|
50
51
|
forwardSubagentText: true,
|
|
51
52
|
outputFormat: { type: "json_schema", schema: PHASE_OUTPUT_SCHEMAS[phase] },
|
|
52
53
|
hooks: hooksForPhase({ phase, state, ...hookCallbacks }),
|
|
53
|
-
spawnClaudeCodeProcess: createSandboxedSpawn({ sandbox, onChild, onIsolation }),
|
|
54
|
+
spawnClaudeCodeProcess: createSandboxedSpawn({ sandbox, onChild, onIsolation, extraWritePaths }),
|
|
54
55
|
};
|
|
55
56
|
if (model) options.model = model;
|
|
56
57
|
if (Number(config.maxBudgetUsd) > 0) options.maxBudgetUsd = Number(config.maxBudgetUsd);
|
package/cli/engine/session.mjs
CHANGED
|
@@ -23,12 +23,17 @@ import { buildChildEnv } from "./env.mjs";
|
|
|
23
23
|
import { loadDotEnv } from "../dotenv.mjs";
|
|
24
24
|
import { checkClaudeAuth } from "./claude-auth.mjs";
|
|
25
25
|
import { createEventMapper, timestamp } from "./events.mjs";
|
|
26
|
+
import { captureOutcome, githubRepository, currentBranch, replyMarker } from "./outcome.mjs";
|
|
26
27
|
import { agentsForPhase, modelForPhase, soloDefinition } from "./agents/index.mjs";
|
|
27
28
|
import { renderPhasePrompt, renderSoloPrompt } from "./prompts.mjs";
|
|
28
29
|
import { renderMemorySection } from "./schemas.mjs";
|
|
29
30
|
import { verdictFromResult } from "./verdict.mjs";
|
|
30
31
|
import { buildQueryOptions, defaultRunQuery } from "./query.mjs";
|
|
31
32
|
import { armPublishGate, onSubagentStopped } from "./hooks.mjs";
|
|
33
|
+
import { prepareWorkspace } from "../worktrees.mjs";
|
|
34
|
+
import { detectProject } from "../detect.mjs";
|
|
35
|
+
import { preparePrivateGit } from "../worktree-git.mjs";
|
|
36
|
+
import { runCancellable } from "./cancellation.mjs";
|
|
32
37
|
|
|
33
38
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
34
39
|
const CLI_VERSION = JSON.parse(readFileSync(join(here, "../../package.json"), "utf-8")).version;
|
|
@@ -46,7 +51,7 @@ async function fetchTrackerCredentials(projectName, apiKey, serverUrl) {
|
|
|
46
51
|
return {};
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
function writeResumeFile({ cwd, taskId, sessionUrl, phase, duration, steps }) {
|
|
54
|
+
function writeResumeFile({ cwd, taskId, sessionUrl, phase, duration, steps, workspaceId }) {
|
|
50
55
|
let branch = "", diffStat = "";
|
|
51
56
|
try { branch = execSync("git branch --show-current", { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch {}
|
|
52
57
|
try { diffStat = execSync("git diff --stat HEAD", { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch {}
|
|
@@ -58,7 +63,7 @@ function writeResumeFile({ cwd, taskId, sessionUrl, phase, duration, steps }) {
|
|
|
58
63
|
`Duration: ${duration}`, `Steps: ${steps}`,
|
|
59
64
|
``, `## Branch`, branch || "(none)",
|
|
60
65
|
``, `## Uncommitted changes`, diffStat || "(none)",
|
|
61
|
-
``, `## Notes`, `Session interrupted during ${phase}.`, `Resume with: agentdesk team ${taskId}`,
|
|
66
|
+
``, `## Notes`, `Session interrupted during ${phase}.`, `Resume from the original project directory with: agentdesk team ${taskId}${workspaceId ? ` --resume-worktree ${workspaceId}` : " --no-worktree"}`,
|
|
62
67
|
``,
|
|
63
68
|
].join("\n"));
|
|
64
69
|
} catch {}
|
|
@@ -68,16 +73,77 @@ function seconds(startedAt) {
|
|
|
68
73
|
return `${((Date.now() - startedAt) / 1000).toFixed(1)}s`;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
export async function runSession({
|
|
76
|
+
export async function runSession(options) {
|
|
77
|
+
const sourceCwd = options.cwd;
|
|
78
|
+
const workspace = prepareWorkspace({ cwd: sourceCwd, sessionId: options.sessionId,
|
|
79
|
+
projectId: options.projectId || options.config?.projectKey || options.project?.name, taskId: options.taskId,
|
|
80
|
+
workspace: options.workspace, root: options.workspaceRoot });
|
|
81
|
+
let privateGit;
|
|
82
|
+
let cleanup;
|
|
83
|
+
let quarantined = false;
|
|
84
|
+
let result;
|
|
85
|
+
try {
|
|
86
|
+
if (workspace.record) privateGit = preparePrivateGit(workspace.record, workspace.stateDir);
|
|
87
|
+
options.onWorkspace?.(workspace.record);
|
|
88
|
+
if (workspace.record) console.error(`[agentdesk] worktree: ${workspace.cwd} (${workspace.record.branch})`);
|
|
89
|
+
result = await runCancellable(onChild => executeSession({ ...options, onChild, registerCleanup: fn => { cleanup = fn; }, cwd: workspace.cwd, sourceCwd,
|
|
90
|
+
project: workspace.record ? { ...detectProject(workspace.cwd), name: options.project?.name } : options.project,
|
|
91
|
+
workspaceRecord: workspace.record, workspaceStateDir: workspace.stateDir,
|
|
92
|
+
workspaceGitEnv: privateGit?.env,
|
|
93
|
+
resumingWorkspace: !!options.workspace?.resumeId }), {
|
|
94
|
+
signal: options.abortSignal,
|
|
95
|
+
timeoutMs: options.cancellationTimeoutMs,
|
|
96
|
+
onChild: child => { workspace.trackChild(child); options.onChild?.(child); },
|
|
97
|
+
cleanup: () => cleanup?.(),
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.name !== "AbortError" && error.name !== "CancellationTimeoutError") throw error;
|
|
102
|
+
if (error.name === "CancellationTimeoutError") {
|
|
103
|
+
// Do not publish Git state or release the lease while a writer may live.
|
|
104
|
+
// Persisted child PIDs let startup/periodic recovery clear it safely later.
|
|
105
|
+
quarantined = true;
|
|
106
|
+
workspace.quarantine();
|
|
107
|
+
console.error(`[agentdesk] ${error.message}`);
|
|
108
|
+
}
|
|
109
|
+
result = { status: "stopped", aborted: true, ...(quarantined && { quarantined: true }), duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0 };
|
|
110
|
+
options.onEvent?.({ type: "session:end", ...result });
|
|
111
|
+
return result;
|
|
112
|
+
} finally {
|
|
113
|
+
// A clean SDK return does not prove that its detached process group exited.
|
|
114
|
+
if (!quarantined && workspace.hasLiveChildren()) {
|
|
115
|
+
quarantined = true;
|
|
116
|
+
workspace.quarantine();
|
|
117
|
+
}
|
|
118
|
+
if (quarantined) {
|
|
119
|
+
if (result) result.quarantined = true;
|
|
120
|
+
options.onEvent?.({ type: "session:workspace", quarantined: true });
|
|
121
|
+
}
|
|
122
|
+
if (!quarantined) {
|
|
123
|
+
try { privateGit?.publish(); }
|
|
124
|
+
finally { workspace.release(); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function executeSession({
|
|
72
130
|
taskId, taskLink, description, createTask, tracker, config = {},
|
|
73
131
|
project, team, sessionUrl, cwd,
|
|
74
132
|
onEvent, apiKey, serverUrl, sessionId, onChild, abortSignal,
|
|
75
133
|
soloAgent = null, childStrategy = null,
|
|
76
134
|
runQuery = defaultRunQuery,
|
|
77
135
|
authCheck = checkClaudeAuth,
|
|
136
|
+
sourceCwd = cwd, workspaceRecord, workspaceStateDir, resumingWorkspace = false,
|
|
137
|
+
workspaceGitEnv = {},
|
|
138
|
+
registerCleanup,
|
|
78
139
|
}) {
|
|
79
140
|
const startedAt = Date.now();
|
|
80
|
-
const emit = event =>
|
|
141
|
+
const emit = event => {
|
|
142
|
+
if (event.type === "session:update" && event.taskId) taskId = event.taskId;
|
|
143
|
+
onEvent?.({ ...event, timestamp: timestamp() });
|
|
144
|
+
};
|
|
145
|
+
const outcomeIds = new Set();
|
|
146
|
+
const outcomeRepo = githubRepository(sourceCwd || cwd);
|
|
81
147
|
|
|
82
148
|
// Solo mode: one agent, one phase, full tools, no lead and no review gate.
|
|
83
149
|
const solo = soloAgent ? team.find(a => a.name === soloAgent) : null;
|
|
@@ -92,11 +158,14 @@ export async function runSession({
|
|
|
92
158
|
sessionNumber: 1,
|
|
93
159
|
agents: teamNames,
|
|
94
160
|
cliVersion: CLI_VERSION,
|
|
161
|
+
branch: workspaceRecord?.branch || null,
|
|
162
|
+
workspace: workspaceRecord ? { id: workspaceRecord.id, branch: workspaceRecord.branch, baseBranch: workspaceRecord.baseRef.replace(/^refs\/(heads|remotes)\//, "") } : null,
|
|
95
163
|
});
|
|
96
164
|
|
|
97
165
|
// --- credentials + preflight (before any sandbox or child) ---------------
|
|
98
166
|
const trackerCreds = await fetchTrackerCredentials(project?.name, apiKey, serverUrl);
|
|
99
|
-
|
|
167
|
+
abortSignal?.throwIfAborted();
|
|
168
|
+
const resolvedToken = resolveGitHubCreds({ cwd: sourceCwd, trackerCreds }).GITHUB_TOKEN;
|
|
100
169
|
const creds = { ...trackerCreds, GITHUB_TOKEN: resolvedToken || trackerCreds.GITHUB_TOKEN };
|
|
101
170
|
|
|
102
171
|
const failStart = (code, message) => {
|
|
@@ -115,8 +184,9 @@ export async function runSession({
|
|
|
115
184
|
// Claude itself must be able to authenticate as a standalone process, with
|
|
116
185
|
// the environment the child will actually get. Checked before the sandbox
|
|
117
186
|
// exists so a missing login is one clear message, not a crashed phase.
|
|
118
|
-
const dotenv = loadDotEnv(cwd);
|
|
187
|
+
const dotenv = { ...loadDotEnv(sourceCwd), ...loadDotEnv(cwd) };
|
|
119
188
|
const auth = await authCheck({ env: buildChildEnv({ dotenv }) });
|
|
189
|
+
abortSignal?.throwIfAborted();
|
|
120
190
|
if (!auth.ok) return failStart("CLAUDE_NOT_LOGGED_IN", `${auth.detail}.\n${auth.hint || ""}`.trim());
|
|
121
191
|
|
|
122
192
|
const sandbox = createScratchHome({
|
|
@@ -128,6 +198,7 @@ export async function runSession({
|
|
|
128
198
|
email: creds.JIRA_EMAIL || "agentdesk@local",
|
|
129
199
|
},
|
|
130
200
|
});
|
|
201
|
+
registerCleanup?.(() => sandbox.cleanup());
|
|
131
202
|
|
|
132
203
|
const abortController = new AbortController();
|
|
133
204
|
const onExternalAbort = () => abortController.abort();
|
|
@@ -135,13 +206,17 @@ export async function runSession({
|
|
|
135
206
|
else abortSignal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
136
207
|
|
|
137
208
|
// --- session memory (engine-owned) ---------------------------------------
|
|
138
|
-
const stateDir = join(cwd, ".agentdesk");
|
|
209
|
+
const stateDir = workspaceStateDir || join(cwd, ".agentdesk");
|
|
139
210
|
const memoryPath = join(stateDir, "session-memory.md");
|
|
140
211
|
const findingsPath = join(stateDir, "review-findings.json");
|
|
141
212
|
try { mkdirSync(stateDir, { recursive: true }); } catch {}
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
213
|
+
if (!resumingWorkspace || !existsSync(memoryPath)) {
|
|
214
|
+
if (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
|
|
215
|
+
try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
|
|
216
|
+
writeFileSync(memoryPath, `# Session Memory\n\n## Task\n- ID: ${taskId}\n${taskLink ? `- Link: ${taskLink}\n` : ""}\n`);
|
|
217
|
+
} else {
|
|
218
|
+
appendFileSync(memoryPath, `\n## Resumed session\n${sessionUrl || sessionId}\n`);
|
|
219
|
+
}
|
|
145
220
|
const memoryText = () => { try { return readFileSync(memoryPath, "utf-8").trim(); } catch { return ""; } };
|
|
146
221
|
const appendMemory = text => { try { appendFileSync(memoryPath, `\n${text}`); } catch {} };
|
|
147
222
|
|
|
@@ -170,7 +245,7 @@ export async function runSession({
|
|
|
170
245
|
const { agents, allowedTools, lead } = solo
|
|
171
246
|
? soloDefinition(solo)
|
|
172
247
|
: agentsForPhase({ phase, team, phaseModels: config.phaseModels });
|
|
173
|
-
|
|
248
|
+
let prompt = solo
|
|
174
249
|
? renderSoloPrompt({ agent: solo, taskId, taskLink, description, tracker, config, project, sessionUrl, cwd, childStrategy })
|
|
175
250
|
: renderPhasePrompt({
|
|
176
251
|
phase, taskId, taskLink, description,
|
|
@@ -179,15 +254,31 @@ export async function runSession({
|
|
|
179
254
|
sessionMemory: memoryText(),
|
|
180
255
|
retryVerdict: phase === "EXECUTION" && lastVerdict && lastVerdict.outcome !== "APPROVED" ? lastVerdict : null,
|
|
181
256
|
});
|
|
257
|
+
if (workspaceRecord) {
|
|
258
|
+
prompt += `\n\n## SESSION WORKSPACE\nAll file reads, writes, shell commands and Git operations must use ${cwd}.\nThe session branch ${workspaceRecord.branch} is already checked out; use it instead of creating or switching branches. The starting branch is ${workspaceRecord.baseRef}. Keep this branch until the session ends. Runtime session memory lives at ${memoryPath}.\n`;
|
|
259
|
+
}
|
|
260
|
+
prompt += `\n\n## Confirmed session outcomes\nWhen creating a PR, use a standalone gh pr create command with explicit --repo ${outcomeRepo || "OWNER/REPO"} and --head ${workspaceRecord?.branch || "BRANCH"}. Do not chain it with other commands; preserve its stdout so the harness can confirm creation. Never create another PR just to record an outcome.\n`;
|
|
261
|
+
if (["SUMMARY", "SOLO"].includes(phase)) {
|
|
262
|
+
prompt += `For the final substantive tracker reply only, include the literal marker ${replyMarker(sessionId)} in the posted comment. Never mark startup/progress comments. Keep the successful provider response intact (no jq, redirects or pipelines). For GitHub use standalone gh issue comment ${taskId} --repo ${outcomeRepo || "OWNER/REPO"} --body with a literal body. For Jira use standalone curl with a literal JSON --data-raw payload and the task's comment endpoint. For Linear use standalone curl with a literal JSON --data-raw payload and a commentCreate mutation selecting success and comment { id url body issue { identifier } }. Do not post an extra reply just to record an outcome.\n`;
|
|
263
|
+
}
|
|
182
264
|
|
|
183
265
|
// Publishing is gated until Sam has audited in this EXECUTION phase.
|
|
184
266
|
if (phase === "EXECUTION") armPublishGate(state, lastVerdict?.outcome === "APPROVED" ? [] : (lastVerdict?.findings || []));
|
|
185
267
|
|
|
186
268
|
const mapper = createEventMapper({ leadAgent: lead, onEvent: emit });
|
|
187
269
|
const options = buildQueryOptions({
|
|
188
|
-
phase, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env }), model,
|
|
270
|
+
phase, cwd, env: buildChildEnv({ dotenv, sandboxEnv: sandbox.env, extra: workspaceGitEnv }), model,
|
|
189
271
|
agents, allowedTools, lead, state, config, abortController, sandbox, onChild,
|
|
272
|
+
extraWritePaths: workspaceStateDir ? [workspaceStateDir, workspaceRecord.tree || cwd] : [],
|
|
190
273
|
hookCallbacks: {
|
|
274
|
+
onToolResult: result => {
|
|
275
|
+
const outcome = captureOutcome({ ...result, phase, sessionId, taskId, repo: outcomeRepo,
|
|
276
|
+
branch: workspaceRecord?.branch || (result.tool === "Bash" && /^gh\s+pr\s+create\b/.test(result.input?.command || "") ? currentBranch(cwd) : null), tracker, config });
|
|
277
|
+
if (outcome && !outcomeIds.has(outcome.id)) {
|
|
278
|
+
outcomeIds.add(outcome.id);
|
|
279
|
+
emit({ type: "session:outcome", project: project?.name || null, outcome });
|
|
280
|
+
}
|
|
281
|
+
},
|
|
191
282
|
onSubagentStop: ({ agentType }) => {
|
|
192
283
|
onSubagentStopped(state, { phase, agentType });
|
|
193
284
|
if (agentType) emit({ type: "agent:message", agent: agentType, tag: "SAY", message: `${agentType} finished and reported back.` });
|
|
@@ -221,7 +312,7 @@ export async function runSession({
|
|
|
221
312
|
const detail = thrown?.message || summary.subtype || "error";
|
|
222
313
|
emit({ type: "session:error", code: "PHASE_FAILED", message: `${phase} failed (${detail}) — session incomplete.` });
|
|
223
314
|
handoff = true;
|
|
224
|
-
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps });
|
|
315
|
+
writeResumeFile({ cwd, taskId, sessionUrl, phase, duration: seconds(startedAt), steps: totals.steps, workspaceId: workspaceRecord?.id });
|
|
225
316
|
break;
|
|
226
317
|
}
|
|
227
318
|
|
package/cli/engine/spawn.mjs
CHANGED
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn } from "child_process";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
|
-
import { dirname } from "path";
|
|
19
|
+
import { dirname, join } from "path";
|
|
20
20
|
import { wrapIsolatedSpawn } from "../session-isolation.mjs";
|
|
21
21
|
import { killTree, trackChild, installExitGuards } from "../proc.mjs";
|
|
22
|
+
import { workspaceGitPaths } from "../worktrees.mjs";
|
|
22
23
|
|
|
23
24
|
// Resolve the directory the SDK's bundled Claude binary lives in so the strict
|
|
24
25
|
// sandbox can expose it read-only. The SDK resolves the platform package
|
|
@@ -40,18 +41,22 @@ export function bundledClaudeDir() {
|
|
|
40
41
|
// onChild — daemon/team hook so cancel can reach the live process
|
|
41
42
|
// extraReadPaths — additional read-only paths for the strict sandbox
|
|
42
43
|
// onIsolation — called once with the isolation descriptor (for logging)
|
|
43
|
-
export function createSandboxedSpawn({ sandbox, onChild, extraReadPaths = [], onIsolation } = {}) {
|
|
44
|
+
export function createSandboxedSpawn({ sandbox, onChild, extraReadPaths = [], extraWritePaths = [], onIsolation } = {}) {
|
|
44
45
|
installExitGuards();
|
|
45
46
|
let reported = false;
|
|
46
47
|
|
|
47
48
|
return function spawnClaudeCodeProcess(opts) {
|
|
49
|
+
const sharedGit = workspaceGitPaths(opts.cwd);
|
|
48
50
|
const wrapped = wrapIsolatedSpawn({
|
|
49
51
|
cmd: opts.command,
|
|
50
52
|
args: opts.args,
|
|
51
53
|
cwd: opts.cwd,
|
|
52
54
|
scratchHome: sandbox.home,
|
|
53
|
-
extraReadPaths: [bundledClaudeDir(), ...extraReadPaths].filter(Boolean),
|
|
55
|
+
extraReadPaths: [bundledClaudeDir(), ...sharedGit, ...extraReadPaths].filter(Boolean),
|
|
56
|
+
extraWritePaths,
|
|
57
|
+
protectedPaths: sharedGit.length ? [...sharedGit, join(opts.env.GIT_WORK_TREE || opts.cwd, ".git"), ...(opts.env.GIT_DIR ? [join(dirname(opts.env.GIT_DIR), "..", "git-pending")] : [])] : [],
|
|
54
58
|
});
|
|
59
|
+
if (sharedGit.length && wrapped.isolation.policy !== "strict") throw new Error("Worktree sessions require strict kernel isolation; shared Git metadata cannot be exposed writable.");
|
|
55
60
|
|
|
56
61
|
if (!reported) {
|
|
57
62
|
reported = true;
|
|
@@ -70,7 +70,7 @@ function legacyMode() {
|
|
|
70
70
|
// policy. The engine passes the Agent SDK's bundled Claude binary here — it
|
|
71
71
|
// lives under an npm prefix (global or the project's node_modules), which the
|
|
72
72
|
// deny-default allowlist does not necessarily cover.
|
|
73
|
-
export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, extraReadPaths = [] }) {
|
|
73
|
+
export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, extraReadPaths = [], extraWritePaths = [], protectedPaths = [] }) {
|
|
74
74
|
const probe = probeIsolation();
|
|
75
75
|
if (probe.kind === "none") {
|
|
76
76
|
return { cmd, args, isolation: { kind: "none", reason: probe.reason } };
|
|
@@ -82,7 +82,7 @@ export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, extraReadPaths
|
|
|
82
82
|
const profilePath = join(scratchHome, "sandbox.sb");
|
|
83
83
|
const profile = policyKind === "legacy"
|
|
84
84
|
? macosProfileLegacy({ cwd, scratchHome })
|
|
85
|
-
: macosProfileStrict({ cwd, scratchHome, extraReadPaths });
|
|
85
|
+
: macosProfileStrict({ cwd, scratchHome, extraReadPaths, extraWritePaths, protectedPaths });
|
|
86
86
|
writeFileSync(profilePath, profile, { mode: 0o600 });
|
|
87
87
|
return {
|
|
88
88
|
cmd: "sandbox-exec",
|
|
@@ -94,7 +94,7 @@ export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, extraReadPaths
|
|
|
94
94
|
if (probe.kind === "bwrap") {
|
|
95
95
|
const bwrap = policyKind === "legacy"
|
|
96
96
|
? bwrapArgsLegacy({ cwd, scratchHome })
|
|
97
|
-
: bwrapArgsStrict({ cwd, scratchHome, extraReadPaths });
|
|
97
|
+
: bwrapArgsStrict({ cwd, scratchHome, extraReadPaths, extraWritePaths, protectedPaths });
|
|
98
98
|
return {
|
|
99
99
|
cmd: "bwrap",
|
|
100
100
|
args: [...bwrap, cmd, ...args],
|
|
@@ -115,7 +115,7 @@ function sbString(s) {
|
|
|
115
115
|
// paths and capabilities legitimate tools need. Anything under $HOME that
|
|
116
116
|
// isn't explicitly allowed is unreadable — that includes ~/Documents,
|
|
117
117
|
// browser cookie stores, other projects' .env files, etc.
|
|
118
|
-
function macosProfileStrict({ cwd, scratchHome, extraReadPaths = [] }) {
|
|
118
|
+
function macosProfileStrict({ cwd, scratchHome, extraReadPaths = [], extraWritePaths = [], protectedPaths = [] }) {
|
|
119
119
|
const home = homedir();
|
|
120
120
|
|
|
121
121
|
// AD-65: Claude walks up from cwd (git root, CLAUDE.md, project lookups) and
|
|
@@ -165,6 +165,7 @@ function macosProfileStrict({ cwd, scratchHome, extraReadPaths = [] }) {
|
|
|
165
165
|
`; Project cwd + scratch HOME — full read/write.`,
|
|
166
166
|
`(allow file-read* file-write* (subpath ${sbString(cwd)}))`,
|
|
167
167
|
`(allow file-read* file-write* (subpath ${sbString(scratchHome)}))`,
|
|
168
|
+
...extraWritePaths.map(p => `(allow file-read* file-write* (subpath ${sbString(p)}))`),
|
|
168
169
|
``,
|
|
169
170
|
`; Root directory + top-level symlinks (/etc → /private/etc, /var, /tmp).`,
|
|
170
171
|
`; dyld stats "/" during process startup on modern macOS; denying it aborts`,
|
|
@@ -227,6 +228,7 @@ function macosProfileStrict({ cwd, scratchHome, extraReadPaths = [] }) {
|
|
|
227
228
|
``,
|
|
228
229
|
`; Caller-supplied read-only paths (e.g. the Agent SDK's bundled Claude binary).`,
|
|
229
230
|
...extraReadPaths.filter(Boolean).map(p => `(allow file-read* (subpath ${sbString(p)}))`),
|
|
231
|
+
...protectedPaths.map(p => `(deny file-write* (subpath ${sbString(p)}))`),
|
|
230
232
|
``,
|
|
231
233
|
].join("\n");
|
|
232
234
|
}
|
|
@@ -273,7 +275,7 @@ function macosProfileLegacy() {
|
|
|
273
275
|
// AD-30 strict allowlist via bwrap. Read-only binds for system paths, --tmpfs
|
|
274
276
|
// over $HOME so nothing on the user's home directory is visible by default,
|
|
275
277
|
// then re-expose specific tool dirs that exist as read-only binds.
|
|
276
|
-
function bwrapArgsStrict({ cwd, scratchHome, extraReadPaths = [] }) {
|
|
278
|
+
function bwrapArgsStrict({ cwd, scratchHome, extraReadPaths = [], extraWritePaths = [], protectedPaths = [] }) {
|
|
277
279
|
const home = homedir();
|
|
278
280
|
const args = [
|
|
279
281
|
"--die-with-parent",
|
|
@@ -328,6 +330,9 @@ function bwrapArgsStrict({ cwd, scratchHome, extraReadPaths = [] }) {
|
|
|
328
330
|
for (const p of extraReadPaths.filter(Boolean)) {
|
|
329
331
|
args.push("--ro-bind-try", p, p);
|
|
330
332
|
}
|
|
333
|
+
for (const p of extraWritePaths) args.push("--bind", p, p);
|
|
334
|
+
// Last mount wins, including when the repository lives under writable /tmp.
|
|
335
|
+
for (const p of protectedPaths) args.push("--ro-bind", p, p);
|
|
331
336
|
|
|
332
337
|
return args;
|
|
333
338
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function sessionLimit(value = process.env.AGENTDESK_MAX_SESSIONS) {
|
|
2
|
+
if (value === undefined || value === "") return 3;
|
|
3
|
+
const limit = Number(value);
|
|
4
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 32) throw new Error("AGENTDESK_MAX_SESSIONS must be an integer from 1 to 32");
|
|
5
|
+
return limit;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Capacity belongs to the daemon, not the network connection or UI status.
|
|
9
|
+
// Hold reservations through confirmation, cancellation and engine teardown.
|
|
10
|
+
export function createSessionQueue({ limit = sessionLimit(), run, onQueued = () => {}, onError = () => {} }) {
|
|
11
|
+
const active = new Map();
|
|
12
|
+
const waiting = [];
|
|
13
|
+
let closed = false;
|
|
14
|
+
function drain() {
|
|
15
|
+
if (closed) return;
|
|
16
|
+
while (active.size < limit) {
|
|
17
|
+
const index = waiting.findIndex(job => !job.key || ![...active.values()].some(other => other.key === job.key));
|
|
18
|
+
if (index < 0) break;
|
|
19
|
+
const job = waiting.splice(index, 1)[0];
|
|
20
|
+
active.set(job.sessionId, job);
|
|
21
|
+
Promise.resolve().then(() => run(job)).catch(error => onError(job, error)).finally(() => {
|
|
22
|
+
active.delete(job.sessionId);
|
|
23
|
+
drain();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
enqueue(job) {
|
|
29
|
+
if (closed) throw new Error("Daemon is shutting down");
|
|
30
|
+
if (active.has(job.sessionId) || waiting.some(other => other.sessionId === job.sessionId)) return;
|
|
31
|
+
if (waiting.length >= 100) throw new Error("Daemon queue is full (100 waiting sessions)");
|
|
32
|
+
waiting.push(job);
|
|
33
|
+
drain();
|
|
34
|
+
if (!active.has(job.sessionId)) onQueued(job);
|
|
35
|
+
},
|
|
36
|
+
cancel(sessionId) {
|
|
37
|
+
const index = waiting.findIndex(job => job.sessionId === sessionId);
|
|
38
|
+
if (index < 0) return false;
|
|
39
|
+
waiting.splice(index, 1);
|
|
40
|
+
return true;
|
|
41
|
+
},
|
|
42
|
+
get activeIds() { return [...active.keys()]; },
|
|
43
|
+
get queuedIds() { return waiting.map(job => job.sessionId); },
|
|
44
|
+
close() { closed = true; waiting.length = 0; },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Abort settles the confirmation even if the input source never responds.
|
|
49
|
+
export function confirmWithAbort(ask, signal) {
|
|
50
|
+
if (signal.aborted) return Promise.resolve(false);
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const abort = () => resolve(false);
|
|
53
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
54
|
+
Promise.resolve().then(() => signal.aborted ? false : ask()).then(resolve, reject)
|
|
55
|
+
.finally(() => signal.removeEventListener("abort", abort));
|
|
56
|
+
});
|
|
57
|
+
}
|
package/cli/team.mjs
CHANGED
|
@@ -203,11 +203,14 @@ export async function runTeam(taskId, opts = {}) {
|
|
|
203
203
|
process.on("SIGINT", onSigint);
|
|
204
204
|
|
|
205
205
|
let result;
|
|
206
|
+
let workspaceId = null;
|
|
206
207
|
try {
|
|
207
208
|
result = await runSession({
|
|
208
209
|
taskId, taskLink, description, createTask, tracker, config,
|
|
209
210
|
project, team, sessionUrl, cwd,
|
|
210
211
|
sessionId,
|
|
212
|
+
workspace: opts.workspace,
|
|
213
|
+
onWorkspace: record => { workspaceId = record?.id || null; },
|
|
211
214
|
onEvent: vizSend,
|
|
212
215
|
apiKey,
|
|
213
216
|
serverUrl: agentdeskServer,
|
|
@@ -215,11 +218,16 @@ export async function runTeam(taskId, opts = {}) {
|
|
|
215
218
|
childStrategy: opts.childStrategy || null,
|
|
216
219
|
abortSignal: abort.signal,
|
|
217
220
|
});
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error(`Session could not start: ${error.message}`);
|
|
223
|
+
result = { error: error.message, duration: "0s", steps: 0, inputTokens: 0, outputTokens: 0 };
|
|
218
224
|
} finally {
|
|
219
225
|
process.removeListener("SIGINT", onSigint);
|
|
220
226
|
clearInterval(heartbeatInterval);
|
|
221
227
|
}
|
|
222
228
|
|
|
229
|
+
const resumeCommand = `agentdesk team ${taskId}${workspaceId ? ` --resume-worktree ${workspaceId}` : " --no-worktree"}`;
|
|
230
|
+
|
|
223
231
|
if (result.error) {
|
|
224
232
|
const red = "\x1b[31m";
|
|
225
233
|
console.log(`\n━━━ ${red}SESSION COULD NOT START${reset} ━━━`);
|
|
@@ -227,8 +235,8 @@ export async function runTeam(taskId, opts = {}) {
|
|
|
227
235
|
} else if (result.aborted) {
|
|
228
236
|
const yellow = "\x1b[33m";
|
|
229
237
|
console.log(`\n━━━ ${yellow}STOPPED${reset} ━━━`);
|
|
230
|
-
console.log(
|
|
231
|
-
console.log(` Resume with: ${cyan}
|
|
238
|
+
console.log(result.quarantined ? " Cancelled — workspace quarantined until remaining processes exit." : " Cancelled — Claude terminated.");
|
|
239
|
+
console.log(` Resume with: ${cyan}${resumeCommand}${reset}\n`);
|
|
232
240
|
} else if (result.status === "handoff" && result.reviewResolved === false) {
|
|
233
241
|
const yellow = "\x1b[33m";
|
|
234
242
|
console.log(`\n━━━ ${yellow}NEEDS REVIEW${reset} ━━━`);
|
|
@@ -239,7 +247,7 @@ export async function runTeam(taskId, opts = {}) {
|
|
|
239
247
|
console.log(`\n━━━ ${yellow}HANDOFF${reset} ━━━`);
|
|
240
248
|
console.log(` Session paused — likely hit Claude rate/context limit.`);
|
|
241
249
|
console.log(` Resume file saved to .agentdesk-resume.md`);
|
|
242
|
-
console.log(` Resume with: ${cyan}
|
|
250
|
+
console.log(` Resume with: ${cyan}${resumeCommand}${reset}\n`);
|
|
243
251
|
} else {
|
|
244
252
|
console.log(`\n━━━ DONE ━━━`);
|
|
245
253
|
}
|