@kendoo.agentdesk/agentdesk 0.27.0 → 0.28.1
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 +33 -0
- package/bin/agentdesk.mjs +35 -45
- package/cli/agents.mjs +4 -256
- package/cli/bootstrap.mjs +40 -59
- package/cli/config.mjs +29 -4
- package/cli/daemon.mjs +72 -44
- package/cli/dotenv.mjs +96 -13
- package/cli/engine/agents/index.mjs +152 -0
- package/cli/engine/claude-auth.mjs +72 -0
- package/cli/engine/env.mjs +56 -0
- package/cli/engine/events.mjs +214 -0
- package/cli/engine/hooks.mjs +119 -0
- package/cli/engine/phases/EXECUTION.md +45 -0
- package/cli/engine/phases/INTAKE.md +34 -0
- package/cli/engine/phases/PLAN.md +26 -0
- package/cli/engine/phases/REVIEW.md +21 -0
- package/cli/engine/phases/SOLO.md +115 -0
- package/cli/engine/phases/SUMMARY.md +23 -0
- package/cli/engine/prompts.mjs +181 -0
- package/cli/engine/query.mjs +63 -0
- package/cli/engine/schemas.mjs +180 -0
- package/cli/engine/session.mjs +285 -0
- package/cli/engine/spawn.mjs +83 -0
- package/cli/engine/tracker/github.md +19 -0
- package/cli/engine/tracker/jira.md +23 -0
- package/cli/engine/tracker/linear.md +24 -0
- package/cli/engine/verdict.mjs +83 -0
- package/cli/init.mjs +290 -147
- package/cli/login.mjs +11 -3
- package/cli/phase-loop.mjs +78 -0
- package/cli/proc.mjs +131 -0
- package/cli/project-key.mjs +56 -0
- package/cli/prompt.mjs +9 -503
- package/cli/prompts.mjs +20 -1
- package/cli/security-check.mjs +1 -1
- package/cli/session-isolation.mjs +65 -9
- package/cli/session-sandbox.mjs +13 -1
- package/cli/setup-helpers.mjs +83 -36
- package/cli/team.mjs +41 -34
- package/cli/tracker-check.mjs +12 -2
- package/cli/tracker-project.mjs +93 -0
- package/cli/update-check.mjs +62 -0
- package/package.json +12 -3
- package/cli/orchestrator.mjs +0 -461
- package/cli/stream-parser.mjs +0 -216
- package/prompts/phased.md +0 -549
- package/prompts/team.md +0 -505
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Pure decision logic for the phased orchestrator loop.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from orchestrator.mjs so the control flow that decides "did this
|
|
4
|
+
// session actually succeed" is unit-testable without spawning Claude. Every
|
|
5
|
+
// function here is pure or filesystem-read-only; nothing spawns, emits, or
|
|
6
|
+
// mutates session state.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, renameSync, unlinkSync } from "fs";
|
|
9
|
+
|
|
10
|
+
export const PHASES = ["INTAKE", "PLAN", "EXECUTION", "REVIEW", "SUMMARY"];
|
|
11
|
+
|
|
12
|
+
// One execution redo after a failed review, then the session ends unresolved.
|
|
13
|
+
export const MAX_REVIEW_RETRIES = 1;
|
|
14
|
+
|
|
15
|
+
// Absolute ceiling on phase runs. The review loop re-enqueues phases, so a bug
|
|
16
|
+
// in verdict parsing must not be able to spin forever burning tokens. With
|
|
17
|
+
// MAX_REVIEW_RETRIES=1 a legitimate run tops out at 7 (5 + EXECUTION + REVIEW).
|
|
18
|
+
export const MAX_PHASE_RUNS = 10;
|
|
19
|
+
|
|
20
|
+
// Classify the first line of `.agentdesk/review-verdict.md`.
|
|
21
|
+
//
|
|
22
|
+
// FAIL CLOSED: anything that is not an explicit APPROVED is treated as
|
|
23
|
+
// unapproved. A REVIEW phase that crashed, hit a limit, or simply forgot to
|
|
24
|
+
// write the file must never be indistinguishable from one that passed.
|
|
25
|
+
export function reviewOutcome(raw) {
|
|
26
|
+
if (raw == null) return "MISSING";
|
|
27
|
+
const firstLine = String(raw).trim().split(/\r?\n/)[0]?.trim().toUpperCase() ?? "";
|
|
28
|
+
if (!firstLine) return "MISSING";
|
|
29
|
+
if (/^NEEDS_MORE_WORK\b/.test(firstLine)) return "NEEDS_MORE_WORK";
|
|
30
|
+
if (/^APPROVED\b/.test(firstLine)) return "APPROVED";
|
|
31
|
+
// Unrecognised content is not approval.
|
|
32
|
+
return "MISSING";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readReviewVerdict(path) {
|
|
36
|
+
try {
|
|
37
|
+
if (!existsSync(path)) return "MISSING";
|
|
38
|
+
return reviewOutcome(readFileSync(path, "utf-8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return "MISSING";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A phase run failed if the process exited non-zero, full stop.
|
|
45
|
+
//
|
|
46
|
+
// This used to be gated on `!existsSync(session-memory.md)`, which made it
|
|
47
|
+
// dead code: INTAKE writes that file, so from phase one onward no crash was
|
|
48
|
+
// ever detected and every broken session reported "complete".
|
|
49
|
+
export function phaseFailed({ exitCode, aborted }) {
|
|
50
|
+
if (aborted) return false; // cancellation is not a crash — reported separately
|
|
51
|
+
return exitCode !== 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Terminal status for the session, in precedence order.
|
|
55
|
+
export function finalStatus({ aborted, crashed, reviewResolved }) {
|
|
56
|
+
if (aborted) return "stopped";
|
|
57
|
+
if (crashed) return "handoff";
|
|
58
|
+
// Review never reached APPROVED — a human has to pick this up. Reuses the
|
|
59
|
+
// existing "handoff" status rather than inventing one the dashboard and
|
|
60
|
+
// server store would not recognise.
|
|
61
|
+
if (!reviewResolved) return "handoff";
|
|
62
|
+
return "complete";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Move a previous run's session memory aside so it cannot leak into this one.
|
|
66
|
+
//
|
|
67
|
+
// Kept (as .prev.md) rather than deleted: it is the only artifact explaining
|
|
68
|
+
// what a crashed prior session believed it had done.
|
|
69
|
+
export function archiveStaleMemory(sessionMemoryPath) {
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(sessionMemoryPath)) return false;
|
|
72
|
+
renameSync(sessionMemoryPath, `${sessionMemoryPath.replace(/\.md$/, "")}.prev.md`);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
try { unlinkSync(sessionMemoryPath); return true; } catch {}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/cli/proc.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Process-tree termination for spawned Claude children.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: `claude` is not a leaf process. It spawns Bash
|
|
4
|
+
// grandchildren that write to the project working tree. Signalling only the
|
|
5
|
+
// direct child leaves those grandchildren running — which is precisely the
|
|
6
|
+
// state a cancel is meant to prevent. So children are spawned detached (their
|
|
7
|
+
// own process group) and torn down by group.
|
|
8
|
+
//
|
|
9
|
+
// Every spawned child is also tracked here so that an unexpected parent exit
|
|
10
|
+
// (SIGINT from the terminal, daemon shutdown) does not orphan a Claude process
|
|
11
|
+
// that keeps committing and pushing after the dashboard says the session is
|
|
12
|
+
// over. This matters more than usual because the scratch HOME is deleted on
|
|
13
|
+
// exit — an orphaned child would keep running with its credentials yanked
|
|
14
|
+
// out from under it, mid-push.
|
|
15
|
+
|
|
16
|
+
const live = new Set();
|
|
17
|
+
|
|
18
|
+
// Send `sig` to the child's whole process group, falling back to the single
|
|
19
|
+
// pid when the child was not detached or the group is already gone.
|
|
20
|
+
function signalGroup(pid, sig) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(-pid, sig);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {}
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, sig);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function hasExited(child) {
|
|
33
|
+
return !child || child.exitCode !== null || child.signalCode !== null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// SIGTERM the tree, then SIGKILL anything still alive after `graceMs`.
|
|
37
|
+
// Returns true when a signal was actually delivered.
|
|
38
|
+
export function killTree(child, { graceMs = 5000 } = {}) {
|
|
39
|
+
if (hasExited(child) || !child.pid) return false;
|
|
40
|
+
|
|
41
|
+
const pid = child.pid;
|
|
42
|
+
const delivered = signalGroup(pid, "SIGTERM");
|
|
43
|
+
if (!delivered) return false;
|
|
44
|
+
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
if (!hasExited(child)) signalGroup(pid, "SIGKILL");
|
|
47
|
+
}, graceMs);
|
|
48
|
+
// Never let the force-kill timer hold the event loop open.
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
child.once("close", () => clearTimeout(timer));
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Register a child for teardown-on-parent-exit. Returns an untrack function.
|
|
56
|
+
export function trackChild(child) {
|
|
57
|
+
if (!child) return () => {};
|
|
58
|
+
live.add(child);
|
|
59
|
+
const drop = () => live.delete(child);
|
|
60
|
+
child.once("close", drop);
|
|
61
|
+
return drop;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function killAllTracked({ graceMs = 0 } = {}) {
|
|
65
|
+
for (const child of [...live]) {
|
|
66
|
+
live.delete(child);
|
|
67
|
+
// On a hard parent exit there is no time to wait out a grace period —
|
|
68
|
+
// the exit handler runs synchronously and the timer would never fire.
|
|
69
|
+
if (graceMs === 0) {
|
|
70
|
+
if (!hasExited(child) && child.pid) signalGroup(child.pid, "SIGKILL");
|
|
71
|
+
} else {
|
|
72
|
+
killTree(child, { graceMs });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function trackedCount() {
|
|
78
|
+
return live.size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Cleanups that must run before the process goes away — currently the scratch
|
|
82
|
+
// HOME teardown, which holds live credentials on disk.
|
|
83
|
+
//
|
|
84
|
+
// Centralised here so there is exactly one set of signal handlers. Registering
|
|
85
|
+
// them in several modules means whichever one calls process.exit() first wins
|
|
86
|
+
// and the others silently never run.
|
|
87
|
+
const terminators = new Set();
|
|
88
|
+
|
|
89
|
+
export function onTerminate(fn) {
|
|
90
|
+
terminators.add(fn);
|
|
91
|
+
return () => terminators.delete(fn);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function runTerminators() {
|
|
95
|
+
for (const fn of [...terminators]) {
|
|
96
|
+
terminators.delete(fn);
|
|
97
|
+
try { fn(); } catch {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let installed = false;
|
|
102
|
+
// Idempotent: multiple orchestrator runs in one process share one set of hooks.
|
|
103
|
+
export function installExitGuards() {
|
|
104
|
+
if (installed) return;
|
|
105
|
+
installed = true;
|
|
106
|
+
|
|
107
|
+
process.once("exit", () => {
|
|
108
|
+
// Children first: they must not outlive the credentials they are using.
|
|
109
|
+
killAllTracked({ graceMs: 0 });
|
|
110
|
+
runTerminators();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
114
|
+
process.on(sig, () => {
|
|
115
|
+
// Defer to the app's own handler when there is one: `agentdesk team`
|
|
116
|
+
// and the daemon both shut down gracefully on SIGINT (abort the run,
|
|
117
|
+
// report the real status, close the socket). Exiting from here would
|
|
118
|
+
// preempt that and leave the server showing a session that never ended.
|
|
119
|
+
//
|
|
120
|
+
// This listener is a last resort for the case where nothing else is
|
|
121
|
+
// handling the signal — without it, Node's default disposition
|
|
122
|
+
// terminates the process without running `exit` handlers at all, and
|
|
123
|
+
// every spawned Claude is orphaned.
|
|
124
|
+
if (process.listenerCount(sig) > 1) return;
|
|
125
|
+
|
|
126
|
+
killAllTracked({ graceMs: 0 });
|
|
127
|
+
runTerminators();
|
|
128
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Project key derivation and collision handling for `agentdesk init`.
|
|
2
|
+
//
|
|
3
|
+
// A project key is the server-side id and the URL segment for a project. New
|
|
4
|
+
// mode derives it from the tracker team/project (e.g. Linear team "KEN" →
|
|
5
|
+
// "ken"), which reads well but is not unique: two repos mapped to the same
|
|
6
|
+
// team derive the same key, and the second `PUT /settings` silently
|
|
7
|
+
// overwrote the first repo's configuration. resolveProjectKey detects that
|
|
8
|
+
// and qualifies the key with the repo name instead.
|
|
9
|
+
//
|
|
10
|
+
// Pure functions; the caller fetches `existing` from the server.
|
|
11
|
+
|
|
12
|
+
export function slugify(s) {
|
|
13
|
+
return String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function deriveProjectKey({ trackerId, repoShort }) {
|
|
17
|
+
return slugify(trackerId || repoShort || "project");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const norm = r => (r ? String(r).toLowerCase() : null);
|
|
21
|
+
|
|
22
|
+
// existing: [{ id, repo }] — every project on the account with its
|
|
23
|
+
// configured github.repo (null when unknown).
|
|
24
|
+
//
|
|
25
|
+
// A key is only a collision when it is taken by a project whose repo is
|
|
26
|
+
// known AND different from ours. Same repo means this IS our project being
|
|
27
|
+
// re-initialised (`init --force-full` to re-push settings), and reusing the
|
|
28
|
+
// key is the intended behaviour. Unknown repo on either side is treated as
|
|
29
|
+
// "ours" too: a project with no GitHub repo cannot run sessions, so there is
|
|
30
|
+
// nothing there to clobber.
|
|
31
|
+
export function resolveProjectKey({ baseKey, repo, repoShort, existing = [] }) {
|
|
32
|
+
const ours = norm(repo);
|
|
33
|
+
const byId = new Map(existing.map(p => [p.id, { id: p.id, repo: norm(p.repo) }]));
|
|
34
|
+
|
|
35
|
+
const takenByOtherRepo = key => {
|
|
36
|
+
const hit = byId.get(key);
|
|
37
|
+
if (!hit) return null;
|
|
38
|
+
if (!ours || !hit.repo || hit.repo === ours) return null;
|
|
39
|
+
return hit;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const first = takenByOtherRepo(baseKey);
|
|
43
|
+
if (!first) return { key: baseKey, renamed: false };
|
|
44
|
+
|
|
45
|
+
const qualified = slugify(`${baseKey}-${repoShort || "repo"}`);
|
|
46
|
+
if (!takenByOtherRepo(qualified)) {
|
|
47
|
+
return { key: qualified, renamed: true, collidedWith: first };
|
|
48
|
+
}
|
|
49
|
+
for (let n = 2; n < 100; n++) {
|
|
50
|
+
const candidate = `${qualified}-${n}`;
|
|
51
|
+
if (!takenByOtherRepo(candidate)) {
|
|
52
|
+
return { key: candidate, renamed: true, collidedWith: first };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { key: `${qualified}-${Date.now()}`, renamed: true, collidedWith: first };
|
|
56
|
+
}
|