@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,85 @@
|
|
|
1
|
+
// Agents never write the shared Git database. Git commands use a private mirror;
|
|
2
|
+
// the trusted host imports verified objects and updates only the session branch.
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { copyFileSync, existsSync, lstatSync, readFileSync, writeFileSync, readdirSync, realpathSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
function command(cwd, args, options = {}) {
|
|
8
|
+
const env = { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1" };
|
|
9
|
+
for (const key of Object.keys(env)) if (/^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|CONFIG_COUNT|CONFIG_KEY_\d+|CONFIG_VALUE_\d+|CONFIG_PARAMETERS)$/.test(key)) delete env[key];
|
|
10
|
+
return execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "core.alternateRefsCommand=", "-c", "gc.auto=0", "-c", "protocol.allow=never", ...args], {
|
|
11
|
+
cwd, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], timeout: 120000, maxBuffer: 256 * 1024 * 1024, ...options,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function validateObjects(gitDir, commonDir) {
|
|
15
|
+
if (realpathSync(gitDir) !== gitDir) throw new Error("Private Git directory was redirected; manual recovery required.");
|
|
16
|
+
const visit = path => {
|
|
17
|
+
const stat = lstatSync(path);
|
|
18
|
+
if (stat.isSymbolicLink()) throw new Error("Private Git object storage contains a symlink; manual recovery required.");
|
|
19
|
+
if (stat.isDirectory()) for (const name of readdirSync(path)) visit(join(path, name));
|
|
20
|
+
};
|
|
21
|
+
visit(join(gitDir, "objects"));
|
|
22
|
+
const alternates = join(gitDir, "objects", "info", "alternates");
|
|
23
|
+
if (existsSync(alternates) && readFileSync(alternates, "utf8").trim() !== join(commonDir, "objects")) throw new Error("Private Git object alternates changed; manual recovery required.");
|
|
24
|
+
}
|
|
25
|
+
function regularCopy(from, to) {
|
|
26
|
+
if (!lstatSync(from).isFile() || lstatSync(from).isSymbolicLink()) throw new Error("Git index is not a regular file; retained for manual review.");
|
|
27
|
+
if (existsSync(to) && !lstatSync(to).isFile()) throw new Error("Git index destination changed.");
|
|
28
|
+
copyFileSync(from, to);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function preparePrivateGit(record, stateDir) {
|
|
32
|
+
const gitDir = join(stateDir, "git");
|
|
33
|
+
const ref = `refs/heads/${record.branch}`;
|
|
34
|
+
const originalGitDir = resolve(record.cwd, command(record.cwd, ["rev-parse", "--git-dir"]).trim());
|
|
35
|
+
const publishedHead = command(record.repo, ["rev-parse", "--verify", ref]).trim();
|
|
36
|
+
if (!existsSync(gitDir)) {
|
|
37
|
+
command(record.repo, ["-c", "protocol.file.allow=always", "clone", "--mirror", "--shared", "--", record.repo, gitDir]);
|
|
38
|
+
command(record.repo, [`--git-dir=${gitDir}`, "config", "remote.origin.mirror", "false"]);
|
|
39
|
+
command(record.repo, [`--git-dir=${gitDir}`, "config", "--unset-all", "remote.origin.fetch"]);
|
|
40
|
+
command(record.repo, [`--git-dir=${gitDir}`, "config", "--add", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]);
|
|
41
|
+
let remote = "";
|
|
42
|
+
try { remote = command(record.repo, ["remote", "get-url", "origin"]).trim(); } catch {}
|
|
43
|
+
if (remote) command(record.repo, [`--git-dir=${gitDir}`, "remote", "set-url", "origin", remote]);
|
|
44
|
+
else command(record.repo, [`--git-dir=${gitDir}`, "remote", "remove", "origin"]);
|
|
45
|
+
command(record.repo, [`--git-dir=${gitDir}`, "config", "core.bare", "false"]);
|
|
46
|
+
command(record.repo, [`--git-dir=${gitDir}`, "config", "core.worktree", record.tree]);
|
|
47
|
+
command(record.repo, [`--git-dir=${gitDir}`, "symbolic-ref", "HEAD", ref]);
|
|
48
|
+
regularCopy(join(originalGitDir, "index"), join(gitDir, "index"));
|
|
49
|
+
}
|
|
50
|
+
const marker = join(stateDir, "..", "git-pending");
|
|
51
|
+
validateObjects(gitDir, record.commonDir);
|
|
52
|
+
// Resume a crashed session's private commits/index if the shared branch has
|
|
53
|
+
// not moved since that session started. Never overwrite its staged work.
|
|
54
|
+
const pendingHead = existsSync(marker) ? readFileSync(marker, "utf8").trim() : "";
|
|
55
|
+
if (pendingHead && pendingHead !== publishedHead) throw new Error(`Unpublished Git state conflicts with the shared branch; recover ${gitDir} before resuming.`);
|
|
56
|
+
const privateHead = command(record.repo, [`--git-dir=${gitDir}`, "rev-parse", "--verify", ref]).trim();
|
|
57
|
+
if (!pendingHead && privateHead !== publishedHead) throw new Error(`The session branch changed outside AgentDesk. Git state retained at ${gitDir} for review.`);
|
|
58
|
+
// Refresh the index on resume (the user may have staged files while idle).
|
|
59
|
+
if (!pendingHead) regularCopy(join(originalGitDir, "index"), join(gitDir, "index"));
|
|
60
|
+
writeFileSync(marker, publishedHead, { mode: 0o600 });
|
|
61
|
+
return {
|
|
62
|
+
env: { GIT_DIR: gitDir, GIT_COMMON_DIR: gitDir, GIT_WORK_TREE: record.tree },
|
|
63
|
+
publish() {
|
|
64
|
+
validateObjects(gitDir, record.commonDir);
|
|
65
|
+
const headRef = command(record.repo, [`--git-dir=${gitDir}`, "symbolic-ref", "HEAD"]).trim();
|
|
66
|
+
if (headRef !== ref) throw new Error(`Session changed Git branches. Private Git state retained at ${gitDir}.`);
|
|
67
|
+
const oid = command(record.repo, [`--git-dir=${gitDir}`, "rev-parse", "--verify", `${ref}^{commit}`]).trim();
|
|
68
|
+
if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(oid)) throw new Error("Invalid session commit");
|
|
69
|
+
if (oid !== publishedHead) {
|
|
70
|
+
// pack-objects has no checkout, filter or transport hooks. index-pack
|
|
71
|
+
// validates content hashes and links before any shared ref is touched.
|
|
72
|
+
const pack = command(record.repo, [`--git-dir=${gitDir}`, "pack-objects", "--stdout", "--revs"], { input: Buffer.from(`${oid}\n^${publishedHead}\n`), encoding: null });
|
|
73
|
+
command(record.repo, ["index-pack", "--stdin", "--strict"], { input: pack });
|
|
74
|
+
}
|
|
75
|
+
command(record.repo, ["update-ref", ref, oid, publishedHead]);
|
|
76
|
+
regularCopy(join(gitDir, "index"), join(originalGitDir, "index"));
|
|
77
|
+
// Empty means synchronized; keep the marker path immutable to the agent.
|
|
78
|
+
writeFileSync(marker, "", { mode: 0o600 });
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function hasUnpublishedGit(stateDir) {
|
|
84
|
+
try { return !!readFileSync(join(stateDir, "..", "git-pending"), "utf8").trim(); } catch { return false; }
|
|
85
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function parseWorkspaceArgs(args) {
|
|
2
|
+
const remaining = [];
|
|
3
|
+
const workspace = { enabled: true };
|
|
4
|
+
const values = { "--base-branch": "baseBranch", "--branch": "branch", "--existing-branch": "branch", "--resume-worktree": "resumeId" };
|
|
5
|
+
for (let i = 0; i < args.length; i++) {
|
|
6
|
+
const arg = args[i];
|
|
7
|
+
if (arg === "--no-worktree") workspace.enabled = false;
|
|
8
|
+
else if (values[arg]) {
|
|
9
|
+
const value = args[++i];
|
|
10
|
+
if (!value || value.startsWith("-")) throw new Error(`${arg} requires a value`);
|
|
11
|
+
workspace[values[arg]] = value;
|
|
12
|
+
if (arg === "--existing-branch") workspace.branchMode = "existing";
|
|
13
|
+
} else remaining.push(arg);
|
|
14
|
+
}
|
|
15
|
+
if (!workspace.enabled && (workspace.branch || workspace.baseBranch || workspace.resumeId)) throw new Error("Worktree options cannot be combined with --no-worktree");
|
|
16
|
+
if (workspace.resumeId && (workspace.branch || workspace.baseBranch)) throw new Error("--resume-worktree cannot be combined with --branch, --existing-branch, or --base-branch");
|
|
17
|
+
return { remaining, workspace };
|
|
18
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Local workspace lifecycle. Paths are derived locally, never accepted from the server.
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, realpathSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, resolve, relative, dirname } from "node:path";
|
|
7
|
+
import { hasUnpublishedGit } from "./worktree-git.mjs";
|
|
8
|
+
|
|
9
|
+
export const REMINDER_AGE = 14 * 24 * 60 * 60 * 1000;
|
|
10
|
+
const hash = value => createHash("sha256").update(value).digest("hex").slice(0, 24);
|
|
11
|
+
function git(cwd, args) {
|
|
12
|
+
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
13
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"]) delete env[key];
|
|
14
|
+
for (const key of Object.keys(env)) if (key.startsWith("GIT_CONFIG_")) delete env[key];
|
|
15
|
+
env.GIT_CONFIG_NOSYSTEM = "1";
|
|
16
|
+
env.GIT_CONFIG_GLOBAL = "/dev/null";
|
|
17
|
+
return execFileSync("git", args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30000 }).trim();
|
|
18
|
+
}
|
|
19
|
+
function rootPath(root) { return root || join(homedir(), ".agentdesk", "workspaces"); }
|
|
20
|
+
function read(path) { return JSON.parse(readFileSync(path, "utf8")); }
|
|
21
|
+
function unlinkIfPresent(path) {
|
|
22
|
+
try { unlinkSync(path); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
23
|
+
}
|
|
24
|
+
function save(path, data) {
|
|
25
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
26
|
+
writeFileSync(temp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
27
|
+
renameSync(temp, path);
|
|
28
|
+
}
|
|
29
|
+
function alive(pid) {
|
|
30
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
31
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; }
|
|
32
|
+
}
|
|
33
|
+
function groupAlive(pid) {
|
|
34
|
+
if (!Number.isInteger(pid) || pid <= 0) return true;
|
|
35
|
+
try { process.kill(-pid, 0); return true; } catch (error) { return error.code !== "ESRCH"; }
|
|
36
|
+
}
|
|
37
|
+
function leaseAlive(owner) {
|
|
38
|
+
return owner.unknownChild || (!owner.quarantined && alive(owner.pid)) ||
|
|
39
|
+
(owner.childPids || []).some(pid => alive(pid) || groupAlive(pid));
|
|
40
|
+
}
|
|
41
|
+
export function workspaceActive(record) {
|
|
42
|
+
if (record.leasePath) {
|
|
43
|
+
try { return !!leaseAlive(read(record.leasePath)); }
|
|
44
|
+
catch (error) { return error.code !== "ENOENT"; }
|
|
45
|
+
}
|
|
46
|
+
return !!record.pid && alive(record.pid);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function repositoryInfo(cwd) {
|
|
50
|
+
const repo = realpathSync(git(cwd, ["rev-parse", "--show-toplevel"]));
|
|
51
|
+
const commonDir = realpathSync(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"])));
|
|
52
|
+
const branches = git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"]).split("\n").filter(Boolean);
|
|
53
|
+
let currentBranch = "";
|
|
54
|
+
try { currentBranch = git(cwd, ["symbolic-ref", "--short", "HEAD"]); } catch {}
|
|
55
|
+
return { repo, commonDir, branches, currentBranch };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function branchRef(cwd, name, localOnly = false) {
|
|
59
|
+
if (typeof name !== "string" || !name || name.startsWith("-") || name.length > 200) throw new Error("Choose a valid branch name.");
|
|
60
|
+
git(cwd, ["check-ref-format", `refs/heads/${name}`]);
|
|
61
|
+
for (const ref of [`refs/heads/${name}`, ...(localOnly ? [] : [`refs/remotes/${name}`])]) {
|
|
62
|
+
try { git(cwd, ["show-ref", "--verify", ref]); return ref; } catch {}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Branch "${name}" does not exist locally. Fetch it first.`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listWorkspaces({ root, projectPaths } = {}) {
|
|
68
|
+
const dir = rootPath(root);
|
|
69
|
+
if (!existsSync(dir)) return [];
|
|
70
|
+
const allowed = projectPaths?.map(p => realpathSync(p));
|
|
71
|
+
return readdirSync(dir).filter(n => /^[a-f0-9]{24}\.json$/.test(n)).flatMap(name => {
|
|
72
|
+
try {
|
|
73
|
+
const record = read(join(dir, name));
|
|
74
|
+
if (allowed && !allowed.includes(record.sourceCwd)) return [];
|
|
75
|
+
return [record];
|
|
76
|
+
} catch { return []; }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getWorkspace(id, { root, projectPaths } = {}) {
|
|
81
|
+
const record = read(join(rootPath(root), `${hash(id)}.json`));
|
|
82
|
+
if (record.id !== id || (projectPaths && !projectPaths.some(p => realpathSync(p) === record.sourceCwd))) throw new Error("Workspace not found for this project.");
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// A filesystem lease protects the same directory across daemon and CLI processes.
|
|
87
|
+
function acquire(cwd, root) {
|
|
88
|
+
const path = join(rootPath(root), `${hash(cwd)}.lock`);
|
|
89
|
+
const token = randomUUID();
|
|
90
|
+
const unknownChildren = new Set();
|
|
91
|
+
const owned = () => {
|
|
92
|
+
try { return read(path).token === token; } catch { return false; }
|
|
93
|
+
};
|
|
94
|
+
const update = change => {
|
|
95
|
+
if (!owned()) throw new Error("Workspace lease ownership changed.");
|
|
96
|
+
save(path, { ...read(path), ...change });
|
|
97
|
+
};
|
|
98
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(path, JSON.stringify({ pid: process.pid, token, childPids: [] }), { flag: "wx", mode: 0o600 });
|
|
101
|
+
return {
|
|
102
|
+
path, token,
|
|
103
|
+
hasLiveChildren() {
|
|
104
|
+
if (!owned()) return true;
|
|
105
|
+
return !!leaseAlive({ ...read(path), quarantined: true });
|
|
106
|
+
},
|
|
107
|
+
release(beforeRelease) {
|
|
108
|
+
if (!owned()) return false;
|
|
109
|
+
let owner;
|
|
110
|
+
try { owner = read(path); } catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
111
|
+
if (leaseAlive({ ...owner, quarantined: true })) return false;
|
|
112
|
+
beforeRelease?.();
|
|
113
|
+
unlinkIfPresent(path);
|
|
114
|
+
return true;
|
|
115
|
+
},
|
|
116
|
+
trackChild(child) {
|
|
117
|
+
const owner = read(path);
|
|
118
|
+
// Retain every group leader until the lease ends: descendants can
|
|
119
|
+
// survive their parent. PID reuse errs on the side of retaining work.
|
|
120
|
+
if (!Number.isInteger(child.pid) || child.pid <= 0) {
|
|
121
|
+
if (child.exitCode == null && child.signalCode == null) {
|
|
122
|
+
unknownChildren.add(child);
|
|
123
|
+
update({ unknownChild: true });
|
|
124
|
+
child.once("close", () => {
|
|
125
|
+
unknownChildren.delete(child);
|
|
126
|
+
if (owned()) update({ unknownChild: unknownChildren.size > 0 });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} else update({ childPids: [...new Set([...owner.childPids, child.pid])] });
|
|
130
|
+
},
|
|
131
|
+
quarantine() { update({ quarantined: true, quarantinedAt: Date.now() }); },
|
|
132
|
+
};
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error.code !== "EEXIST") throw error;
|
|
135
|
+
let owner;
|
|
136
|
+
try { owner = read(path); } catch (error) { if (error.code === "ENOENT") continue; throw error; }
|
|
137
|
+
if (leaseAlive(owner)) throw new Error(owner.quarantined
|
|
138
|
+
? "This workspace is quarantined until its processes exit."
|
|
139
|
+
: "This workspace already has a running session.");
|
|
140
|
+
unlinkIfPresent(path);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
throw new Error("Could not acquire workspace.");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Called on daemon startup and periodically afterwards. Never kill a PID from
|
|
147
|
+
// disk (it may have been reused); only ESRCH is evidence that it is gone.
|
|
148
|
+
export function recoverWorkspaceLocks({ root } = {}) {
|
|
149
|
+
const dir = rootPath(root);
|
|
150
|
+
const result = { released: 0, retained: 0 };
|
|
151
|
+
if (!existsSync(dir)) return result;
|
|
152
|
+
for (const name of readdirSync(dir).filter(n => /^[a-f0-9]{24}\.lock$/.test(n))) {
|
|
153
|
+
const path = join(dir, name);
|
|
154
|
+
try {
|
|
155
|
+
const original = readFileSync(path, "utf8");
|
|
156
|
+
const owner = JSON.parse(original);
|
|
157
|
+
if (leaseAlive(owner)) { result.retained++; continue; }
|
|
158
|
+
// Don't clear metadata or unlink a replacement lease from a newer run.
|
|
159
|
+
if (readFileSync(path, "utf8") !== original) continue;
|
|
160
|
+
for (const record of listWorkspaces({ root })) {
|
|
161
|
+
if (`${hash(record.tree || record.cwd)}.lock` !== name) continue;
|
|
162
|
+
if (record.leaseToken && record.leaseToken !== owner.token) continue;
|
|
163
|
+
save(join(dir, `${hash(record.id)}.json`), { ...record, pid: null, quarantined: false, lastActivityAt: Date.now() });
|
|
164
|
+
}
|
|
165
|
+
if (readFileSync(path, "utf8") === original) { unlinkSync(path); result.released++; }
|
|
166
|
+
} catch { result.retained++; } // Malformed or inaccessible locks fail closed.
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function prepareWorkspace({ cwd, sessionId, projectId, taskId, workspace = {}, root }) {
|
|
172
|
+
const sourceCwd = realpathSync(cwd);
|
|
173
|
+
if (workspace.resumeId && (workspace.branch || workspace.baseBranch)) throw new Error("Resuming a worktree cannot change its branch or starting branch.");
|
|
174
|
+
const requestedDir = rootPath(root);
|
|
175
|
+
mkdirSync(requestedDir, { recursive: true, mode: 0o700 });
|
|
176
|
+
const dir = realpathSync(requestedDir);
|
|
177
|
+
if (workspace.branchMode !== undefined && !["new", "existing"].includes(workspace.branchMode)) throw new Error("Invalid branch mode.");
|
|
178
|
+
if (workspace.enabled !== false && workspace.branchMode === "existing" && !workspace.branch) throw new Error("Choose an existing local branch.");
|
|
179
|
+
if (workspace.enabled === false) {
|
|
180
|
+
let leasePath = sourceCwd;
|
|
181
|
+
try { leasePath = repositoryInfo(sourceCwd).repo; } catch {}
|
|
182
|
+
const lease = acquire(leasePath, root);
|
|
183
|
+
return { cwd: sourceCwd, record: null, ...lease };
|
|
184
|
+
}
|
|
185
|
+
let record;
|
|
186
|
+
if (workspace.resumeId) {
|
|
187
|
+
record = getWorkspace(workspace.resumeId, { root, projectPaths: [sourceCwd] });
|
|
188
|
+
if (record.removedAt) throw new Error("This worktree was removed. Start a new session on its retained branch.");
|
|
189
|
+
const info = verifyWorktree(record);
|
|
190
|
+
if (info.currentBranch !== record.branch) throw new Error("Worktree branch changed; restore the session branch before resuming.");
|
|
191
|
+
} else {
|
|
192
|
+
const info = repositoryInfo(sourceCwd);
|
|
193
|
+
const baseRef = branchRef(sourceCwd, workspace.baseBranch || info.currentBranch);
|
|
194
|
+
const id = sessionId || randomUUID();
|
|
195
|
+
if (existsSync(join(dir, `${hash(id)}.json`))) throw new Error("This session already has a workspace. Resume it explicitly.");
|
|
196
|
+
const branch = workspace.branch || `agentdesk/${(taskId || "task").replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 40)}-${randomUUID().slice(0, 8)}`;
|
|
197
|
+
git(sourceCwd, ["check-ref-format", "--branch", branch]);
|
|
198
|
+
const tree = join(dir, hash(id), "tree");
|
|
199
|
+
mkdirSync(join(dir, hash(id)), { recursive: true, mode: 0o700 });
|
|
200
|
+
const existing = workspace.branchMode === "existing";
|
|
201
|
+
if (existing) branchRef(sourceCwd, branch, true);
|
|
202
|
+
// No --force: Git rejects a branch already checked out in any other worktree.
|
|
203
|
+
try {
|
|
204
|
+
git(sourceCwd, ["worktree", "add", ...(existing ? [] : ["-b", branch]), "--", tree, existing ? branch : baseRef]);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
throw new Error(`Could not create worktree: ${String(error.stderr || error.message).trim()}`);
|
|
207
|
+
}
|
|
208
|
+
record = { id, projectId, taskId, sourceCwd, repo: info.repo, commonDir: info.commonDir, tree: realpathSync(tree), cwd: join(realpathSync(tree), relative(info.repo, sourceCwd)), branch, baseRef,
|
|
209
|
+
baseCommit: git(tree, ["rev-parse", baseRef]), createdAt: Date.now(), lastActivityAt: Date.now() };
|
|
210
|
+
save(join(dir, `${hash(id)}.json`), record);
|
|
211
|
+
}
|
|
212
|
+
const lease = acquire(record.tree || record.cwd, root);
|
|
213
|
+
record = { ...record, pid: process.pid, leasePath: lease.path, leaseToken: lease.token,
|
|
214
|
+
quarantined: false, lastActivityAt: Date.now(), archived: false };
|
|
215
|
+
const recordPath = join(dir, `${hash(record.id)}.json`);
|
|
216
|
+
save(recordPath, record);
|
|
217
|
+
const stateDir = join(dir, hash(record.id), "state");
|
|
218
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
219
|
+
let released = false;
|
|
220
|
+
return {
|
|
221
|
+
cwd: record.cwd, record, stateDir,
|
|
222
|
+
trackChild: lease.trackChild,
|
|
223
|
+
hasLiveChildren: lease.hasLiveChildren,
|
|
224
|
+
quarantine() {
|
|
225
|
+
lease.quarantine();
|
|
226
|
+
save(recordPath, { ...read(recordPath), quarantined: true });
|
|
227
|
+
},
|
|
228
|
+
release() {
|
|
229
|
+
if (released) return;
|
|
230
|
+
released = lease.release(() => save(recordPath, { ...read(recordPath), pid: null, quarantined: false, lastActivityAt: Date.now() }));
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function verifyWorktree(record) {
|
|
236
|
+
if (!existsSync(record.cwd)) throw new Error("Worktree directory is missing; retained for manual review.");
|
|
237
|
+
const info = repositoryInfo(record.cwd);
|
|
238
|
+
if (info.repo !== realpathSync(record.tree || record.cwd) || info.commonDir !== record.commonDir || info.repo === record.repo) throw new Error("Worktree identity changed; manual review required.");
|
|
239
|
+
const entries = git(record.repo, ["worktree", "list", "--porcelain", "-z"]).split("\0");
|
|
240
|
+
if (!entries.includes(`worktree ${record.tree || record.cwd}`)) throw new Error("Worktree is no longer registered.");
|
|
241
|
+
return info;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function inspectWorkspace(record, now = Date.now(), { heldLease = false } = {}) {
|
|
245
|
+
// Cleanup holds its own lease while inspecting; that isn't an active session.
|
|
246
|
+
const active = !heldLease && workspaceActive(record);
|
|
247
|
+
const reminder = !active && !record.removedAt && now - record.lastActivityAt >= REMINDER_AGE;
|
|
248
|
+
if (record.removedAt) return { ...record, active: false, safe: false, reminder: false, reason: "Worktree removed; branch retained." };
|
|
249
|
+
if (active) return { ...record, active, safe: false, reminder: false,
|
|
250
|
+
reason: record.quarantined ? "Cancellation timed out; quarantined until its processes exit." : "Session is running." };
|
|
251
|
+
try {
|
|
252
|
+
const info = verifyWorktree(record);
|
|
253
|
+
if (hasUnpublishedGit(join(dirname(record.tree || record.cwd), "state"))) throw new Error("Private Git state has unpublished work; manual recovery required.");
|
|
254
|
+
// Include ignored files: even an ignored .env or build artifact may contain valuable work.
|
|
255
|
+
if (git(record.tree || record.cwd, ["status", "--porcelain=v1", "--untracked-files=all", "--ignored"])) throw new Error("Uncommitted, untracked, or ignored files remain.");
|
|
256
|
+
if (info.currentBranch !== record.branch) throw new Error("The worktree branch changed; manual review required.");
|
|
257
|
+
git(record.repo, ["show-ref", "--verify", record.baseRef]);
|
|
258
|
+
if (`refs/heads/${record.branch}` === record.baseRef) throw new Error("Session branch is also the starting branch; merge into another branch before cleanup.");
|
|
259
|
+
try { git(record.cwd, ["merge-base", "--is-ancestor", "HEAD", record.baseRef]); }
|
|
260
|
+
catch { throw new Error("Commits have not been merged into the starting branch."); }
|
|
261
|
+
return { ...record, active, safe: true, reminder, reason: "Clean and merged into the starting branch." };
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return { ...record, active, safe: false, reminder, reason: error.message };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function cleanupWorkspace(id, { root, projectPaths, discard = false, confirmation, archive = false } = {}) {
|
|
268
|
+
let record = getWorkspace(id, { root, projectPaths });
|
|
269
|
+
if (record.removedAt) return inspectWorkspace(record);
|
|
270
|
+
if (workspaceActive(record)) throw new Error("Stop the session before removing its worktree.");
|
|
271
|
+
const lease = acquire(record.tree || record.cwd, root);
|
|
272
|
+
try {
|
|
273
|
+
const info = verifyWorktree(record);
|
|
274
|
+
if (info.currentBranch !== record.branch) throw new Error("Worktree branch changed; restore it before removing the worktree.");
|
|
275
|
+
const status = inspectWorkspace(record, Date.now(), { heldLease: true });
|
|
276
|
+
if (archive) {
|
|
277
|
+
record = { ...record, archived: true };
|
|
278
|
+
save(join(rootPath(root), `${hash(id)}.json`), record);
|
|
279
|
+
}
|
|
280
|
+
if (!status.safe && !discard) return { ...status, archived: record.archived };
|
|
281
|
+
if (discard && confirmation !== record.branch) throw new Error("Type the branch name to confirm discarding files. The branch itself will be kept.");
|
|
282
|
+
// Git checks locks, submodules and dirt again. Only explicit discard may force removal.
|
|
283
|
+
git(record.repo, ["worktree", "remove", ...(discard ? ["--force"] : []), "--", record.tree || record.cwd]);
|
|
284
|
+
record = { ...record, removedAt: Date.now(), pid: null };
|
|
285
|
+
save(join(rootPath(root), `${hash(id)}.json`), record);
|
|
286
|
+
return inspectWorkspace(record);
|
|
287
|
+
} finally { lease.release(); }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function workspaceGitPaths(cwd) {
|
|
291
|
+
try {
|
|
292
|
+
const common = realpathSync(resolve(cwd, git(cwd, ["rev-parse", "--git-common-dir"])));
|
|
293
|
+
return relative(cwd, common).startsWith("..") ? [common] : [];
|
|
294
|
+
} catch { return []; }
|
|
295
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kendoo.agentdesk/agentdesk",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.4",
|
|
4
4
|
"description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
11
|
"cli/",
|
|
12
|
+
"shared/",
|
|
12
13
|
"README.md",
|
|
13
14
|
"CHANGELOG.md"
|
|
14
15
|
],
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
"server": "node server/index.mjs",
|
|
22
23
|
"build": "vite build",
|
|
23
24
|
"preview": "vite preview",
|
|
24
|
-
"test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs",
|
|
25
|
+
"test": "node --test tests/server.test.mjs tests/agents.test.mjs tests/homepage.test.mjs tests/sessionUtils.test.mjs tests/tracker-url.test.mjs tests/session-preflight.test.mjs tests/access.test.mjs tests/project-ownership.test.mjs tests/random-hex.test.mjs tests/projects-registry.test.mjs tests/phase-loop.test.mjs tests/proc.test.mjs tests/crypto.test.mjs tests/dotenv.test.mjs tests/update-check.test.mjs tests/setup-helpers.test.mjs tests/project-key.test.mjs tests/tracker-project.test.mjs tests/tracker-check.test.mjs tests/config.test.mjs tests/engine-env.test.mjs tests/engine-events.test.mjs tests/engine-verdict.test.mjs tests/engine-hooks.test.mjs tests/engine-agents.test.mjs tests/session-isolation.test.mjs tests/engine-session.test.mjs tests/engine-prompts.test.mjs tests/engine-schemas.test.mjs tests/engine-claude-auth.test.mjs tests/worktrees.test.mjs tests/workspaces-api.test.mjs tests/engine-outcome.test.mjs tests/session-outcomes.test.mjs tests/outcome-hydration.test.mjs tests/mobile-outcomes.test.mjs tests/session-queue.test.mjs",
|
|
25
26
|
"test:coverage": "node --test --experimental-test-coverage --test-coverage-include='cli/**' --test-coverage-include='server/**' --test-coverage-lines=60 --test-coverage-branches=62 tests/*.test.mjs",
|
|
26
27
|
"lint": "eslint .",
|
|
27
28
|
"lint:fix": "eslint . --fix",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Shared wire/storage validation. No credentials or raw tool output belong here.
|
|
2
|
+
export function normalizeOutcome(value) {
|
|
3
|
+
if (!value || !["pr_created", "replied"].includes(value.kind) ||
|
|
4
|
+
!["github", "jira", "linear"].includes(value.provider)) return null;
|
|
5
|
+
for (const key of ["container", "externalId", "actionId", "taskId"]) {
|
|
6
|
+
if (typeof value[key] !== "string" || !value[key] || value[key].length > 300 || /[\x00-\x1f]/.test(value[key])) return null;
|
|
7
|
+
}
|
|
8
|
+
if (!Number.isSafeInteger(value.observedAt) || value.observedAt <= 0) return null;
|
|
9
|
+
let url;
|
|
10
|
+
try { url = new URL(value.url); } catch { return null; }
|
|
11
|
+
if (url.protocol !== "https:" || url.username || url.password || url.port || value.url.length > 2000) return null;
|
|
12
|
+
if (value.provider === "github") {
|
|
13
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(value.container) || url.hostname !== "github.com" || url.search) return null;
|
|
14
|
+
if (value.kind === "pr_created") {
|
|
15
|
+
if (!/^\d+$/.test(value.externalId) || url.pathname !== `/${value.container}/pull/${value.externalId}` || url.hash) return null;
|
|
16
|
+
} else {
|
|
17
|
+
if (!/^\d+$/.test(value.taskId) || !/^\d+$/.test(value.externalId) ||
|
|
18
|
+
url.pathname !== `/${value.container}/issues/${value.taskId}` || url.hash !== `#issuecomment-${value.externalId}`) return null;
|
|
19
|
+
}
|
|
20
|
+
} else if (value.provider === "jira") {
|
|
21
|
+
if (value.kind !== "replied" || !/^\d+$/.test(value.externalId) || !/^[A-Z][A-Z0-9_]*-\d+$/.test(value.taskId) ||
|
|
22
|
+
url.origin !== value.container || url.pathname !== `/browse/${value.taskId}` ||
|
|
23
|
+
url.search !== `?focusedCommentId=${value.externalId}` || url.hash) return null;
|
|
24
|
+
} else if (value.kind !== "replied" || url.hostname !== "linear.app" ||
|
|
25
|
+
!/^[\w-]+$/.test(value.container) || !/^[\w-]+$/.test(value.externalId) ||
|
|
26
|
+
!url.pathname.startsWith(`/${value.container}/issue/${value.taskId}/`) || !url.hash || url.search) return null;
|
|
27
|
+
const id = [value.kind, value.provider, value.container, value.externalId].map(encodeURIComponent).join(":");
|
|
28
|
+
return { id, kind: value.kind, provider: value.provider, container: value.container,
|
|
29
|
+
externalId: value.externalId, actionId: value.actionId, taskId: value.taskId,
|
|
30
|
+
url: url.href, observedAt: value.observedAt,
|
|
31
|
+
...(typeof value.branch === "string" && value.branch.length <= 300 ? { branch: value.branch } : {}) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function mergeOutcomes(existing = [], incoming = []) {
|
|
35
|
+
const found = new Map();
|
|
36
|
+
for (const raw of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) {
|
|
37
|
+
const outcome = normalizeOutcome(raw);
|
|
38
|
+
if (outcome && !found.has(outcome.id)) found.set(outcome.id, outcome);
|
|
39
|
+
}
|
|
40
|
+
return [...found.values()];
|
|
41
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Running consumes execution capacity. Open also includes queued work and a
|
|
2
|
+
// human handoff: neither may disappear through generic inactive cleanup.
|
|
3
|
+
export const RUNNING_SESSION_STATUSES = new Set(["active", "stale"]);
|
|
4
|
+
export const OPEN_SESSION_STATUSES = new Set(["queued", "active", "stale", "handoff"]);
|
|
5
|
+
export const isRunningSession = status => RUNNING_SESSION_STATUSES.has(status);
|
|
6
|
+
export const isOpenSession = status => OPEN_SESSION_STATUSES.has(status);
|