@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
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
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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,
|
|
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 = {
|
|
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
|
|
470
|
-
|
|
471
|
-
const
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
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
|
-
|
|
607
|
+
stopSessionWork(activeSession);
|
|
580
608
|
}
|
|
581
609
|
|
|
582
610
|
send({ type: "daemon:disconnect" });
|
package/cli/dotenv.mjs
CHANGED
|
@@ -1,22 +1,105 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
+
}
|