@kendoo.agentdesk/agentdesk 0.26.0 → 0.28.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/bin/agentdesk.mjs +35 -45
  3. package/cli/agents.mjs +4 -256
  4. package/cli/bootstrap.mjs +40 -59
  5. package/cli/config.mjs +29 -4
  6. package/cli/daemon.mjs +148 -66
  7. package/cli/dotenv.mjs +96 -13
  8. package/cli/engine/agents/index.mjs +151 -0
  9. package/cli/engine/claude-auth.mjs +72 -0
  10. package/cli/engine/env.mjs +56 -0
  11. package/cli/engine/events.mjs +214 -0
  12. package/cli/engine/hooks.mjs +112 -0
  13. package/cli/engine/phases/EXECUTION.md +45 -0
  14. package/cli/engine/phases/INTAKE.md +34 -0
  15. package/cli/engine/phases/PLAN.md +26 -0
  16. package/cli/engine/phases/REVIEW.md +21 -0
  17. package/cli/engine/phases/SOLO.md +115 -0
  18. package/cli/engine/phases/SUMMARY.md +23 -0
  19. package/cli/engine/prompts.mjs +181 -0
  20. package/cli/engine/query.mjs +63 -0
  21. package/cli/engine/schemas.mjs +180 -0
  22. package/cli/engine/session.mjs +285 -0
  23. package/cli/engine/spawn.mjs +83 -0
  24. package/cli/engine/tracker/github.md +19 -0
  25. package/cli/engine/tracker/jira.md +23 -0
  26. package/cli/engine/tracker/linear.md +24 -0
  27. package/cli/engine/verdict.mjs +83 -0
  28. package/cli/init.mjs +295 -149
  29. package/cli/login.mjs +52 -6
  30. package/cli/phase-loop.mjs +78 -0
  31. package/cli/proc.mjs +131 -0
  32. package/cli/project-key.mjs +56 -0
  33. package/cli/projects.mjs +41 -6
  34. package/cli/prompt.mjs +9 -503
  35. package/cli/prompts.mjs +20 -1
  36. package/cli/security-check.mjs +1 -1
  37. package/cli/session-isolation.mjs +65 -9
  38. package/cli/session-sandbox.mjs +13 -1
  39. package/cli/setup-helpers.mjs +83 -36
  40. package/cli/team.mjs +41 -34
  41. package/cli/tracker-check.mjs +12 -2
  42. package/cli/tracker-project.mjs +93 -0
  43. package/cli/update-check.mjs +62 -0
  44. package/package.json +12 -3
  45. package/cli/orchestrator.mjs +0 -461
  46. package/cli/stream-parser.mjs +0 -216
  47. package/prompts/phased.md +0 -549
  48. package/prompts/team.md +0 -505
package/cli/login.mjs CHANGED
@@ -17,13 +17,42 @@ function getEnvApiKey() {
17
17
  }
18
18
 
19
19
  export function getStoredApiKey() {
20
+ return getStoredCredentials()?.apiKey || null;
21
+ }
22
+
23
+ export function getStoredCredentials() {
20
24
  if (!existsSync(CREDENTIALS_PATH)) return null;
21
25
  try {
22
- const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
23
- return creds.apiKey || null;
26
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
27
+ } catch { return null; }
28
+ }
29
+
30
+ // AD-64: resolve which account an API key belongs to, so local project
31
+ // registry entries can be scoped per account.
32
+ async function fetchAccountIdentity(apiKey) {
33
+ try {
34
+ const res = await fetch(`${AGENTDESK_SERVER}/api/me`, {
35
+ headers: { "x-api-key": apiKey },
36
+ signal: AbortSignal.timeout(5000),
37
+ });
38
+ if (!res.ok) return null;
39
+ const me = await res.json();
40
+ return me?.id ? { accountId: me.id, email: me.email || null } : null;
24
41
  } catch { return null; }
25
42
  }
26
43
 
44
+ // Backfill accountId into credentials saved by pre-AD-64 versions.
45
+ // Returns the (possibly updated) credentials object.
46
+ export async function ensureAccountIdentity() {
47
+ const creds = getStoredCredentials();
48
+ if (!creds?.apiKey || creds.accountId) return creds;
49
+ const identity = await fetchAccountIdentity(creds.apiKey);
50
+ if (!identity) return creds;
51
+ const updated = { ...creds, ...identity };
52
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify(updated, null, 2), { mode: 0o600 });
53
+ return updated;
54
+ }
55
+
27
56
  export async function runLogin() {
28
57
  console.log("");
29
58
  console.log(" AgentDesk — Login");
@@ -120,7 +149,16 @@ export async function runLogin() {
120
149
  console.log(" agentdesk team TASK-123");
121
150
  console.log("");
122
151
 
123
- setTimeout(() => { server.close(); process.exit(0); }, 500);
152
+ // AD-64: attach the account identity so the project registry can be
153
+ // scoped per account. Best-effort — login still succeeds without it.
154
+ fetchAccountIdentity(apiKey)
155
+ .then(identity => {
156
+ if (identity) {
157
+ writeFileSync(CREDENTIALS_PATH, JSON.stringify({ apiKey, name, ...identity, savedAt: Date.now() }, null, 2), { mode: 0o600 });
158
+ }
159
+ })
160
+ .catch(() => {})
161
+ .finally(() => { server.close(); process.exit(0); });
124
162
  } else {
125
163
  res.writeHead(400, { "Content-Type": "text/html" });
126
164
  res.end("<html><body><h2>Login failed. No API key received.</h2></body></html>");
@@ -147,11 +185,19 @@ export async function runLogin() {
147
185
  console.log("");
148
186
  console.log(" Waiting for authentication...");
149
187
 
150
- // Open browser (use execFile to avoid shell injection)
188
+ // Open browser (use execFile to avoid shell injection).
189
+ //
190
+ // On Windows `start` is a cmd.exe builtin, not an executable, so
191
+ // execFile("start", …) failed silently and the browser never opened. Go
192
+ // through cmd, with the empty "" title argument `start` requires when the
193
+ // next argument is a quoted URL.
151
194
  const { execFile } = await import("child_process");
152
195
  const platform = process.platform;
153
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
154
- execFile(cmd, [loginUrl], () => {});
196
+ if (platform === "win32") {
197
+ execFile("cmd", ["/c", "start", "", loginUrl], () => {});
198
+ } else {
199
+ execFile(platform === "darwin" ? "open" : "xdg-open", [loginUrl], () => {});
200
+ }
155
201
 
156
202
  // Timeout after 5 minutes
157
203
  setTimeout(() => {
@@ -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
+ }
package/cli/projects.mjs CHANGED
@@ -1,4 +1,9 @@
1
1
  // Local project registry — tracks which projects have been initialized with `agentdesk init`
2
+ //
3
+ // AD-64: entries are tagged with the accountId they were registered under so
4
+ // the daemon can scope its project list to the logged-in account. Entries
5
+ // written by older versions have no accountId ("untagged") — the daemon
6
+ // claims them for the active account when the server confirms ownership.
2
7
 
3
8
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
9
  import { join } from "path";
@@ -6,7 +11,7 @@ import { join } from "path";
6
11
  const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE, ".agentdesk");
7
12
  const PROJECTS_PATH = join(CONFIG_DIR, "projects.json");
8
13
 
9
- export function getRegisteredProjects() {
14
+ function readRegistry() {
10
15
  try {
11
16
  if (!existsSync(PROJECTS_PATH)) return [];
12
17
  const data = JSON.parse(readFileSync(PROJECTS_PATH, "utf-8"));
@@ -16,10 +21,28 @@ export function getRegisteredProjects() {
16
21
  }
17
22
  }
18
23
 
19
- export function registerLocalProject(id, name, path) {
20
- const projects = getRegisteredProjects();
24
+ function writeRegistry(projects) {
25
+ if (!existsSync(CONFIG_DIR)) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
27
+ }
28
+ writeFileSync(PROJECTS_PATH, JSON.stringify({ projects }, null, 2) + "\n", { mode: 0o600 });
29
+ }
30
+
31
+ // accountId: return only that account's entries plus legacy untagged ones
32
+ // (callers that can reach the server should claimLocalProjects() first so
33
+ // untagged entries get resolved rather than leaking across accounts).
34
+ export function getRegisteredProjects(accountId) {
35
+ const projects = readRegistry();
36
+ if (!accountId) return projects;
37
+ return projects.filter(p => !p.accountId || p.accountId === accountId);
38
+ }
39
+
40
+ export function registerLocalProject(id, name, path, accountId) {
41
+ const projects = readRegistry();
21
42
  const existing = projects.findIndex(p => p.id === id);
22
43
  const entry = { id, name, path, registeredAt: Date.now() };
44
+ if (accountId) entry.accountId = accountId;
45
+ else if (existing >= 0 && projects[existing].accountId) entry.accountId = projects[existing].accountId;
23
46
 
24
47
  if (existing >= 0) {
25
48
  projects[existing] = entry;
@@ -27,8 +50,20 @@ export function registerLocalProject(id, name, path) {
27
50
  projects.push(entry);
28
51
  }
29
52
 
30
- if (!existsSync(CONFIG_DIR)) {
31
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
53
+ writeRegistry(projects);
54
+ }
55
+
56
+ // Tag untagged entries whose ids the server confirmed belong to accountId.
57
+ export function claimLocalProjects(ids, accountId) {
58
+ if (!accountId || !ids?.length) return;
59
+ const idSet = new Set(ids);
60
+ const projects = readRegistry();
61
+ let changed = false;
62
+ for (const p of projects) {
63
+ if (!p.accountId && idSet.has(p.id)) {
64
+ p.accountId = accountId;
65
+ changed = true;
66
+ }
32
67
  }
33
- writeFileSync(PROJECTS_PATH, JSON.stringify({ projects }, null, 2) + "\n", { mode: 0o600 });
68
+ if (changed) writeRegistry(projects);
34
69
  }