@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +33 -0
  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 +72 -44
  7. package/cli/dotenv.mjs +96 -13
  8. package/cli/engine/agents/index.mjs +152 -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 +119 -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 +290 -147
  29. package/cli/login.mjs +11 -3
  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/prompt.mjs +9 -503
  34. package/cli/prompts.mjs +20 -1
  35. package/cli/security-check.mjs +1 -1
  36. package/cli/session-isolation.mjs +65 -9
  37. package/cli/session-sandbox.mjs +13 -1
  38. package/cli/setup-helpers.mjs +83 -36
  39. package/cli/team.mjs +41 -34
  40. package/cli/tracker-check.mjs +12 -2
  41. package/cli/tracker-project.mjs +93 -0
  42. package/cli/update-check.mjs +62 -0
  43. package/package.json +12 -3
  44. package/cli/orchestrator.mjs +0 -461
  45. package/cli/stream-parser.mjs +0 -216
  46. package/prompts/phased.md +0 -549
  47. package/prompts/team.md +0 -505
package/cli/config.mjs CHANGED
@@ -58,8 +58,13 @@ async function fetchServerConfig(projectName, apiKey, serverUrl) {
58
58
  }
59
59
  }
60
60
 
61
+ // opts.readOnly — return the merged view without the two side effects
62
+ // (auto-heal push to the server, rewrite of .agentdesk.json). Setup wizards
63
+ // use this: they read the config at the top of the flow, before the user has
64
+ // confirmed anything, and must not mutate disk or server state on the way in.
65
+ // Runtime callers (`team`, `daemon`) leave it off so the cache stays fresh.
61
66
  export async function loadConfig(dir, opts = {}) {
62
- const { apiKey, serverUrl, projectName, silent = false } = opts;
67
+ const { apiKey, serverUrl, projectName, silent = false, readOnly = false } = opts;
63
68
  const configPath = join(dir, ".agentdesk.json");
64
69
 
65
70
  // Load local .agentdesk.json
@@ -101,7 +106,7 @@ export async function loadConfig(dir, opts = {}) {
101
106
  // no row at all or a partial row), push the merged config up so the
102
107
  // server catches up. Fire-and-forget — we already have the right
103
108
  // answer in `config` for the current caller.
104
- const shouldHeal = localConfig && apiKey && serverUrl && projectName && (
109
+ const shouldHeal = !readOnly && localConfig && apiKey && serverUrl && projectName && (
105
110
  !serverConfig || hasFieldsServerLacks(localConfig, serverConfig)
106
111
  );
107
112
  if (shouldHeal) {
@@ -113,7 +118,7 @@ export async function loadConfig(dir, opts = {}) {
113
118
  // actually fetched from the server — offline runs must not silently
114
119
  // mutate the user's local file. With mergeNonNull above, we're
115
120
  // guaranteed this write never strips existing local fields.
116
- if (serverConfig) {
121
+ if (serverConfig && !readOnly) {
117
122
  writeLocalConfig(configPath, config);
118
123
  }
119
124
 
@@ -137,14 +142,34 @@ function hasFieldsServerLacks(local, server) {
137
142
  return false;
138
143
  }
139
144
 
145
+ // The fields PUT /api/projects/:id/settings accepts. Anything else makes the
146
+ // server reject the whole request with 400 "Unknown fields", and the local
147
+ // merged config carries at least `projectKey` (the project's own id) — so
148
+ // every heal-sync and init push used to fail silently, and the server never
149
+ // learned the Jira project / Linear team the wizard had picked.
150
+ const SETTINGS_FIELDS = [
151
+ "tracker", "linear", "jira", "github", "team", "commands", "projectAgents",
152
+ "instructions", "screenshots", "phaseModels", "identityBadge",
153
+ ];
154
+
155
+ export function toSettingsPayload(config) {
156
+ const clean = stripCredentials(config || {});
157
+ const out = {};
158
+ for (const key of SETTINGS_FIELDS) {
159
+ if (clean[key] !== undefined) out[key] = clean[key];
160
+ }
161
+ return out;
162
+ }
163
+
140
164
  // Push a config object to the server. Caller is responsible for deciding when.
165
+ // The payload is reduced to the settings fields the server accepts.
141
166
  export async function pushConfig(apiKey, serverUrl, projectName, payload) {
142
167
  if (!apiKey || !serverUrl || !projectName) return { ok: false, error: "missing_auth" };
143
168
  try {
144
169
  const res = await fetch(`${serverUrl}/api/projects/${projectName}/settings`, {
145
170
  method: "PUT",
146
171
  headers: { "Content-Type": "application/json", "x-api-key": apiKey },
147
- body: JSON.stringify(payload),
172
+ body: JSON.stringify(toSettingsPayload(payload)),
148
173
  signal: AbortSignal.timeout(5000),
149
174
  });
150
175
  if (!res.ok) {
package/cli/daemon.mjs CHANGED
@@ -1,18 +1,16 @@
1
1
  // `agentdesk daemon` — local background daemon for UI-triggered sessions
2
2
 
3
- import { spawn } from "child_process";
4
3
  import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "fs";
5
4
  import { createInterface } from "readline";
6
5
  import { join } from "path";
7
- import { randomUUID } from "crypto";
8
6
  import WebSocket from "ws";
9
7
  import { detectProject } from "./detect.mjs";
10
8
  import { loadConfig } from "./config.mjs";
11
9
  import { getStoredApiKey, ensureAccountIdentity } from "./login.mjs";
12
- import { resolveTeam, generateTeamPrompt } from "./agents.mjs";
13
- import { buildPrompt } from "./prompt.mjs";
14
- import { createStreamParser } from "./stream-parser.mjs";
15
- import { runOrchestrator, runPhasedOrchestrator } from "./orchestrator.mjs";
10
+ import { resolveTeam } from "./agents.mjs";
11
+ import { runSession } from "./engine/session.mjs";
12
+ import { killTree } from "./proc.mjs";
13
+ import { loadDotEnv } from "./dotenv.mjs";
16
14
  import { getRegisteredProjects, registerLocalProject, claimLocalProjects } from "./projects.mjs";
17
15
  import { buildTrackerUrl } from "./tracker-url.mjs";
18
16
  import { fileURLToPath } from "url";
@@ -60,22 +58,6 @@ class RingBuffer {
60
58
  get length() { return this.items.length; }
61
59
  }
62
60
 
63
- // --- Dot-env loader ---
64
-
65
- function loadDotEnv(dir) {
66
- const envPath = join(dir, ".env");
67
- if (!existsSync(envPath)) return {};
68
- const vars = {};
69
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
70
- const trimmed = line.trim();
71
- if (!trimmed || trimmed.startsWith("#")) continue;
72
- const eq = trimmed.indexOf("=");
73
- if (eq === -1) continue;
74
- vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
75
- }
76
- return vars;
77
- }
78
-
79
61
  // --- Metadata logger ---
80
62
 
81
63
  function logSessionMetadata(sessionId, metadata) {
@@ -404,7 +386,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
404
386
 
405
387
  // 4. Session handling
406
388
 
407
- async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, phased, screenshots: screenshotsOverride }) {
389
+ async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride }) {
408
390
  // Validate project against local allowlist
409
391
  const project = projects.find(p => p.id === projectId);
410
392
  if (!project) {
@@ -435,7 +417,16 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
435
417
  return;
436
418
  }
437
419
  // Claim the slot immediately (before any await) to prevent race conditions
438
- activeSession = { sessionId, projectId, child: null, startedAt: Date.now(), filePathsTouched: new Set() };
420
+ activeSession = {
421
+ sessionId, projectId,
422
+ child: null,
423
+ // Cancelling must stop the whole phase pipeline, not just the child that
424
+ // happens to be running: killing one phase's child would otherwise let
425
+ // the loop advance and spawn the next phase.
426
+ abort: new AbortController(),
427
+ startedAt: Date.now(),
428
+ filePathsTouched: new Set(),
429
+ };
439
430
 
440
431
  console.log(` ${green}Starting session${reset} ${dim}${sessionId}${reset}`);
441
432
  console.log(` Project: ${project.name} ${dim}(${project.path})${reset}`);
@@ -460,25 +451,31 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
460
451
  // Build task link (only when a real tracker task ID was provided)
461
452
  const taskLink = remoteTaskId ? buildTrackerUrl({ tracker, config, taskId: remoteTaskId }) : null;
462
453
 
463
- // Resolve team
464
454
  const team = resolveTeam(config);
465
- const teamSections = generateTeamPrompt(team, { tracker, config });
466
455
 
467
456
  const sessionUrl = `${agentdeskServer}/sessions/${sessionId}`;
468
457
 
469
- // Run orchestrator
470
- const orchestrate = phased ? runPhasedOrchestrator : runOrchestrator;
471
- const result = await orchestrate({
458
+ // Run the engine (real subagents, one query per phase). The server may
459
+ // still send `phased`; every session is phased now.
460
+ const sessionAbort = activeSession.abort;
461
+ const result = await runSession({
472
462
  taskId, taskLink,
473
463
  description: prompt || "",
474
464
  createTask: !remoteTaskId && !!prompt && !!tracker,
475
465
  tracker, config,
476
- project: detected, team, teamSections,
466
+ project: detected, team,
477
467
  sessionUrl,
478
468
  sessionId,
479
469
  cwd: project.path,
480
470
  apiKey,
481
471
  serverUrl: agentdeskServer,
472
+ abortSignal: sessionAbort.signal,
473
+ // Hand the live child up so cancel/shutdown can actually kill it.
474
+ // This was never wired: activeSession.child stayed null forever, so
475
+ // every cancel reported "stopped" while Claude kept writing to the repo.
476
+ onChild: (child) => {
477
+ if (activeSession?.sessionId === sessionId) activeSession.child = child;
478
+ },
482
479
  onEvent: (() => {
483
480
  // Stagger agent messages for real-time feel, flush on non-message events
484
481
  const MSG_STAGGER_MS = 400;
@@ -504,6 +501,9 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
504
501
 
505
502
  return (event) => {
506
503
  if (!activeSession || activeSession.sessionId !== sessionId) return;
504
+ // A cancelled session already sent its terminal event; the
505
+ // orchestrator's own unwind must not send a second one.
506
+ if (activeSession.cancelled) return;
507
507
 
508
508
  if (event.type === "agent:message") {
509
509
  msgQueue.push(event);
@@ -528,12 +528,21 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
528
528
  })(),
529
529
  });
530
530
 
531
- console.log(` ${green}Session complete${reset} ${dim}${sessionId}${reset} (${result.duration}, ${result.steps} steps)`);
531
+ const outcome = result.status || (result.handoff ? "handoff" : "complete");
532
+ const clean = outcome === "complete";
533
+ console.log(
534
+ ` ${clean ? green : yellow}Session ${outcome}${reset} ${dim}${sessionId}${reset} (${result.duration}, ${result.steps} steps)`
535
+ );
532
536
 
533
537
  logSessionMetadata(sessionId, {
534
538
  sessionId, projectId,
535
539
  startedAt, endedAt: Date.now(),
536
- duration: result.duration, exitCode: 0, steps: result.steps,
540
+ duration: result.duration,
541
+ // Was hardcoded to 0, so the metadata log claimed every session
542
+ // exited cleanly regardless of what actually happened.
543
+ exitCode: clean ? 0 : 1,
544
+ status: outcome,
545
+ steps: result.steps,
537
546
  filePathsTouched: [...filePathsTouched],
538
547
  });
539
548
 
@@ -547,24 +556,43 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
547
556
  }
548
557
  }
549
558
 
550
- function killChild(child) {
551
- if (!child) return;
552
- try { child.kill("SIGTERM"); } catch {}
553
- // Force kill if SIGTERM doesn't work within 5 seconds
554
- setTimeout(() => {
555
- try { if (!child.killed) child.kill("SIGKILL"); } catch {}
556
- }, 5000);
559
+ // Tear down a session's work: signal the orchestrator loop to stop enqueuing
560
+ // phases, then kill the running child's whole process group. Both halves are
561
+ // required — killing the child alone just makes the loop start the next phase.
562
+ function stopSessionWork(session) {
563
+ if (!session) return;
564
+ try { session.abort?.abort(); } catch {}
565
+ killTree(session.child);
557
566
  }
558
567
 
559
568
  function handleCancelSession({ sessionId }) {
560
569
  if (activeSession?.sessionId === sessionId) {
561
570
  console.log(` ${yellow}Cancelling session${reset} ${dim}${sessionId}${reset}`);
562
571
  const duration = `${((Date.now() - activeSession.startedAt) / 1000).toFixed(1)}s`;
563
- killChild(activeSession.child);
564
- // Clear active session BEFORE sending session:end this prevents
565
- // the orchestrator's onEvent callback from sending a duplicate
566
- activeSession = null;
572
+
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.
578
+ activeSession.cancelled = true;
579
+ stopSessionWork(activeSession);
580
+
581
+ // The `cancelled` flag also suppresses the orchestrator's own
582
+ // session:end, so this stays the single terminal event for the session.
567
583
  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?.();
568
596
  }
569
597
  }
570
598
 
@@ -576,7 +604,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
576
604
  clearTimeout(reconnectTimer);
577
605
 
578
606
  if (activeSession) {
579
- killChild(activeSession.child);
607
+ stopSessionWork(activeSession);
580
608
  }
581
609
 
582
610
  send({ type: "daemon:disconnect" });
package/cli/dotenv.mjs CHANGED
@@ -1,22 +1,105 @@
1
- // Minimal .env reader used by orchestrator + preflight. Does not interpret
2
- // quotes or escapes — matches the pre-existing behavior of the duplicated
3
- // loaders in cli/team.mjs, cli/init.mjs, and cli/daemon.mjs (which we are
4
- // NOT consolidating in this PR out of scope).
1
+ // The one .env reader/writer for the CLI.
2
+ //
3
+ // There used to be five copies of the loader (here, setup-helpers, bootstrap,
4
+ // daemon, team) and two of the writer. None stripped quotes, so a perfectly
5
+ // conventional line like
6
+ //
7
+ // GITHUB_TOKEN="ghp_abc123"
8
+ //
9
+ // produced a token with literal quote characters in it and a baffling 401 from
10
+ // GitHub. A fix had to be applied five times to land. Every caller now imports
11
+ // from here.
12
+ //
13
+ // Deliberately minimal: quotes are stripped, `export KEY=…` is tolerated,
14
+ // comments and blanks are skipped. Escape sequences and `${VAR}` interpolation
15
+ // are NOT interpreted — a token is an opaque string and must round-trip as-is.
5
16
 
6
- import { existsSync, readFileSync } from "fs";
17
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "fs";
7
18
  import { join } from "path";
8
19
 
20
+ export function parseDotEnv(text) {
21
+ const vars = {};
22
+ for (const rawLine of String(text ?? "").split(/\r?\n/)) {
23
+ let line = rawLine.trim();
24
+ if (!line || line.startsWith("#")) continue;
25
+ if (line.startsWith("export ")) line = line.slice(7).trim();
26
+
27
+ const eq = line.indexOf("=");
28
+ if (eq === -1) continue;
29
+
30
+ const key = line.slice(0, eq).trim();
31
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
32
+
33
+ let value = line.slice(eq + 1).trim();
34
+ const quote = value[0];
35
+ if ((quote === '"' || quote === "'") && value.length >= 2 && value.endsWith(quote)) {
36
+ value = value.slice(1, -1);
37
+ }
38
+ vars[key] = value;
39
+ }
40
+ return vars;
41
+ }
42
+
9
43
  export function loadDotEnv(dir) {
10
44
  if (!dir) return {};
11
45
  const envPath = join(dir, ".env");
12
46
  if (!existsSync(envPath)) return {};
13
- const vars = {};
14
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
15
- const trimmed = line.trim();
16
- if (!trimmed || trimmed.startsWith("#")) continue;
17
- const eq = trimmed.indexOf("=");
18
- if (eq === -1) continue;
19
- vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
47
+ try {
48
+ return parseDotEnv(readFileSync(envPath, "utf-8"));
49
+ } catch {
50
+ return {};
20
51
  }
21
- return vars;
52
+ }
53
+
54
+ function escapeRegex(s) {
55
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ }
57
+
58
+ // Upsert KEY=value in <dir>/.env, preserving every other line verbatim.
59
+ //
60
+ // The file ends up mode 0600 whether it was just created or already existed —
61
+ // `writeFileSync`'s `mode` option only applies on creation, so an existing
62
+ // world-readable .env would otherwise stay world-readable after we drop a
63
+ // GitHub token into it.
64
+ export function saveEnvVar(dir, key, value) {
65
+ const envPath = join(dir, ".env");
66
+ let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
67
+ const re = new RegExp(`^(?:export\\s+)?${escapeRegex(key)}=.*$`, "m");
68
+ const entry = `${key}=${value}`;
69
+ if (re.test(content)) {
70
+ content = content.replace(re, entry);
71
+ } else {
72
+ content += `${content && !content.endsWith("\n") ? "\n" : ""}${entry}\n`;
73
+ }
74
+ writeFileSync(envPath, content, { mode: 0o600 });
75
+ try { chmodSync(envPath, 0o600); } catch {}
76
+ return envPath;
77
+ }
78
+
79
+ // Make sure each of `entries` is listed in <dir>/.gitignore. Returns the
80
+ // entries that had to be added (empty when everything was already covered).
81
+ //
82
+ // Matching is deliberately loose about the forms people actually write —
83
+ // `.env`, `/.env`, `.agentdesk`, `.agentdesk/` — and deliberately NOT clever
84
+ // about globs: `.env*` is not treated as covering `.env`, because a rule we
85
+ // can't reason about is a rule we shouldn't rely on for a credential file.
86
+ export function ensureGitignored(dir, entries) {
87
+ const path = join(dir, ".gitignore");
88
+ const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
89
+ const present = new Set(
90
+ existing.split(/\r?\n/).map(l => l.trim().replace(/^\//, "").replace(/\/$/, "")).filter(Boolean)
91
+ );
92
+
93
+ const added = [];
94
+ for (const entry of entries) {
95
+ const norm = entry.replace(/^\//, "").replace(/\/$/, "");
96
+ if (present.has(norm)) continue;
97
+ added.push(entry);
98
+ present.add(norm);
99
+ }
100
+ if (added.length === 0) return added;
101
+
102
+ const nl = existing && !existing.endsWith("\n") ? "\n" : "";
103
+ writeFileSync(path, `${existing}${nl}${added.join("\n")}\n`);
104
+ return added;
22
105
  }
@@ -0,0 +1,152 @@
1
+ // Persona registry → Agent SDK definitions.
2
+ //
3
+ // The team (cli/agents.mjs BUILT_IN_AGENTS + project customisations via
4
+ // resolveTeam) becomes, per phase:
5
+ // - one lead definition — Jane — that the main thread runs *as* (Options.agent),
6
+ // with the Agent tool and nothing else. She cannot touch code because the
7
+ // tools do not exist in her session.
8
+ // - one AgentDefinition per engineering role, with the tool list that role
9
+ // needs in that phase and nothing more.
10
+ //
11
+ // Tool lists are the enforcement; prompts describe intent. hooks.mjs is the
12
+ // belt to these braces.
13
+
14
+ import { BUILT_IN_AGENTS } from "../../agents.mjs";
15
+
16
+ export const LEAD = "Jane";
17
+ export const READ_ONLY = Object.freeze(["Read", "Grep", "Glob"]);
18
+ const RO_BASH = Object.freeze([...READ_ONLY, "Bash"]);
19
+ const FULL = Object.freeze(["Read", "Edit", "Write", "Bash", "Grep", "Glob"]);
20
+
21
+ // Which built-in agents take part in each phase, and with which tools.
22
+ export const PHASE_ROSTER = Object.freeze({
23
+ INTAKE: { Dennis: RO_BASH },
24
+ PLAN: { Dennis: READ_ONLY, Sam: READ_ONLY, Vera: READ_ONLY, Luna: READ_ONLY, Mark: READ_ONLY, Nora: READ_ONLY },
25
+ EXECUTION: { Dennis: FULL, Sam: READ_ONLY, Vera: FULL, Bart: RO_BASH, Luna: RO_BASH, Mark: READ_ONLY, Nora: FULL },
26
+ // Reviewers read the diff cold and may run read-only commands (tests, git
27
+ // log). None of them can edit — fixes happen back in EXECUTION.
28
+ REVIEW: { Sam: READ_ONLY, Bart: RO_BASH, Vera: RO_BASH },
29
+ SUMMARY: { Dennis: RO_BASH },
30
+ });
31
+
32
+ // Phases where project-defined custom agents (config.projectAgents) join.
33
+ const CUSTOM_AGENT_PHASES = new Set(["PLAN", "EXECUTION"]);
34
+
35
+ // Today's defaults: REVIEW and SUMMARY on haiku, the rest on the CLI default.
36
+ // `phaseModels` values are "opus" | "sonnet" | "haiku" | "default" | undefined.
37
+ export function modelForPhase(phase, phaseModels = {}) {
38
+ const choice = phaseModels?.[phase];
39
+ if (choice && choice !== "default") return choice;
40
+ if (phase === "REVIEW" || phase === "SUMMARY") return "haiku";
41
+ return undefined; // let the SDK/CLI pick its default
42
+ }
43
+
44
+ const PHASE_GUIDANCE = {
45
+ INTAKE: "Phase INTAKE: gather the task, its attachments and the repo context. Report facts. Do not plan the implementation and do not write code.",
46
+ PLAN: "Phase PLAN: assess the approach from your role's angle and report a concrete plan contribution. Do not modify any files.",
47
+ EXECUTION: "Phase EXECUTION: do your role's part of the plan. Verify every claim with an observation (command output, test result, rendered page) before reporting it.",
48
+ REVIEW: "Phase REVIEW: you are reviewing a diff you did not write. Report concrete findings with file:line. Be strict but not pedantic — only real gaps against the task and the plan. Do not modify anything.",
49
+ SUMMARY: "Phase SUMMARY: execute exactly the tracker writes the lead dictates and confirm each with the command's output.",
50
+ };
51
+
52
+ function executionTasks(a) {
53
+ const tasks = a.execution?.tasks;
54
+ if (!Array.isArray(tasks) || tasks.length === 0) return "";
55
+ return `\nYour execution checklist:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join("\n")}\n`;
56
+ }
57
+
58
+ export function agentSystemPrompt(a, phase) {
59
+ return [
60
+ `You are ${a.name}, ${a.role} on an AgentDesk software team led by ${LEAD}.`,
61
+ `Role: ${a.description}.`,
62
+ a.groundRules ? `Ground rules: ${a.groundRules}` : "",
63
+ a.codePrinciple ? `Code principle: ${a.codePrinciple}` : "",
64
+ "",
65
+ PHASE_GUIDANCE[phase] || "",
66
+ phase === "EXECUTION" ? executionTasks(a) : "",
67
+ "",
68
+ "No announcement without observation: never claim something is done, passes, or works unless you ran the check and read its output. If the observation is out of reach, say so plainly.",
69
+ `Report back to ${LEAD} concisely. You may prefix a message with [THINK], [ACT], [ARGUE] or [AGREE] to make your stance clear.`,
70
+ ].filter(l => l !== null && l !== undefined).join("\n").replace(/\n{3,}/g, "\n\n").trim();
71
+ }
72
+
73
+ function rosterLine(name, def) {
74
+ return `- ${name} (${def.role}) — ${def.description}. Tools: ${def.tools.join(", ")}.`;
75
+ }
76
+
77
+ export function leadSystemPrompt(roster, phase) {
78
+ const jane = BUILT_IN_AGENTS.Jane;
79
+ const lines = Object.entries(roster).map(([n, d]) => rosterLine(n, d));
80
+ return [
81
+ `You are ${LEAD}, ${jane.role}. ${jane.description}.`,
82
+ "",
83
+ "You coordinate; you do not build. You have exactly one tool — Agent — and you use it to delegate to the team. You never read files, run commands, or edit anything yourself; when you need technical information, ask an agent for it.",
84
+ "Always delegate with run_in_background: false. A backgrounded agent's own tool calls cannot be approved, so it will be unable to do anything.",
85
+ "Your language is product-only: user stories, acceptance criteria, scope, priorities, stakeholder impact.",
86
+ "",
87
+ `Team available in phase ${phase}:`,
88
+ ...lines,
89
+ "",
90
+ "When you delegate, give the agent everything it needs in the prompt: the task, the relevant decisions so far, and exactly what to report back. Agents start with no memory of this conversation.",
91
+ "When an agent reports, relay the substance in one to three lines and move on. Never claim a result you did not receive from an agent.",
92
+ "Announce a new task id, if you create one, on its own line as `TASK_ID: <id>`, and a short session title as `SESSION_TITLE: <title>`.",
93
+ ].join("\n");
94
+ }
95
+
96
+ // Solo mode: the named agent IS the main thread, with the full tool set and
97
+ // no subagents. Its system prompt is the phase template (renderSoloPrompt), so
98
+ // the AgentDefinition prompt stays a short identity line.
99
+ export function soloDefinition(agent) {
100
+ const def = {
101
+ description: `${agent.role}: ${agent.description}`,
102
+ prompt: `You are ${agent.name}, ${agent.role}. You work alone on the task given to you, end to end.`,
103
+ tools: [...FULL],
104
+ };
105
+ return { agents: { [agent.name]: def }, allowedTools: [...FULL], lead: agent.name };
106
+ }
107
+
108
+ // team — resolveTeam(config) output (array of { name, role, description, ... })
109
+ // phase — one of PHASES
110
+ // phaseModels — config.phaseModels
111
+ // Returns { agents: Record<name, AgentDefinition>, allowedTools: string[], lead: "Jane" }
112
+ export function agentsForPhase({ phase, team, phaseModels = {} }) {
113
+ const roster = PHASE_ROSTER[phase] || {};
114
+ const subagentModel = (phase === "REVIEW" || phase === "SUMMARY") ? modelForPhase(phase, phaseModels) : "inherit";
115
+
116
+ const agents = {};
117
+ for (const a of team) {
118
+ if (a.name === LEAD) continue;
119
+ const isBuiltIn = !!BUILT_IN_AGENTS[a.name];
120
+ let tools;
121
+ if (isBuiltIn) {
122
+ tools = roster[a.name];
123
+ if (!tools) continue; // this role has no part in this phase
124
+ } else {
125
+ if (!CUSTOM_AGENT_PHASES.has(phase)) continue;
126
+ tools = Array.isArray(a.tools) && a.tools.length ? a.tools : READ_ONLY;
127
+ }
128
+ agents[a.name] = {
129
+ description: `${a.role}: ${a.description}`,
130
+ prompt: agentSystemPrompt(a, phase),
131
+ tools: [...tools],
132
+ model: subagentModel,
133
+ role: a.role,
134
+ };
135
+ }
136
+
137
+ // Strip our bookkeeping field before handing definitions to the SDK.
138
+ const sdkAgents = {};
139
+ for (const [name, def] of Object.entries(agents)) {
140
+ sdkAgents[name] = { description: def.description, prompt: def.prompt, tools: def.tools, model: def.model };
141
+ }
142
+ sdkAgents[LEAD] = {
143
+ description: `${BUILT_IN_AGENTS.Jane.role}: ${BUILT_IN_AGENTS.Jane.description}`,
144
+ prompt: leadSystemPrompt(agents, phase),
145
+ tools: ["Agent"],
146
+ };
147
+
148
+ const allowed = new Set(["Agent"]);
149
+ for (const def of Object.values(sdkAgents)) for (const t of def.tools) allowed.add(t);
150
+
151
+ return { agents: sdkAgents, allowedTools: [...allowed], lead: LEAD };
152
+ }
@@ -0,0 +1,72 @@
1
+ // Can a *standalone* Claude process authenticate on this machine?
2
+ //
3
+ // Setup used to check GitHub and the tracker and never Claude itself, so a
4
+ // new user reached their first session and got "Not logged in" from deep
5
+ // inside the engine. An interactive `claude` working in the user's terminal
6
+ // is not proof: it may be inheriting auth from a parent session that our
7
+ // child processes cannot reach.
8
+ //
9
+ // The check runs the same binary the engine will spawn, with the same
10
+ // scrubbed environment, and reads `claude auth status`.
11
+
12
+ import { execFile } from "child_process";
13
+ import { createRequire } from "node:module";
14
+ import { dirname, join } from "path";
15
+ import { stripParentSessionVars } from "./env.mjs";
16
+
17
+ export function bundledClaudePath() {
18
+ const platformPkg = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`;
19
+ try {
20
+ const pkgDir = dirname(createRequire(import.meta.url).resolve(`${platformPkg}/package.json`));
21
+ return join(pkgDir, process.platform === "win32" ? "claude.exe" : "claude");
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function claudeBinary(env = process.env) {
28
+ return env.AGENTDESK_CLAUDE_PATH || bundledClaudePath() || "claude";
29
+ }
30
+
31
+ export const CLAUDE_LOGIN_HINT = [
32
+ "Claude Code has no login that a standalone session can use.",
33
+ "Fix one of:",
34
+ " • run `claude auth login` in this terminal (if you use account profiles,",
35
+ " the same CLAUDE_CONFIG_DIR you will run agentdesk with), then re-run;",
36
+ " • or add ANTHROPIC_API_KEY=<key> to the project's .env.",
37
+ ].join("\n");
38
+
39
+ function run(exec, bin, args, env, timeoutMs) {
40
+ return new Promise(resolve => {
41
+ exec(bin, args, { env, timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
42
+ resolve({ err, stdout: String(stdout || ""), stderr: String(stderr || "") });
43
+ });
44
+ });
45
+ }
46
+
47
+ // Returns { ok, method, detail, hint? }.
48
+ // env — the environment the session child will get (dotenv + sandbox applied)
49
+ // exec — injectable for tests (child_process.execFile signature)
50
+ export async function checkClaudeAuth({ env = process.env, exec = execFile, timeoutMs = 10000 } = {}) {
51
+ if (env.ANTHROPIC_API_KEY) return { ok: true, method: "api-key", detail: "ANTHROPIC_API_KEY" };
52
+
53
+ const bin = claudeBinary(env);
54
+ const { err, stdout } = await run(exec, bin, ["auth", "status"], stripParentSessionVars(env), timeoutMs);
55
+
56
+ if (err && !stdout) {
57
+ const detail = err.code === "ENOENT"
58
+ ? `Claude binary not found (${bin})`
59
+ : `could not run \`claude auth status\` (${err.message || err.code})`;
60
+ return { ok: false, method: "unknown", detail, hint: CLAUDE_LOGIN_HINT };
61
+ }
62
+
63
+ let status;
64
+ try { status = JSON.parse(stdout); } catch {
65
+ return { ok: false, method: "unknown", detail: "unexpected output from `claude auth status`", hint: CLAUDE_LOGIN_HINT };
66
+ }
67
+
68
+ if (status.loggedIn === true) {
69
+ return { ok: true, method: status.authMethod || "oauth", detail: status.email || status.authMethod || "logged in" };
70
+ }
71
+ return { ok: false, method: "none", detail: "not logged in", hint: CLAUDE_LOGIN_HINT };
72
+ }