@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
@@ -22,8 +22,8 @@
22
22
  // "scoped env" rather than "kernel boundary."
23
23
 
24
24
  import { execSync } from "child_process";
25
- import { existsSync, writeFileSync } from "fs";
26
- import { join } from "path";
25
+ import { writeFileSync } from "fs";
26
+ import { join, dirname } from "path";
27
27
  import { platform, homedir } from "os";
28
28
 
29
29
  // Detect which kernel-isolation tool is usable. Cached after first probe.
@@ -65,7 +65,12 @@ function legacyMode() {
65
65
  // Build the spawn arguments for an isolated claude invocation. Takes the
66
66
  // original command/args/options and returns the wrapped form, plus a flag
67
67
  // indicating which isolation mode is active.
68
- export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, sessionId }) {
68
+ //
69
+ // extraReadPaths: additional directories to expose read-only under the strict
70
+ // policy. The engine passes the Agent SDK's bundled Claude binary here — it
71
+ // lives under an npm prefix (global or the project's node_modules), which the
72
+ // deny-default allowlist does not necessarily cover.
73
+ export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, extraReadPaths = [] }) {
69
74
  const probe = probeIsolation();
70
75
  if (probe.kind === "none") {
71
76
  return { cmd, args, isolation: { kind: "none", reason: probe.reason } };
@@ -77,7 +82,7 @@ export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, sessionId }) {
77
82
  const profilePath = join(scratchHome, "sandbox.sb");
78
83
  const profile = policyKind === "legacy"
79
84
  ? macosProfileLegacy({ cwd, scratchHome })
80
- : macosProfileStrict({ cwd, scratchHome });
85
+ : macosProfileStrict({ cwd, scratchHome, extraReadPaths });
81
86
  writeFileSync(profilePath, profile, { mode: 0o600 });
82
87
  return {
83
88
  cmd: "sandbox-exec",
@@ -89,7 +94,7 @@ export function wrapIsolatedSpawn({ cmd, args, cwd, scratchHome, sessionId }) {
89
94
  if (probe.kind === "bwrap") {
90
95
  const bwrap = policyKind === "legacy"
91
96
  ? bwrapArgsLegacy({ cwd, scratchHome })
92
- : bwrapArgsStrict({ cwd, scratchHome });
97
+ : bwrapArgsStrict({ cwd, scratchHome, extraReadPaths });
93
98
  return {
94
99
  cmd: "bwrap",
95
100
  args: [...bwrap, cmd, ...args],
@@ -110,9 +115,20 @@ function sbString(s) {
110
115
  // paths and capabilities legitimate tools need. Anything under $HOME that
111
116
  // isn't explicitly allowed is unreadable — that includes ~/Documents,
112
117
  // browser cookie stores, other projects' .env files, etc.
113
- function macosProfileStrict({ cwd, scratchHome }) {
118
+ function macosProfileStrict({ cwd, scratchHome, extraReadPaths = [] }) {
114
119
  const home = homedir();
115
120
 
121
+ // AD-65: Claude walks up from cwd (git root, CLAUDE.md, project lookups) and
122
+ // aborts if an ancestor directory node is unreadable. Grant the directory
123
+ // nodes themselves (literal = stat + listing), NOT their contents.
124
+ const ancestorDirs = [];
125
+ for (let dir = cwd; ; ) {
126
+ const parent = dirname(dir);
127
+ if (parent === dir) break;
128
+ ancestorDirs.push(parent);
129
+ dir = parent;
130
+ }
131
+
116
132
  // Tool installations and caches users typically need read access to. If a
117
133
  // path doesn't exist we still list it — sandbox-exec ignores missing paths.
118
134
  const userToolPaths = [
@@ -150,6 +166,33 @@ function macosProfileStrict({ cwd, scratchHome }) {
150
166
  `(allow file-read* file-write* (subpath ${sbString(cwd)}))`,
151
167
  `(allow file-read* file-write* (subpath ${sbString(scratchHome)}))`,
152
168
  ``,
169
+ `; Root directory + top-level symlinks (/etc → /private/etc, /var, /tmp).`,
170
+ `; dyld stats "/" during process startup on modern macOS; denying it aborts`,
171
+ `; every child before main() (AD-65).`,
172
+ `(allow file-read* (literal "/") (literal "/etc") (literal "/var") (literal "/tmp"))`,
173
+ ``,
174
+ `; Ancestor directory nodes of cwd — dir listing/stat only, not contents (AD-65).`,
175
+ ...ancestorDirs.map(p => `(allow file-read* (literal ${sbString(p)}))`),
176
+ ``,
177
+ `; Claude's own state — it authenticates and persists session state here.`,
178
+ `(allow file-read* file-write* (subpath ${sbString(join(home, ".claude"))}))`,
179
+ `(allow file-read* file-write* (prefix ${sbString(join(home, ".claude.json"))}))`,
180
+ `(allow file-read* file-write* (subpath ${sbString(join(home, ".config", "claude"))}))`,
181
+ // Account profiles: CLAUDE_CONFIG_DIR relocates all of the above. Without
182
+ // this a profile user's child cannot read its own credentials and reports
183
+ // "Not logged in" inside the sandbox.
184
+ ...(process.env.CLAUDE_CONFIG_DIR
185
+ ? [`(allow file-read* file-write* (subpath ${sbString(process.env.CLAUDE_CONFIG_DIR)}))`]
186
+ : []),
187
+ ``,
188
+ `; macOS Keychain — Claude Code stores its OAuth session there. The Security`,
189
+ `; framework opens the login keychain database directly; with it denied the`,
190
+ `; child reports "Not logged in" even when the machine is logged in.`,
191
+ `(allow file-read* (subpath ${sbString(join(home, "Library", "Keychains"))}))`,
192
+ `(allow file-read* (subpath "/private/var/db/mds"))`,
193
+ `(allow file-read* file-write* (subpath "/private/var/folders"))`,
194
+ `(allow ipc-posix-shm)`,
195
+ ``,
153
196
  `; System binaries and libraries — read-only.`,
154
197
  `(allow file-read* (subpath "/usr"))`,
155
198
  `(allow file-read* (subpath "/bin"))`,
@@ -182,12 +225,15 @@ function macosProfileStrict({ cwd, scratchHome }) {
182
225
  `; readable from inside the sandbox.`,
183
226
  ...userToolPaths.map(p => `(allow file-read* file-write* (subpath ${sbString(p)}))`),
184
227
  ``,
228
+ `; Caller-supplied read-only paths (e.g. the Agent SDK's bundled Claude binary).`,
229
+ ...extraReadPaths.filter(Boolean).map(p => `(allow file-read* (subpath ${sbString(p)}))`),
230
+ ``,
185
231
  ].join("\n");
186
232
  }
187
233
 
188
234
  // Pre-AD-30 denylist — kept as opt-in fallback via AGENTDESK_SANDBOX_LEGACY=1
189
235
  // in case the strict policy breaks a tool we haven't accounted for.
190
- function macosProfileLegacy({ cwd, scratchHome }) {
236
+ function macosProfileLegacy() {
191
237
  const home = homedir();
192
238
  const denyPathsSubpath = [
193
239
  join(home, ".ssh"),
@@ -227,7 +273,7 @@ function macosProfileLegacy({ cwd, scratchHome }) {
227
273
  // AD-30 strict allowlist via bwrap. Read-only binds for system paths, --tmpfs
228
274
  // over $HOME so nothing on the user's home directory is visible by default,
229
275
  // then re-expose specific tool dirs that exist as read-only binds.
230
- function bwrapArgsStrict({ cwd, scratchHome }) {
276
+ function bwrapArgsStrict({ cwd, scratchHome, extraReadPaths = [] }) {
231
277
  const home = homedir();
232
278
  const args = [
233
279
  "--die-with-parent",
@@ -254,6 +300,12 @@ function bwrapArgsStrict({ cwd, scratchHome }) {
254
300
  "--bind", scratchHome, scratchHome,
255
301
  // Network is intentionally NOT unshared — agents need tracker APIs and git.
256
302
  ];
303
+ // Claude's own state under $HOME (blanked by the tmpfs above) — and the
304
+ // relocated equivalent when the user runs an account profile.
305
+ for (const p of [join(home, ".claude"), join(home, ".config", "claude"), process.env.CLAUDE_CONFIG_DIR]) {
306
+ if (p) args.push("--bind-try", p, p);
307
+ }
308
+ args.push("--bind-try", join(home, ".claude.json"), join(home, ".claude.json"));
257
309
 
258
310
  // Tool dirs the user typically has in $HOME — re-expose only if present.
259
311
  // `--ro-bind-try` is a no-op if the source path doesn't exist.
@@ -272,12 +324,16 @@ function bwrapArgsStrict({ cwd, scratchHome }) {
272
324
  for (const p of userToolPaths) {
273
325
  args.push("--ro-bind-try", p, p);
274
326
  }
327
+ // Caller-supplied read-only paths (e.g. the Agent SDK's bundled Claude binary).
328
+ for (const p of extraReadPaths.filter(Boolean)) {
329
+ args.push("--ro-bind-try", p, p);
330
+ }
275
331
 
276
332
  return args;
277
333
  }
278
334
 
279
335
  // Pre-AD-30 denylist via bwrap (--bind / / + tmpfs over deny paths).
280
- function bwrapArgsLegacy({ cwd, scratchHome }) {
336
+ function bwrapArgsLegacy() {
281
337
  const home = homedir();
282
338
  const denyPaths = [
283
339
  join(home, ".ssh"),
@@ -23,6 +23,7 @@ import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
23
23
  import { join } from "path";
24
24
  import { tmpdir } from "os";
25
25
  import { randomUUID } from "crypto";
26
+ import { installExitGuards, onTerminate } from "./proc.mjs";
26
27
 
27
28
  // AD-46: refuse any value with embedded newlines before it lands in YAML or
28
29
  // gitconfig. Real GitHub PATs / emails / names won't have them, but a server-
@@ -130,9 +131,20 @@ export function createScratchHome({ projectId, sessionId, creds = {}, commitIden
130
131
  cleaned = true;
131
132
  try { if (existsSync(base)) rmSync(base, { recursive: true, force: true }); } catch {}
132
133
  }
134
+ // `exit` alone does not fire on a signalled termination, which left the
135
+ // scratch HOME — containing a 0600 GitHub token and the tracker
136
+ // credentials — on disk whenever the daemon was stopped with SIGTERM or
137
+ // Ctrl-C. installExitGuards owns the signal handlers for the whole CLI so
138
+ // that this cleanup and the child teardown cannot race each other.
139
+ installExitGuards();
140
+ const unterminate = onTerminate(cleanup);
133
141
  process.once("exit", cleanup);
134
142
 
135
- return { home: base, env, cleanup };
143
+ return {
144
+ home: base,
145
+ env,
146
+ cleanup: () => { unterminate(); cleanup(); },
147
+ };
136
148
  }
137
149
 
138
150
  function safe(s) {
@@ -2,38 +2,16 @@
2
2
  // `agentdesk bootstrap`. Kept here so the two entry commands can compose
3
3
  // the same building blocks without importing from each other.
4
4
 
5
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
5
+ import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
6
6
  import { join } from "path";
7
- import { execSync, execFileSync } from "child_process";
7
+ import { execFileSync } from "child_process";
8
8
  import { checkTrackerPermissions, resolveCredentialsFromEnv } from "./tracker-check.mjs";
9
9
  import { assertPushable, PreflightError, getGitRemoteUrl } from "./session-preflight.mjs";
10
+ import { checkClaudeAuth } from "./engine/claude-auth.mjs";
10
11
 
11
- // ---------- .env read/write ----------
12
-
13
- export function loadDotEnv(dir) {
14
- const envPath = join(dir, ".env");
15
- const out = {};
16
- if (!existsSync(envPath)) return out;
17
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
18
- const trimmed = line.trim();
19
- if (!trimmed || trimmed.startsWith("#")) continue;
20
- const eq = trimmed.indexOf("=");
21
- if (eq !== -1) out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
22
- }
23
- return out;
24
- }
25
-
26
- export function saveEnvVar(dir, key, value) {
27
- const envPath = join(dir, ".env");
28
- let content = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
29
- const re = new RegExp(`^${key}=.*$`, "m");
30
- if (re.test(content)) {
31
- content = content.replace(re, `${key}=${value}`);
32
- } else {
33
- content += `${content && !content.endsWith("\n") ? "\n" : ""}${key}=${value}\n`;
34
- }
35
- writeFileSync(envPath, content);
36
- }
12
+ // .env read/write lives in dotenv.mjs (single implementation, quote-aware,
13
+ // 0600 on write). Re-exported so existing importers keep working.
14
+ export { loadDotEnv, saveEnvVar, ensureGitignored } from "./dotenv.mjs";
37
15
 
38
16
  // ---------- Git detection + parsing ----------
39
17
 
@@ -45,7 +23,9 @@ export function parseGitHubRef(input) {
45
23
  // Reject non-github hosts early.
46
24
  if (/^(https?:\/\/|git@|ssh:\/\/)/.test(s) && !/github\.com/i.test(s)) return null;
47
25
  // SSH / HTTPS / short form — pull out owner/repo from the tail.
48
- const m = s.match(/(?:github\.com[:/])?([^/@:\s]+)\/([^/.\s]+?)(?:\.git)?\/?$/i);
26
+ // The repo segment allows dots: `vercel/next.js`, `socketio/socket.io`.
27
+ // The lazy quantifier plus optional `.git` keeps `next.js.git` → `next.js`.
28
+ const m = s.match(/(?:github\.com[:/])?([^/@:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
49
29
  if (!m) return null;
50
30
  return { owner: m[1], repo: m[2] };
51
31
  }
@@ -109,17 +89,50 @@ export async function checkGitHubRepoAccess(token, owner, repo) {
109
89
 
110
90
  // ---------- Clone ----------
111
91
 
92
+ // The directory itself is the root of a git checkout (not a plain
93
+ // subdirectory inside some other checkout). Returns the root path or null.
94
+ function checkoutRoot(dir) {
95
+ try {
96
+ const top = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
97
+ stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8",
98
+ }).trim();
99
+ return realpathSync(top) === realpathSync(dir) ? top : null;
100
+ } catch { return null; }
101
+ }
102
+
103
+ // <cwd>/<repo> when that directory is already a checkout whose origin is
104
+ // github.com/<owner>/<repo> (case-insensitive), else null. Lets `init` reuse
105
+ // a clone that is already in place — the common case when the wizard is run
106
+ // from the directory that contains the clone — instead of refusing to clone
107
+ // over it.
108
+ export function findExistingClone({ cwd, owner, repo }) {
109
+ const targetDir = join(cwd, repo);
110
+ if (!existsSync(targetDir) || !checkoutRoot(targetDir)) return null;
111
+ const { ownerRepo } = detectGitRepo(targetDir);
112
+ if (!ownerRepo) return null;
113
+ return ownerRepo.toLowerCase() === `${owner}/${repo}`.toLowerCase() ? targetDir : null;
114
+ }
115
+
112
116
  // Clone github.com/<owner>/<repo> into <cwd>/<repo> using the provided token.
113
117
  // Token is embedded in the URL via the x-access-token username (GitHub's
114
118
  // documented pattern). Disables terminal prompts so a bad token fails fast
115
119
  // instead of hanging waiting for a password.
120
+ // If <cwd>/<repo> is already a checkout of that repo it is returned as-is and
121
+ // nothing is cloned.
116
122
  export function cloneRepoWithToken({ cwd, owner, repo, token }) {
117
123
  const targetDir = join(cwd, repo);
118
- if (existsSync(targetDir)) {
119
- const entries = readdirSync(targetDir);
120
- if (entries.length > 0) {
121
- throw new Error(`./${repo} already exists and is not empty — remove it or run from another directory`);
122
- }
124
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
125
+ if (findExistingClone({ cwd, owner, repo })) return targetDir;
126
+ // Something else lives there. Say what, so the user knows whether to
127
+ // remove it or to cd into the right clone.
128
+ const isCheckout = !!checkoutRoot(targetDir);
129
+ const other = isCheckout ? detectGitRepo(targetDir).ownerRepo : null;
130
+ const what = !isCheckout
131
+ ? "is not a git checkout"
132
+ : other
133
+ ? `is a checkout of ${other}, not ${owner}/${repo}`
134
+ : "is a git checkout with no GitHub origin";
135
+ throw new Error(`./${repo} already exists and ${what} — remove it, or cd into the right clone and re-run \`agentdesk init\``);
123
136
  }
124
137
  const url = `https://x-access-token:${token}@github.com/${owner}/${repo}.git`;
125
138
  // Execute with env override so we don't echo the token into the user's
@@ -128,9 +141,33 @@ export function cloneRepoWithToken({ cwd, owner, repo, token }) {
128
141
  stdio: "inherit",
129
142
  env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
130
143
  });
144
+
145
+ // git stores the clone URL verbatim as `remote.origin.url`, so without this
146
+ // the token sits in plaintext in .git/config for the life of the checkout —
147
+ // long after it has been rotated. Sessions authenticate through the
148
+ // credential helper in session-sandbox.mjs; the remote never needed it.
149
+ execFileSync("git", ["-C", targetDir, "remote", "set-url", "origin", cleanGitHubUrl(owner, repo)], {
150
+ stdio: "ignore",
151
+ });
131
152
  return targetDir;
132
153
  }
133
154
 
155
+ export function cleanGitHubUrl(owner, repo) {
156
+ return `https://github.com/${owner}/${repo}.git`;
157
+ }
158
+
159
+ // Remove any `user:secret@` userinfo from the origin remote of an existing
160
+ // checkout. Returns true when something was stripped. Used by `init` to
161
+ // repair clones made by versions that embedded the token in the URL.
162
+ export function stripRemoteCredentials(dir) {
163
+ const url = getGitRemoteUrl(dir);
164
+ if (!url) return false;
165
+ const m = url.match(/^(https?:\/\/)[^@/]+@(.+)$/);
166
+ if (!m) return false;
167
+ execFileSync("git", ["-C", dir, "remote", "set-url", "origin", `${m[1]}${m[2]}`], { stdio: "ignore" });
168
+ return true;
169
+ }
170
+
134
171
  // ---------- Access checks (summary checklist) ----------
135
172
 
136
173
  export async function checkAgentDeskAccess(apiKey, serverUrl) {
@@ -149,12 +186,16 @@ export async function checkAgentDeskAccess(apiKey, serverUrl) {
149
186
  // Run all three access checks for the final summary and return the results.
150
187
  // No prompts — the caller decides whether to abort or let the user save
151
188
  // anyway based on the outcome.
152
- export async function runAccessChecks({ apiKey, serverUrl, cwd, config, creds }) {
153
- const results = { agentdesk: null, github: null, tracker: null };
189
+ export async function runAccessChecks({ apiKey, serverUrl, cwd, config, creds, authCheck = checkClaudeAuth }) {
190
+ const results = { agentdesk: null, claude: null, github: null, tracker: null };
154
191
 
155
192
  // agentdesk.live
156
193
  results.agentdesk = await checkAgentDeskAccess(apiKey, serverUrl);
157
194
 
195
+ // Claude Code itself — a standalone login, with the project's .env applied
196
+ // (so an ANTHROPIC_API_KEY there counts).
197
+ results.claude = await authCheck({ env: { ...process.env, ...(creds || {}) } });
198
+
158
199
  // GitHub — token presence + (if we know the repo) push access
159
200
  if (creds?.GITHUB_TOKEN) {
160
201
  const login = await fetchGitHubLogin(creds.GITHUB_TOKEN);
@@ -209,6 +250,12 @@ export function printAccessChecks(results, { tracker } = {}) {
209
250
  } else {
210
251
  console.log(` ✗ agentdesk.live ${results.agentdesk?.error || "unknown error"}`);
211
252
  }
253
+ if (results.claude?.ok) {
254
+ console.log(` ✓ Claude Code ${results.claude.detail || "logged in"}`);
255
+ } else if (results.claude) {
256
+ console.log(` ✗ Claude Code ${results.claude.detail || "not logged in"}`);
257
+ for (const line of String(results.claude.hint || "").split("\n")) if (line) console.log(` ${line}`);
258
+ }
212
259
  if (results.github?.ok) {
213
260
  const detail = [
214
261
  results.github.login ? `@${results.github.login}` : null,
package/cli/team.mjs CHANGED
@@ -1,34 +1,19 @@
1
1
  // `agentdesk team <TASK-ID> [--description "..."]` — run a team session
2
2
 
3
- import { existsSync, readFileSync } from "fs";
4
- import { join, dirname } from "path";
3
+ import { dirname } from "path";
5
4
  import { randomUUID } from "crypto";
6
5
  import { fileURLToPath } from "url";
7
6
  import WebSocket from "ws";
8
7
  import { detectProject } from "./detect.mjs";
9
8
  import { loadConfig } from "./config.mjs";
10
9
  import { getStoredApiKey } from "./login.mjs";
11
- import { resolveTeam, generateTeamPrompt } from "./agents.mjs";
12
- import { runOrchestrator, runPhasedOrchestrator } from "./orchestrator.mjs";
10
+ import { resolveTeam } from "./agents.mjs";
11
+ import { runSession } from "./engine/session.mjs";
13
12
  import { checkTrackerPermissions, resolveCredentialsFromEnv } from "./tracker-check.mjs";
14
13
  import { buildTrackerUrl } from "./tracker-url.mjs";
14
+ import { loadDotEnv } from "./dotenv.mjs";
15
15
 
16
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
17
- const CLI_VERSION = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")).version;
18
-
19
- function loadDotEnv(dir) {
20
- const envPath = join(dir, ".env");
21
- if (!existsSync(envPath)) return {};
22
- const vars = {};
23
- for (const line of readFileSync(envPath, "utf-8").split("\n")) {
24
- const trimmed = line.trim();
25
- if (!trimmed || trimmed.startsWith("#")) continue;
26
- const eq = trimmed.indexOf("=");
27
- if (eq === -1) continue;
28
- vars[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
29
- }
30
- return vars;
31
- }
32
17
 
33
18
  export async function runTeam(taskId, opts = {}) {
34
19
  const cyan = "\x1b[36m";
@@ -85,9 +70,7 @@ export async function runTeam(taskId, opts = {}) {
85
70
  console.log(`Child strategy: ${opts.childStrategy}`);
86
71
  }
87
72
 
88
- // Resolve team and generate dynamic prompt sections
89
73
  const team = resolveTeam(config);
90
- const teamSections = generateTeamPrompt(team, { tracker, config });
91
74
 
92
75
  // --- Verify tracker permissions before starting session ---
93
76
  if (tracker) {
@@ -209,24 +192,48 @@ export async function runTeam(taskId, opts = {}) {
209
192
  }
210
193
  }, 30000);
211
194
 
212
- const orchestrate = opts.phased ? runPhasedOrchestrator : runOrchestrator;
213
- const result = await orchestrate({
214
- taskId, taskLink, description, createTask, tracker, config,
215
- project, team, teamSections, sessionUrl, cwd,
216
- sessionId,
217
- onEvent: vizSend,
218
- apiKey,
219
- serverUrl: agentdeskServer,
220
- soloAgent: opts.soloAgent || null,
221
- childStrategy: opts.childStrategy || null,
222
- });
223
-
224
- clearInterval(heartbeatInterval);
195
+ // Children are spawned into their own process group so a cancel can take the
196
+ // whole tree down. That also means they no longer receive the terminal's
197
+ // Ctrl-C, so the signal has to be forwarded deliberately.
198
+ const abort = new AbortController();
199
+ const onSigint = () => {
200
+ console.log(`\n Stopping session — terminating Claude...`);
201
+ abort.abort();
202
+ };
203
+ process.on("SIGINT", onSigint);
204
+
205
+ let result;
206
+ try {
207
+ result = await runSession({
208
+ taskId, taskLink, description, createTask, tracker, config,
209
+ project, team, sessionUrl, cwd,
210
+ sessionId,
211
+ onEvent: vizSend,
212
+ apiKey,
213
+ serverUrl: agentdeskServer,
214
+ soloAgent: opts.soloAgent || null,
215
+ childStrategy: opts.childStrategy || null,
216
+ abortSignal: abort.signal,
217
+ });
218
+ } finally {
219
+ process.removeListener("SIGINT", onSigint);
220
+ clearInterval(heartbeatInterval);
221
+ }
225
222
 
226
223
  if (result.error) {
227
224
  const red = "\x1b[31m";
228
225
  console.log(`\n━━━ ${red}SESSION COULD NOT START${reset} ━━━`);
229
226
  console.log(` ${result.error}\n`);
227
+ } else if (result.aborted) {
228
+ const yellow = "\x1b[33m";
229
+ console.log(`\n━━━ ${yellow}STOPPED${reset} ━━━`);
230
+ console.log(` Cancelled — Claude terminated.`);
231
+ console.log(` Resume with: ${cyan}agentdesk team ${taskId}${reset}\n`);
232
+ } else if (result.status === "handoff" && result.reviewResolved === false) {
233
+ const yellow = "\x1b[33m";
234
+ console.log(`\n━━━ ${yellow}NEEDS REVIEW${reset} ━━━`);
235
+ console.log(` Review did not approve the work — this session is NOT complete.`);
236
+ console.log(` Check the branch before merging.\n`);
230
237
  } else if (result.handoff) {
231
238
  const yellow = "\x1b[33m";
232
239
  console.log(`\n━━━ ${yellow}HANDOFF${reset} ━━━`);
@@ -2,6 +2,7 @@
2
2
  // Checks: read tasks, create tasks, update tasks
3
3
 
4
4
  import { execFileSync } from "child_process";
5
+ import { trackerNeedsProject, getTrackerProject, missingTrackerProjectError } from "./tracker-project.mjs";
5
6
 
6
7
  // AD-29: any external string (repo name, ref, etc.) must be regex-validated
7
8
  // before it goes into a child process. Even with execFile, an upstream caller
@@ -14,9 +15,18 @@ const LINEAR_API = "https://api.linear.app/graphql";
14
15
  * Check tracker permissions. Returns { ok, errors[] }.
15
16
  * Each error is a string describing what permission is missing.
16
17
  */
17
- export async function checkTrackerPermissions({ tracker, config, credentials }) {
18
+ // `requireProject` (default true): a Linear team key / Jira project key is
19
+ // part of a working configuration — without it, create-from-description
20
+ // sessions file tasks in whatever project the token can reach. Only the
21
+ // init wizard's credential step, which runs before the project is picked,
22
+ // turns this off.
23
+ export async function checkTrackerPermissions({ tracker, config, credentials, requireProject = true }) {
18
24
  if (!tracker) return { ok: true, errors: [] };
19
25
 
26
+ if (requireProject && trackerNeedsProject(tracker) && !getTrackerProject(tracker, config)) {
27
+ return { ok: false, errors: [missingTrackerProjectError(tracker)] };
28
+ }
29
+
20
30
  switch (tracker) {
21
31
  case "linear":
22
32
  return checkLinear(config.linear || {}, credentials);
@@ -29,7 +39,7 @@ export async function checkTrackerPermissions({ tracker, config, credentials })
29
39
  }
30
40
  }
31
41
 
32
- async function checkLinear({ teamKey, workspace }, creds) {
42
+ async function checkLinear({ teamKey }, creds) {
33
43
  const apiKey = creds.LINEAR_API_KEY;
34
44
  if (!apiKey) return { ok: false, errors: ["Missing LINEAR_API_KEY — configure it in the AgentDesk dashboard or .env"] };
35
45
 
@@ -0,0 +1,93 @@
1
+ // Tracker project binding (Linear team key / Jira project key) for
2
+ // `agentdesk init`.
3
+ //
4
+ // The binding is the one project-wide setting that `init` must never leave
5
+ // empty: without it the create-from-description prompt cannot pin the
6
+ // project, so the agent files tasks wherever its token can reach. Before
7
+ // this module the wizard only asked for it in new mode, and only when the
8
+ // credential check succeeded; existing mode copied whatever the server had
9
+ // (often nothing) and moved on without a word.
10
+ //
11
+ // `resolveTrackerProject` is the single decision point for step 9 of the
12
+ // wizard. Prompts are injected so the decision table is unit-testable.
13
+
14
+ export function trackerNeedsProject(tracker) {
15
+ return tracker === "linear" || tracker === "jira";
16
+ }
17
+
18
+ export function trackerProjectLabel(tracker) {
19
+ return tracker === "linear" ? "Linear team key (e.g. KEN)" : "Jira project key (e.g. PROJ)";
20
+ }
21
+
22
+ export function trackerProjectNoun(tracker) {
23
+ return tracker === "linear" ? "team" : "project";
24
+ }
25
+
26
+ // The configured team/project id, or null.
27
+ export function getTrackerProject(tracker, config) {
28
+ if (tracker === "linear") return config?.linear?.teamKey || null;
29
+ if (tracker === "jira") return config?.jira?.project || null;
30
+ return null;
31
+ }
32
+
33
+ // Return a copy of `config` with the team/project id set on the right block.
34
+ export function setTrackerProject(tracker, config, id) {
35
+ const out = { ...(config || {}) };
36
+ if (tracker === "linear") out.linear = { ...(out.linear || {}), teamKey: id };
37
+ if (tracker === "jira") out.jira = { ...(out.jira || {}), project: id };
38
+ return out;
39
+ }
40
+
41
+ export function missingTrackerProjectError(tracker) {
42
+ return tracker === "linear"
43
+ ? "Missing Linear team key — run 'agentdesk init' to pick the team"
44
+ : "Missing Jira project key — run 'agentdesk init' to pick the project";
45
+ }
46
+
47
+ // Decide the tracker project for the wizard.
48
+ //
49
+ // tracker "linear" | "jira" | "github" | null
50
+ // isExisting wizard mode
51
+ // serverProject the id the server already has (existing mode), or null
52
+ // verified true when the credential check passed (a listing is possible)
53
+ // listProjects async () => [{ id, name }] | null (only called when verified)
54
+ // promptSelect async ({ message, choices }) => value
55
+ // promptRequired async (label) => string
56
+ //
57
+ // Returns { id, name, source } where source is:
58
+ // "server" — existing mode, server already had it (read-only)
59
+ // "picked" — chosen from the tracker's list
60
+ // "typed" — entered by hand (no list available, or creds not verified)
61
+ // "none" — tracker has no project concept (github / no tracker)
62
+ //
63
+ // A tracker that needs a project NEVER returns null here: the previous
64
+ // wizard gated the prompt on the credential check, and "Save anyway" then
65
+ // skipped the question entirely.
66
+ export async function resolveTrackerProject({
67
+ tracker, isExisting, serverProject, verified,
68
+ listProjects, promptSelect, promptRequired,
69
+ }) {
70
+ if (!trackerNeedsProject(tracker)) return { id: null, name: null, source: "none" };
71
+
72
+ if (isExisting && serverProject) {
73
+ return { id: serverProject, name: serverProject, source: "server" };
74
+ }
75
+
76
+ const noun = trackerProjectNoun(tracker);
77
+ if (verified) {
78
+ const items = (await listProjects()) || [];
79
+ if (items.length > 0) {
80
+ const pickedId = await promptSelect({
81
+ message: `Pick the ${noun} this project maps to`,
82
+ choices: items.map(it => ({ name: it.name, value: it.id })),
83
+ });
84
+ const name = items.find(it => it.id === pickedId)?.name || pickedId;
85
+ return { id: pickedId, name, source: "picked" };
86
+ }
87
+ }
88
+
89
+ // Both trackers use upper-case keys (KEN-12, PROJ-34); normalise so a
90
+ // lower-case entry doesn't produce a key the tracker won't recognise.
91
+ const id = String(await promptRequired(trackerProjectLabel(tracker))).trim().toUpperCase();
92
+ return { id, name: id, source: "typed" };
93
+ }
@@ -0,0 +1,62 @@
1
+ // Update-check policy for the CLI entry point.
2
+ //
3
+ // Pure functions, so the rules about when a newer release is allowed to stop
4
+ // the CLI from running are testable without touching the npm registry.
5
+ //
6
+ // The previous inline logic treated a MINOR bump as breaking and exited 1.
7
+ // That meant the moment 0.28.0 was published, every `agentdesk daemon` that
8
+ // restarted — a process specifically meant to run unattended — refused to
9
+ // start until a human ran `agentdesk update`. It also ran before `--version`
10
+ // and `help` were handled, so `agentdesk --version` needed network access and
11
+ // could fail.
12
+
13
+ // Commands that must never be blocked by an available update.
14
+ // - daemon: unattended; a hard exit here is an outage, not a nudge.
15
+ // - update/version/help: the user is trying to find out about or fix the
16
+ // very thing we'd be blocking on.
17
+ const NEVER_BLOCK = new Set(["daemon", "update", "version", "--version", "-v", "help", "--help"]);
18
+
19
+ // Commands where the check is pure noise — they should not even fetch.
20
+ const SKIP_CHECK = new Set(["version", "--version", "-v", "help", "--help", "update", undefined, ""]);
21
+
22
+ export function parseVersion(v) {
23
+ const m = String(v ?? "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
24
+ if (!m) return null;
25
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
26
+ }
27
+
28
+ // "none" | "patch" | "minor" | "major" | "behind-registry"
29
+ //
30
+ // "behind-registry" covers a local build that is NEWER than what npm has
31
+ // (a pre-release checkout). The old code reported that as "update available
32
+ // 0.27.0 → 0.26.0", which is backwards.
33
+ export function classifyUpdate(current, latest) {
34
+ const c = parseVersion(current);
35
+ const l = parseVersion(latest);
36
+ if (!c || !l) return "none";
37
+
38
+ if (l.major > c.major) return "major";
39
+ if (l.major < c.major) return "behind-registry";
40
+ if (l.minor > c.minor) return "minor";
41
+ if (l.minor < c.minor) return "behind-registry";
42
+ if (l.patch > c.patch) return "patch";
43
+ if (l.patch < c.patch) return "behind-registry";
44
+ return "none";
45
+ }
46
+
47
+ export function shouldSkipCheck(command, env = process.env) {
48
+ if (env.AGENTDESK_SKIP_UPDATE_CHECK === "1") return true;
49
+ return SKIP_CHECK.has(command);
50
+ }
51
+
52
+ // Decide what to do with a classification for a given command.
53
+ // Returns { action: "none" | "warn" | "block" }.
54
+ //
55
+ // Only a MAJOR bump blocks, and only for interactive commands. Semver says a
56
+ // minor release is backward compatible; treating it as breaking punishes
57
+ // every user for every feature release.
58
+ export function updateAction(command, classification) {
59
+ if (classification === "none" || classification === "behind-registry") return { action: "none" };
60
+ if (classification === "major" && !NEVER_BLOCK.has(command)) return { action: "block" };
61
+ return { action: "warn" };
62
+ }