@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.
@@ -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
+ }
@@ -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);
@@ -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 => onEvent?.({ ...event, timestamp: timestamp() });
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
- const resolvedToken = resolveGitHubCreds({ cwd, trackerCreds }).GITHUB_TOKEN;
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 (archiveStaleMemory(memoryPath)) console.error("[agentdesk] archived stale session-memory.md from a previous run");
143
- try { if (existsSync(findingsPath)) unlinkSync(findingsPath); } catch {}
144
- writeFileSync(memoryPath, `# Session Memory\n\n## Task\n- ID: ${taskId}\n${taskLink ? `- Link: ${taskLink}\n` : ""}\n`);
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
- const prompt = solo
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
 
@@ -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(` Cancelled — Claude terminated.`);
231
- console.log(` Resume with: ${cyan}agentdesk team ${taskId}${reset}\n`);
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}agentdesk team ${taskId}${reset}\n`);
250
+ console.log(` Resume with: ${cyan}${resumeCommand}${reset}\n`);
243
251
  } else {
244
252
  console.log(`\n━━━ DONE ━━━`);
245
253
  }