@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 CHANGED
@@ -8,14 +8,27 @@ All user-facing changes to AgentDesk. Each entry is tagged:
8
8
 
9
9
  Internal refactors, infrastructure changes, and architectural notes are not listed here.
10
10
 
11
- ## [0.28.3] — 2026-09-19
12
-
13
- ### Changed
14
- - `[UI]` The docked team status bar now defaults to a compact one-line strip (avatars + current status) instead of the full grid, with a drag handle (and keyboard resize) to expand it.
15
- - `[Both]` Session messages are filtered to the session's declared roster, so nested/ad-hoc subagents (e.g. a generic search delegate) no longer show their internal narration in the feed as if they were a team member.
11
+ ## [Unreleased]
16
12
 
17
13
  ### Fixed
18
- - `[CLI]` Every phase (INTAKE, PLAN, EXECUTION, REVIEW, SUMMARY, SOLO) told the model to answer with the raw handoff JSON as its final chat reply, so the model printed that JSON into the session feed on top of the SDK's separate structured-output channel. Phase prompts no longer ask for that, and a code-side guard strips it if a model still echoes it.
14
+ - `[Both]` Daemon sessions now have a configurable concurrency limit (default 3), with excess requests queued. Cancellation during confirmation stays stopped; workspace quarantine survives cancellation and restart, including when processes outlive a successful session.
15
+ - `[Both]` Worktree Git commands ignore inherited Git config overrides, lock cleanup tolerates concurrent removal, runtime config healing is restored, handoff sessions remain open, and outcome branch validation includes the 300-character boundary.
16
+ - `[Both]` Session restoration checks persisted ownership; outcome capture tolerates diagnostic output and malformed receipts. Live outcomes survive dashboard loading races and appear on the mobile Run screen.
17
+ - `[Both]` Phase handoff JSON is captured automatically instead of repeated in chat; a fallback filter suppresses duplicate structured-output objects in agent messages.
18
+ - `[UI]` Internal helpers outside a session's declared team no longer appear in its roster or contribute SAY/ACT messages to the conversation feed.
19
+ - `[Both]` Cancellation now stops waiting after 30 seconds and quarantines worktrees with surviving processes. Daemon startup and periodic checks recover stale locks without deleting unfinished work.
20
+ - `[Both]` Session agents can no longer modify shared Git refs or hooks. Private Git state is published back only to the session branch, with conflict checks.
21
+ - `[Both]` Cancellation releases the workspace after child processes stop even if the agent connection stalls. Dashboard sessions retain their tracker links.
22
+ - `[CLI]` Conflicting resume and branch options now report an error.
23
+ - `[UI]` Daemon status counts sessions within the current account without scanning other accounts.
24
+
25
+ ### Added
26
+ - `[Both]` Confirmed PR creation and final tracker replies are recorded during the session and shown separately from its status, surviving disconnects and later failures. Background outcome recovery remains deferred.
27
+ - `[UI]` The sidebar shows the 12 most recent past sessions by default, with a More control and automatic reveal of a selected older session.
28
+ - `[UI]` The docked team footer starts as a compact avatar/status row. Drag its handle upward for the full grid, or use arrow keys and Enter to resize it.
29
+ - `[Both]` New sessions use separate Git worktrees by default, so tasks can run concurrently on different branches in the same repository. Choose the starting branch, create or reuse a branch, or opt out to use the existing directory.
30
+ - `[Both]` Archived sessions automatically clean up clean, merged worktrees while retaining branches. Unfinished work stays available to resume, with a review reminder after 14 days and explicit confirmation before discarding files.
31
+ - `[UI]` A Worktrees panel shows session directories, cleanup status, and resume controls.
19
32
 
20
33
  ## [0.28.2] — 2026-09-18
21
34
 
package/README.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # AgentDesk
2
2
 
3
+ ### Session status and outcomes
4
+
5
+ Session status remains independent of accomplishments: **Stopped · PR created** still means stopped, not approved or complete. Confirmed PRs and substantive tracker replies appear as separate links in the sidebar and status panel. Outcomes are saved immediately, survive later session updates and server restarts, and are deduplicated on replay. Archived sessions retain them; explicit session deletion removes the associated records.
6
+
7
+ Capture currently recognizes successful, standalone `gh pr create` commands with an explicit repository and head branch; marked final `gh issue comment` calls; and marked Jira/Linear comment-creation responses from standalone `curl` calls using literal JSON payloads. The engine supplies instructions for a session-specific `[AgentDesk reply:…]` marker in the final task reply, not startup/progress comments. Jira responses must include the created comment's ID/body/self URL; Linear mutations must return `success` and `comment { id url body issue { identifier } }`. Provider receipts must match the session's task/repository. Raw command output and credentials are not stored with outcomes.
8
+
9
+ Arbitrary shell scripts, pipelines, redirected output, file-based comment payloads, unsupported providers/hosts, and unconfirmed responses remain unknown rather than being guessed from chat or a PR lookup. A crash before the receipt reaches durable storage can still lose an outcome. There is no background recovery or historical backfill in this release.
10
+
11
+ The sidebar initially shows 12 past sessions across its date groups. **More** reveals older loaded sessions; an explicitly selected older session is revealed automatically. Active sessions are not limited.
12
+
13
+ ### Parallel sessions with Git worktrees
14
+
15
+ New team and solo sessions create a separate Git worktree and branch by default. Agents, their shell commands, file tools, and Git operations use that session directory. Multiple sessions can work on the same repository concurrently. Existing sessions keep their original directories.
16
+
17
+ The daemon runs at most **3 sessions concurrently** by default, including sessions awaiting confirmation or completing teardown. Set `AGENTDESK_MAX_SESSIONS=5 agentdesk daemon` to change the limit (1–32). Excess requests wait in a cancellable in-memory daemon queue (up to 100); non-worktree sessions sharing a repository are serialized. The limit applies to daemon-managed sessions, not independently launched `agentdesk team` processes. Queued requests survive a network reconnect but not a daemon process restart.
18
+
19
+ ```sh
20
+ agentdesk team -d "Add search" --base-branch main --branch feature/search
21
+ agentdesk team -d "Fix navigation" --base-branch main
22
+ agentdesk team TASK-123 --existing-branch feature/unfinished --base-branch main
23
+ agentdesk team TASK-123 --no-worktree
24
+ agentdesk team TASK-123 --resume-worktree SESSION-ID
25
+ ```
26
+
27
+ The dashboard's **Run Team** dialog offers the same choices. Git refuses an existing branch that is already checked out elsewhere. A starting branch must exist locally (fetch remote branches first); an unborn or detached HEAD requires selecting a valid starting branch. Opting out uses the current directory and allows one AgentDesk session in that directory at a time.
28
+
29
+ Worktrees start from committed files. Uncommitted source files, dependencies, and ignored files are not copied or symlinked. Project configuration and environment values are loaded from the registered project, while commands run in the worktree. Install dependencies within each worktree as needed. Worktrees and retained runtime notes live under `~/.agentdesk/workspaces/`; the CLI prints the working directory when a session starts.
30
+
31
+ Agents use private Git refs, configuration, hooks, and an index, borrowing existing objects read-only. The shared Git database is read-only inside the kernel sandbox. When a session exits, AgentDesk validates new objects and publishes only its branch and index; it refuses to overwrite a branch that moved independently. Private Git state is retained if publication fails and can be resumed when the shared branch has not moved. Worktree sessions require strict kernel isolation (macOS sandbox-exec or Linux bubblewrap); disabling it or selecting the legacy policy will prevent the session from starting. `--resume-worktree` cannot be combined with branch-selection flags.
32
+
33
+ Cancellation waits at most 30 seconds for process teardown. If processes still remain, the session ends but its worktree is quarantined: no Git publication, resume, or deletion until those processes exit. Other worktrees remain usable. The daemon checks stale locks on startup and every 15 seconds, preserving locks held by surviving agent PIDs or process groups even if the previous daemon crashed. Recovery removes only stale locks, never unfinished files or branches; unpublished private Git state remains available for recovery on resume. Inaccessible or unidentifiable processes require manual review rather than automatic unlocking.
34
+
35
+ The same live-process check applies after a successful session. Quarantine is recorded separately from the session result and survives server restart; the dashboard reports quarantine at shutdown, while Worktrees shows the current recovery state. Handoff and queued sessions remain open for display and are excluded from bulk inactive-session deletion; only active/stale sessions count as running.
36
+
37
+ Use **Worktrees** in the dashboard to inspect retained directories, resume work, or remove them. Archiving or deleting a session requests cleanup, including when the daemon is temporarily offline. Automatic removal requires a clean directory (including no untracked or ignored files) and commits reachable from the selected starting branch. Branches are kept. Merge checks use local refs; fetch first if a merge happened remotely. Squash merges do not prove commit ancestry and require manual review.
38
+
39
+ Unfinished work is retained, with a review reminder after 14 days of session inactivity. Age never triggers deletion. **Discard files** requires typing the branch name and permanently removes uncommitted, untracked, and ignored files; committed branch history remains. Automatic cleanup is retried while the daemon and dashboard are connected. Update both the server and daemon to use this feature.
40
+
3
41
  AI team orchestrator for [Claude Code](https://claude.ai/code). Run collaborative agent sessions from your terminal and watch them live on [agentdesk.live](https://agentdesk.live).
4
42
 
5
43
  AgentDesk spawns a team of AI agents inside Claude Code that collaborate on your tasks. The default team includes 8 built-in agents, and you can add custom agents to fit your workflow.
package/bin/agentdesk.mjs CHANGED
@@ -50,6 +50,11 @@ if (!command || command === "help" || command === "--help") {
50
50
  Options:
51
51
  --description, -d Task description or requirements
52
52
  --cwd Working directory (defaults to current)
53
+ --no-worktree Use the existing directory instead of an isolated worktree
54
+ --base-branch NAME Starting branch (defaults to current branch)
55
+ --branch NAME Name of the new session branch (default: generated)
56
+ --existing-branch NAME Use an existing local branch in a new worktree
57
+ --resume-worktree ID Resume a retained worktree
53
58
  --child-strategy How to handle child tasks: "inline" (default) or "branch"
54
59
 
55
60
  Sessions run five phases — INTAKE → PLAN → EXECUTION → REVIEW → SUMMARY — each as
@@ -111,7 +116,8 @@ if (AGENT_NAMES.includes(command?.toLowerCase())) {
111
116
  let cwd = process.cwd();
112
117
  let taskId = null;
113
118
  let childStrategy = null;
114
- const remaining = args.slice(1);
119
+ const { parseWorkspaceArgs } = await import("../cli/worktree-options.mjs");
120
+ const { remaining, workspace } = parseWorkspaceArgs(args.slice(1));
115
121
 
116
122
  for (let i = 0; i < remaining.length; i++) {
117
123
  if ((remaining[i] === "--description" || remaining[i] === "-d") && remaining[i + 1]) {
@@ -132,7 +138,7 @@ if (AGENT_NAMES.includes(command?.toLowerCase())) {
132
138
  }
133
139
 
134
140
  const { runTeam } = await import("../cli/team.mjs");
135
- const code = await runTeam(taskId, { description, cwd, soloAgent: command.charAt(0).toUpperCase() + command.slice(1).toLowerCase(), childStrategy });
141
+ const code = await runTeam(taskId, { description, cwd, workspace, soloAgent: command.charAt(0).toUpperCase() + command.slice(1).toLowerCase(), childStrategy });
136
142
  process.exit(code);
137
143
  }
138
144
 
@@ -166,7 +172,8 @@ else if (command === "team") {
166
172
  let cwd = process.cwd();
167
173
  let taskId = null;
168
174
  let childStrategy = null;
169
- const remaining = args.slice(1);
175
+ const { parseWorkspaceArgs } = await import("../cli/worktree-options.mjs");
176
+ const { remaining, workspace } = parseWorkspaceArgs(args.slice(1));
170
177
 
171
178
  for (let i = 0; i < remaining.length; i++) {
172
179
  if ((remaining[i] === "--description" || remaining[i] === "-d") && remaining[i + 1]) {
@@ -189,7 +196,7 @@ else if (command === "team") {
189
196
  }
190
197
 
191
198
  const { runTeam } = await import("../cli/team.mjs");
192
- const code = await runTeam(taskId, { description, cwd, childStrategy });
199
+ const code = await runTeam(taskId, { description, cwd, workspace, childStrategy });
193
200
  process.exit(code);
194
201
  }
195
202
 
package/cli/config.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  // on-disk file stays a cached view of the server. Credentials never land
10
10
  // on disk — they stay in the server DB, encrypted.
11
11
 
12
- import { existsSync, readFileSync, writeFileSync } from "fs";
12
+ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
13
+ import { randomUUID } from "node:crypto";
13
14
  import { join } from "path";
14
15
 
15
16
  const DEFAULTS = {
@@ -184,11 +185,15 @@ export async function pushConfig(apiKey, serverUrl, projectName, payload) {
184
185
  }
185
186
 
186
187
  function writeLocalConfig(configPath, config) {
188
+ const temp = `${configPath}.${randomUUID()}.tmp`;
187
189
  try {
188
190
  const payload = stripCredentials(config);
189
- writeFileSync(configPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
191
+ writeFileSync(temp, JSON.stringify(payload, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
192
+ renameSync(temp, configPath);
190
193
  } catch (err) {
191
194
  console.error(`Warning: Failed to write .agentdesk.json: ${err.message}`);
195
+ } finally {
196
+ try { unlinkSync(temp); } catch {}
192
197
  }
193
198
  }
194
199
 
package/cli/daemon.mjs CHANGED
@@ -15,6 +15,8 @@ import { getRegisteredProjects, registerLocalProject, claimLocalProjects } from
15
15
  import { buildTrackerUrl } from "./tracker-url.mjs";
16
16
  import { fileURLToPath } from "url";
17
17
  import { dirname } from "path";
18
+ import { repositoryInfo, listWorkspaces, inspectWorkspace, cleanupWorkspace, recoverWorkspaceLocks } from "./worktrees.mjs";
19
+ import { createSessionQueue, sessionLimit, confirmWithAbort } from "./session-queue.mjs";
18
20
 
19
21
  const __dirname_daemon = dirname(fileURLToPath(import.meta.url));
20
22
  const DAEMON_VERSION = JSON.parse(readFileSync(join(__dirname_daemon, "../package.json"), "utf-8")).version;
@@ -125,6 +127,16 @@ export async function runDaemon() {
125
127
  const cyan = "\x1b[36m";
126
128
  const reset = "\x1b[0m";
127
129
 
130
+ // Recover crashes before accepting new work, then reap quarantined leases as
131
+ // their last process exits. Recovery never deletes a worktree or its branch.
132
+ const recoverLocks = () => {
133
+ try { recoverWorkspaceLocks(); }
134
+ catch (error) { console.error(`[agentdesk] Workspace recovery failed: ${error.message}`); }
135
+ };
136
+ recoverLocks();
137
+ const recoveryTimer = setInterval(recoverLocks, 15000);
138
+ recoveryTimer.unref();
139
+
128
140
  // 1. Load credentials
129
141
  const apiKey = getStoredApiKey();
130
142
  if (!apiKey) {
@@ -264,7 +276,22 @@ export async function runDaemon() {
264
276
  const buffer = new RingBuffer();
265
277
 
266
278
  // Active session state
267
- let activeSession = null; // { sessionId, projectId, child, startedAt, filePathsTouched }
279
+ const activeSessions = new Map();
280
+ let confirmationQueue = Promise.resolve();
281
+ const maxSessions = sessionLimit();
282
+ const sessionQueue = createSessionQueue({ limit: maxSessions, run: handleStartSession,
283
+ onQueued: job => sendBuffered(job.sessionId, { type: "session:update", status: "queued" }),
284
+ onError: job => send({ type: "daemon:error", sessionId: job.sessionId, error: "Unable to start queued session" }),
285
+ });
286
+ function enqueueSession(job) {
287
+ const project = projects.find(p => p.id === job.projectId);
288
+ if (!project) { send({ type: "daemon:error", sessionId: job.sessionId, error: "Unknown project" }); return; }
289
+ try {
290
+ let key = job.workspace?.resumeId ? `workspace:${job.workspace.resumeId}` : null;
291
+ if (job.workspace?.enabled === false) key = `repo:${repositoryInfo(project.path).repo}`;
292
+ sessionQueue.enqueue({ ...job, key });
293
+ } catch (error) { send({ type: "daemon:error", sessionId: job.sessionId, error: error.message }); }
294
+ }
268
295
 
269
296
  function send(data) {
270
297
  if (connected && ws?.readyState === WebSocket.OPEN) {
@@ -304,6 +331,8 @@ export async function runDaemon() {
304
331
  send({
305
332
  type: "daemon:connect",
306
333
  version: DAEMON_VERSION,
334
+ worktrees: true,
335
+ maxSessions,
307
336
  projects: projects.map(p => ({ id: p.id, name: p.name })),
308
337
  });
309
338
 
@@ -318,7 +347,7 @@ export async function runDaemon() {
318
347
 
319
348
  // Start heartbeat
320
349
  clearInterval(heartbeatTimer);
321
- heartbeatTimer = setInterval(() => send({ type: "daemon:heartbeat" }), 30000);
350
+ heartbeatTimer = setInterval(() => send({ type: "daemon:heartbeat", activeSessionIds: [...activeSessions.keys()], queuedSessionIds: sessionQueue.queuedIds }), 30000);
322
351
 
323
352
  console.log(` ${dim}Waiting for sessions...${reset}\n`);
324
353
  }
@@ -331,12 +360,13 @@ export async function runDaemon() {
331
360
  }
332
361
 
333
362
  if (msg.type === "daemon:start-session") {
334
- handleStartSession(msg);
363
+ enqueueSession(msg);
335
364
  }
336
365
 
337
366
  if (msg.type === "daemon:cancel-session") {
338
367
  handleCancelSession(msg);
339
368
  }
369
+ if (msg.type === "daemon:workspace-request") handleWorkspaceRequest(msg);
340
370
  });
341
371
 
342
372
  ws.on("error", (err) => {
@@ -367,7 +397,7 @@ export async function runDaemon() {
367
397
  // terminal. Returns true if the user accepts. If stdin isn't a TTY (running
368
398
  // headless / no terminal), refuse — the safer default. Set
369
399
  // AGENTDESK_DAEMON_AUTO_ACCEPT=1 to skip this prompt for trusted automation.
370
- async function confirmIncomingSession({ project, taskId, prompt }) {
400
+ async function confirmIncomingSession({ project, taskId, prompt, signal }) {
371
401
  if (!process.stdin.isTTY) {
372
402
  console.log(" Refusing server-pushed session: no terminal to confirm at. Set AGENTDESK_DAEMON_AUTO_ACCEPT=1 to allow.");
373
403
  return false;
@@ -379,14 +409,42 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
379
409
  console.log(` Prompt: ${promptPreview}${String(prompt || "").length > 240 ? "..." : ""}`);
380
410
  console.log("");
381
411
  const rl = createInterface({ input: process.stdin, output: process.stdout });
382
- const answer = await new Promise(resolve => rl.question(" Approve this session? [y/N] ", resolve));
412
+ const close = () => rl.close();
413
+ signal.addEventListener("abort", close, { once: true });
414
+ const answer = await confirmWithAbort(() => new Promise(resolve => rl.question(" Approve this session? [y/N] ", resolve)), signal);
415
+ signal.removeEventListener("abort", close);
383
416
  rl.close();
384
417
  return /^y(es)?$/i.test(String(answer).trim());
385
418
  }
386
419
 
387
420
  // 4. Session handling
388
421
 
389
- async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride }) {
422
+ function handleWorkspaceRequest({ requestId, action, projectId, workspaceId, discard, confirmation }) {
423
+ try {
424
+ const projectPaths = projects.map(p => p.path);
425
+ let result;
426
+ if (action === "branches") {
427
+ const project = projects.find(p => p.id === projectId);
428
+ if (!project) throw new Error("Unknown project");
429
+ const { branches, currentBranch } = repositoryInfo(project.path);
430
+ result = { branches, currentBranch };
431
+ } else if (action === "list") {
432
+ result = listWorkspaces({ projectPaths }).map(record => {
433
+ if (record.archived && !record.removedAt) {
434
+ try { return cleanupWorkspace(record.id, { projectPaths }); } catch {}
435
+ }
436
+ return inspectWorkspace(record);
437
+ });
438
+ } else if (action === "cleanup" || action === "archive") {
439
+ result = cleanupWorkspace(workspaceId, { projectPaths, discard: discard === true, confirmation, archive: action === "archive" });
440
+ } else throw new Error("Unknown workspace action");
441
+ send({ type: "daemon:workspace-response", requestId, result });
442
+ } catch (error) {
443
+ send({ type: "daemon:workspace-response", requestId, error: error.message });
444
+ }
445
+ }
446
+
447
+ async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride, workspace }) {
390
448
  // Validate project against local allowlist
391
449
  const project = projects.find(p => p.id === projectId);
392
450
  if (!project) {
@@ -401,23 +459,8 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
401
459
  // command through to the victim machine without someone at the keyboard
402
460
  // saying yes. Opt out via AGENTDESK_DAEMON_AUTO_ACCEPT=1 for trusted
403
461
  // automation environments.
404
- if (process.env.AGENTDESK_DAEMON_AUTO_ACCEPT !== "1") {
405
- const accepted = await confirmIncomingSession({ project, taskId: remoteTaskId, prompt });
406
- if (!accepted) {
407
- console.log(` ${yellow}Rejected by user:${reset} ${dim}${sessionId}${reset}`);
408
- send({ type: "daemon:error", sessionId, error: "Session declined by daemon owner" });
409
- return;
410
- }
411
- }
412
-
413
- // Enforce max 1 concurrent session — set flag BEFORE any async work to prevent race
414
- if (activeSession) {
415
- console.log(` ${red}Rejected:${reset} session already running ${dim}(${activeSession.sessionId})${reset}`);
416
- send({ type: "daemon:error", sessionId, error: "Max concurrent sessions reached (1). Try again after the current session completes." });
417
- return;
418
- }
419
- // Claim the slot immediately (before any await) to prevent race conditions
420
- activeSession = {
462
+ if (activeSessions.has(sessionId)) return;
463
+ const activeSession = {
421
464
  sessionId, projectId,
422
465
  child: null,
423
466
  // Cancelling must stop the whole phase pipeline, not just the child that
@@ -427,6 +470,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
427
470
  startedAt: Date.now(),
428
471
  filePathsTouched: new Set(),
429
472
  };
473
+ activeSessions.set(sessionId, activeSession);
430
474
 
431
475
  console.log(` ${green}Starting session${reset} ${dim}${sessionId}${reset}`);
432
476
  console.log(` Project: ${project.name} ${dim}(${project.path})${reset}`);
@@ -435,11 +479,20 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
435
479
  const filePathsTouched = activeSession.filePathsTouched;
436
480
 
437
481
  try {
482
+ if (process.env.AGENTDESK_DAEMON_AUTO_ACCEPT !== "1") {
483
+ const approval = confirmationQueue.then(() => activeSession.cancelled ? false : confirmIncomingSession({ project, taskId: remoteTaskId, prompt, signal: activeSession.abort.signal }));
484
+ confirmationQueue = approval.catch(() => false);
485
+ const accepted = await approval;
486
+ if (activeSession.cancelled) return;
487
+ if (!accepted) throw new Error("Session declined by daemon owner");
488
+ }
489
+ if (activeSession.cancelled) return;
438
490
  // Load project config
439
491
  const detected = detectProject(project.path);
440
492
  const projectEnv = loadDotEnv(project.path);
441
493
  const projectApiKey = projectEnv.AGENTDESK_API_KEY || process.env.AGENTDESK_API_KEY || apiKey;
442
- const config = await loadConfig(project.path, { apiKey: projectApiKey, serverUrl: agentdeskServer, projectName: project.name });
494
+ const config = await loadConfig(project.path, { apiKey: projectApiKey, serverUrl: agentdeskServer, projectName: projectId });
495
+ if (activeSession.cancelled) return;
443
496
  if (screenshotsOverride !== undefined) config.screenshots = screenshotsOverride;
444
497
  const tracker = config.tracker || null;
445
498
 
@@ -463,10 +516,12 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
463
516
  description: prompt || "",
464
517
  createTask: !remoteTaskId && !!prompt && !!tracker,
465
518
  tracker, config,
466
- project: detected, team,
519
+ project: { ...detected, name: projectId }, team,
520
+ projectId,
467
521
  sessionUrl,
468
522
  sessionId,
469
523
  cwd: project.path,
524
+ workspace,
470
525
  apiKey,
471
526
  serverUrl: agentdeskServer,
472
527
  abortSignal: sessionAbort.signal,
@@ -474,7 +529,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
474
529
  // This was never wired: activeSession.child stayed null forever, so
475
530
  // every cancel reported "stopped" while Claude kept writing to the repo.
476
531
  onChild: (child) => {
477
- if (activeSession?.sessionId === sessionId) activeSession.child = child;
532
+ activeSession.child = child;
478
533
  },
479
534
  onEvent: (() => {
480
535
  // Stagger agent messages for real-time feel, flush on non-message events
@@ -500,7 +555,10 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
500
555
  }
501
556
 
502
557
  return (event) => {
503
- if (!activeSession || activeSession.sessionId !== sessionId) return;
558
+ // Confirmed accomplishments may arrive during cancellation teardown.
559
+ // They do not revive the session or emit a second terminal event.
560
+ if (["session:outcome", "session:workspace"].includes(event.type)) { sendBuffered(sessionId, event); return; }
561
+ if (activeSessions.get(sessionId) !== activeSession) return;
504
562
  // A cancelled session already sent its terminal event; the
505
563
  // orchestrator's own unwind must not send a second one.
506
564
  if (activeSession.cancelled) return;
@@ -546,13 +604,13 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
546
604
  filePathsTouched: [...filePathsTouched],
547
605
  });
548
606
 
549
- activeSession = null;
550
-
551
607
  } catch (err) {
552
608
  console.log(` ${red}Failed to start session:${reset} ${err.message}`);
609
+ if (activeSession.cancelled) return;
553
610
  // Send generic error to server — don't leak internal details (paths, config, etc.)
554
611
  send({ type: "daemon:error", sessionId, error: "Failed to start session on daemon" });
555
- activeSession = null;
612
+ } finally {
613
+ if (activeSessions.get(sessionId) === activeSession) activeSessions.delete(sessionId);
556
614
  }
557
615
  }
558
616
 
@@ -566,46 +624,36 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
566
624
  }
567
625
 
568
626
  function handleCancelSession({ sessionId }) {
569
- if (activeSession?.sessionId === sessionId) {
627
+ if (sessionQueue.cancel(sessionId)) {
628
+ sendBuffered(sessionId, { type: "session:end", status: "stopped", duration: "0s" });
629
+ return;
630
+ }
631
+ const activeSession = activeSessions.get(sessionId);
632
+ if (activeSession) {
570
633
  console.log(` ${yellow}Cancelling session${reset} ${dim}${sessionId}${reset}`);
571
634
  const duration = `${((Date.now() - activeSession.startedAt) / 1000).toFixed(1)}s`;
572
635
 
573
- // Mark rather than clear. Nulling activeSession here would free the
574
- // concurrency slot immediately while the child is still inside its
575
- // SIGTERM grace period — letting a second session start against the same
576
- // working tree. The slot is released where it always was: after the
577
- // orchestrator await unwinds, which cancellation now guarantees.
636
+ // Keep the workspace lease until the engine unwinds, including the
637
+ // child's SIGTERM grace period. Other worktrees may continue running.
578
638
  activeSession.cancelled = true;
579
639
  stopSessionWork(activeSession);
580
640
 
581
641
  // The `cancelled` flag also suppresses the orchestrator's own
582
642
  // session:end, so this stays the single terminal event for the session.
583
643
  sendBuffered(sessionId, { type: "session:end", duration, steps: 0, inputTokens: 0, outputTokens: 0, status: "stopped" });
584
-
585
- // Safety valve: if the child somehow never dies, don't wedge the daemon
586
- // into rejecting every future session.
587
- const wedged = activeSession;
588
- const timer = setTimeout(() => {
589
- if (activeSession === wedged) {
590
- console.log(` ${red}Cancelled session did not exit — releasing slot${reset}`);
591
- killTree(wedged.child, { graceMs: 0 });
592
- activeSession = null;
593
- }
594
- }, 30000);
595
- timer.unref?.();
596
644
  }
597
645
  }
598
646
 
599
647
  // 5. Graceful shutdown
600
648
 
601
649
  function shutdown() {
650
+ sessionQueue.close();
602
651
  console.log(`\n ${dim}Shutting down...${reset}`);
603
652
  clearInterval(heartbeatTimer);
653
+ clearInterval(recoveryTimer);
604
654
  clearTimeout(reconnectTimer);
605
655
 
606
- if (activeSession) {
607
- stopSessionWork(activeSession);
608
- }
656
+ for (const session of activeSessions.values()) stopSessionWork(session);
609
657
 
610
658
  send({ type: "daemon:disconnect" });
611
659
  try { ws?.close(); } catch {}
@@ -0,0 +1,90 @@
1
+ import { killTree } from "../proc.mjs";
2
+
3
+ // A child can exit without `close` arriving (for example, inherited stdio may
4
+ // remain open). Never depend on that event alone to release a cancelled lease.
5
+ function forceStop(child) {
6
+ if (!child.pid) return child.exitCode != null || child.signalCode != null;
7
+ try { process.kill(-child.pid, "SIGKILL"); }
8
+ catch (error) { if (error.code !== "ESRCH") return false; }
9
+ // Kill the direct PID too, in case it was not in a detached process group.
10
+ let exited = child.exitCode != null || child.signalCode != null;
11
+ try { process.kill(child.pid, "SIGKILL"); }
12
+ catch (error) { if (error.code === "ESRCH") exited = true; else return false; }
13
+ // Delivery alone isn't proof of exit. Keep the lease until Node or the OS
14
+ // confirms teardown; permission errors must never unlock a live workspace.
15
+ if (!exited) return false;
16
+ try { process.kill(-child.pid, 0); return false; }
17
+ catch (error) { return error.code === "ESRCH"; }
18
+ }
19
+
20
+ // The SDK iterator may never settle after its process exits. Cancellation waits
21
+ // for process teardown, not for that iterator, before releasing the workspace.
22
+ export async function runCancellable(run, { signal, onChild, graceMs = 5000, timeoutMs = 30000, cleanup } = {}) {
23
+ const children = new Set();
24
+ let rejectAbort, timer, deadline, cancelled = false, timedOut = false, cleaned = false;
25
+ const clean = () => { if (!cleaned) { cleaned = true; cleanup?.(); } };
26
+ const aborted = new Promise((_, reject) => { rejectAbort = reject; });
27
+ const finishAbort = () => {
28
+ if (!cancelled || children.size) return;
29
+ clearTimeout(timer);
30
+ clearTimeout(deadline);
31
+ if (timedOut) { clean(); return; }
32
+ const error = new Error("Session cancelled"); error.name = "AbortError";
33
+ rejectAbort(error);
34
+ };
35
+ const track = child => {
36
+ children.add(child);
37
+ child.once("close", () => {
38
+ // Bash grandchildren may survive their direct parent. They share the
39
+ // detached process group and must stop before the workspace is reusable.
40
+ if (child.pid && !forceStop(child)) return;
41
+ children.delete(child); finishAbort();
42
+ });
43
+ onChild?.(child);
44
+ if (cancelled) killTree(child, { graceMs: 0 });
45
+ };
46
+ const abort = () => {
47
+ if (cancelled) return;
48
+ cancelled = true;
49
+ deadline = setTimeout(() => {
50
+ if (!children.size) return;
51
+ timedOut = true;
52
+ const error = new Error("Cancellation timed out; workspace quarantined until its processes exit.");
53
+ error.name = "CancellationTimeoutError";
54
+ rejectAbort(error);
55
+ }, timeoutMs);
56
+ for (const child of children) killTree(child, { graceMs });
57
+ const watchdog = () => {
58
+ for (const child of children) {
59
+ if (!forceStop(child)) continue;
60
+ children.delete(child);
61
+ for (const stream of child.stdio || []) stream?.destroy?.();
62
+ }
63
+ finishAbort();
64
+ if (children.size) {
65
+ timer = setTimeout(watchdog, timedOut ? 1000 : 50);
66
+ if (timedOut) timer.unref?.();
67
+ }
68
+ };
69
+ timer = setTimeout(watchdog, graceMs);
70
+ finishAbort();
71
+ };
72
+ signal?.addEventListener("abort", abort, { once: true });
73
+ try {
74
+ if (signal?.aborted) { abort(); return await aborted; }
75
+ const running = Promise.resolve().then(() => {
76
+ signal?.throwIfAborted();
77
+ return run(track);
78
+ }).then(result => cancelled ? aborted : result, error => {
79
+ if (cancelled) return aborted;
80
+ throw error;
81
+ });
82
+ return await Promise.race([running, aborted]);
83
+ } finally {
84
+ if (timedOut) timer?.unref?.();
85
+ else clearTimeout(timer);
86
+ clearTimeout(deadline);
87
+ signal?.removeEventListener("abort", abort);
88
+ if (!timedOut) clean();
89
+ }
90
+ }
@@ -25,6 +25,9 @@ export const PARENT_SESSION_VARS = Object.freeze([
25
25
  "CLAUDE_CODE_SESSION_ID",
26
26
  "CLAUDE_PID",
27
27
  "CLAUDE_EFFORT",
28
+ // A session must resolve Git against its own cwd, not an inherited repository/index.
29
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE",
30
+ "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES",
28
31
  ]);
29
32
 
30
33
  // Hard caps on what a session may spawn. Depth 1: our subagents may not spawn
@@ -105,7 +105,8 @@ export function hooksForPhase({ phase, state, onToolUse, onToolResult, onSubagen
105
105
  }],
106
106
  PostToolUse: [{
107
107
  hooks: [async (input) => {
108
- onToolResult?.({ agentType: input.agent_type || null, tool: input.tool_name, response: input.tool_response });
108
+ await onToolResult?.({ agentType: input.agent_type || null, tool: input.tool_name,
109
+ input: input.tool_input, actionId: input.tool_use_id, response: input.tool_response });
109
110
  return {};
110
111
  }],
111
112
  }],