@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.
- package/CHANGELOG.md +32 -1
- 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 +148 -66
- package/cli/dotenv.mjs +96 -13
- package/cli/engine/agents/index.mjs +151 -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 +112 -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 +295 -149
- package/cli/login.mjs +52 -6
- package/cli/phase-loop.mjs +78 -0
- package/cli/proc.mjs +131 -0
- package/cli/project-key.mjs +56 -0
- package/cli/projects.mjs +41 -6
- 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,19 +1,17 @@
|
|
|
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
|
-
import { getStoredApiKey } from "./login.mjs";
|
|
12
|
-
import { resolveTeam
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { getRegisteredProjects, registerLocalProject } from "./projects.mjs";
|
|
9
|
+
import { getStoredApiKey, ensureAccountIdentity } from "./login.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";
|
|
14
|
+
import { getRegisteredProjects, registerLocalProject, claimLocalProjects } from "./projects.mjs";
|
|
17
15
|
import { buildTrackerUrl } from "./tracker-url.mjs";
|
|
18
16
|
import { fileURLToPath } from "url";
|
|
19
17
|
import { dirname } from "path";
|
|
@@ -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) {
|
|
@@ -150,46 +132,100 @@ export async function runDaemon() {
|
|
|
150
132
|
process.exit(1);
|
|
151
133
|
}
|
|
152
134
|
|
|
153
|
-
// 2. Load registered projects
|
|
135
|
+
// 2. Load registered projects, scoped to the logged-in account (AD-64).
|
|
136
|
+
// The local registry is machine-global — it accumulates projects from
|
|
137
|
+
// every account ever used on this machine — so the server's per-account
|
|
138
|
+
// project list is the authority on what this account may see.
|
|
154
139
|
const agentdeskServer = process.env.AGENTDESK_SERVER || "https://agentdesk.live";
|
|
155
|
-
|
|
140
|
+
const creds = await ensureAccountIdentity();
|
|
141
|
+
const accountId = creds?.accountId || null;
|
|
156
142
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
143
|
+
let serverIds = null;
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(`${agentdeskServer}/api/projects`, {
|
|
146
|
+
headers: { "x-api-key": apiKey },
|
|
147
|
+
signal: AbortSignal.timeout(5000),
|
|
148
|
+
});
|
|
149
|
+
if (res.ok) {
|
|
150
|
+
const serverProjects = await res.json();
|
|
151
|
+
if (Array.isArray(serverProjects)) {
|
|
152
|
+
serverIds = new Set(serverProjects.map(sp => sp.id || sp.name));
|
|
153
|
+
|
|
154
|
+
// Untagged local entries with no server row — either they predate
|
|
155
|
+
// server-side registration or were initialized under another login.
|
|
156
|
+
// Offer them to the server: it accepts free ids and silently no-ops
|
|
157
|
+
// ids owned by another account (AD-23), so re-fetching the list
|
|
158
|
+
// tells us which ones are actually ours.
|
|
159
|
+
const unclaimed = getRegisteredProjects().filter(p =>
|
|
160
|
+
!p.accountId && !serverIds.has(p.id) &&
|
|
161
|
+
existsSync(p.path) && existsSync(join(p.path, ".agentdesk.json")));
|
|
162
|
+
if (unclaimed.length > 0) {
|
|
163
|
+
for (const p of unclaimed) {
|
|
164
|
+
try {
|
|
165
|
+
await fetch(`${agentdeskServer}/api/projects`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json", "x-api-key": apiKey },
|
|
168
|
+
body: JSON.stringify({ id: p.id, name: p.name, path: p.path }),
|
|
169
|
+
signal: AbortSignal.timeout(5000),
|
|
170
|
+
});
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const recheck = await fetch(`${agentdeskServer}/api/projects`, {
|
|
175
|
+
headers: { "x-api-key": apiKey },
|
|
176
|
+
signal: AbortSignal.timeout(5000),
|
|
177
|
+
});
|
|
178
|
+
if (recheck.ok) {
|
|
179
|
+
const confirmed = await recheck.json();
|
|
180
|
+
if (Array.isArray(confirmed)) {
|
|
181
|
+
serverIds = new Set(confirmed.map(sp => sp.id || sp.name));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
for (const p of unclaimed) {
|
|
186
|
+
if (!serverIds.has(p.id)) {
|
|
187
|
+
console.log(` ${yellow}Skipping ${p.name}${reset} ${dim}— registered to a different AgentDesk account${reset}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Claim legacy untagged local entries the server confirmed as ours
|
|
193
|
+
claimLocalProjects([...serverIds], accountId);
|
|
194
|
+
|
|
195
|
+
// Sync server projects missing from the local registry (pre-0.7.0
|
|
196
|
+
// setups, or projects initialized on another machine)
|
|
197
|
+
const localIds = new Set(getRegisteredProjects().map(p => p.id));
|
|
198
|
+
const missing = serverProjects.filter(sp => !localIds.has(sp.id || sp.name));
|
|
199
|
+
if (missing.length > 0) {
|
|
200
|
+
console.log(` ${dim}Syncing ${missing.length} project(s) from server...${reset}`);
|
|
201
|
+
for (const sp of missing) {
|
|
169
202
|
const projectName = sp.id || sp.name;
|
|
170
203
|
// If server has a valid path, use it
|
|
171
204
|
if (sp.path && existsSync(sp.path)) {
|
|
172
|
-
registerLocalProject(projectName, sp.name, sp.path);
|
|
205
|
+
registerLocalProject(projectName, sp.name, sp.path, accountId);
|
|
173
206
|
continue;
|
|
174
207
|
}
|
|
175
208
|
// Server has empty path — try to find the project locally
|
|
176
209
|
const found = findProjectLocally(projectName);
|
|
177
210
|
if (found) {
|
|
178
211
|
console.log(` ${dim}Found ${projectName} at ${found}${reset}`);
|
|
179
|
-
registerLocalProject(projectName, sp.name, found);
|
|
212
|
+
registerLocalProject(projectName, sp.name, found, accountId);
|
|
180
213
|
} else {
|
|
181
214
|
console.log(` ${yellow}Could not find ${projectName} locally.${reset} Run ${cyan}agentdesk init${reset} in its directory.`);
|
|
182
215
|
}
|
|
183
216
|
}
|
|
184
|
-
allProjects = getRegisteredProjects();
|
|
185
217
|
}
|
|
186
218
|
}
|
|
187
|
-
} catch {
|
|
188
|
-
// Server not reachable — continue with local only
|
|
189
219
|
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Server not reachable — fall back to account-tagged local entries
|
|
190
222
|
}
|
|
191
223
|
|
|
192
|
-
const projects =
|
|
224
|
+
const projects = getRegisteredProjects(accountId).filter(p => {
|
|
225
|
+
// Server reachable → it is authoritative: hide entries it doesn't own,
|
|
226
|
+
// even legacy untagged ones (they may belong to another account).
|
|
227
|
+
// Offline → tagged + untagged entries from getRegisteredProjects().
|
|
228
|
+
if (serverIds && !serverIds.has(p.id) && !(accountId && p.accountId === accountId)) return false;
|
|
193
229
|
if (!existsSync(p.path)) return false;
|
|
194
230
|
if (!existsSync(join(p.path, ".agentdesk.json"))) return false;
|
|
195
231
|
return true;
|
|
@@ -350,7 +386,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
350
386
|
|
|
351
387
|
// 4. Session handling
|
|
352
388
|
|
|
353
|
-
async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt,
|
|
389
|
+
async function handleStartSession({ sessionId, projectId, taskId: remoteTaskId, prompt, screenshots: screenshotsOverride }) {
|
|
354
390
|
// Validate project against local allowlist
|
|
355
391
|
const project = projects.find(p => p.id === projectId);
|
|
356
392
|
if (!project) {
|
|
@@ -381,7 +417,16 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
381
417
|
return;
|
|
382
418
|
}
|
|
383
419
|
// Claim the slot immediately (before any await) to prevent race conditions
|
|
384
|
-
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
|
+
};
|
|
385
430
|
|
|
386
431
|
console.log(` ${green}Starting session${reset} ${dim}${sessionId}${reset}`);
|
|
387
432
|
console.log(` Project: ${project.name} ${dim}(${project.path})${reset}`);
|
|
@@ -406,25 +451,31 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
406
451
|
// Build task link (only when a real tracker task ID was provided)
|
|
407
452
|
const taskLink = remoteTaskId ? buildTrackerUrl({ tracker, config, taskId: remoteTaskId }) : null;
|
|
408
453
|
|
|
409
|
-
// Resolve team
|
|
410
454
|
const team = resolveTeam(config);
|
|
411
|
-
const teamSections = generateTeamPrompt(team, { tracker, config });
|
|
412
455
|
|
|
413
456
|
const sessionUrl = `${agentdeskServer}/sessions/${sessionId}`;
|
|
414
457
|
|
|
415
|
-
// Run
|
|
416
|
-
|
|
417
|
-
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({
|
|
418
462
|
taskId, taskLink,
|
|
419
463
|
description: prompt || "",
|
|
420
464
|
createTask: !remoteTaskId && !!prompt && !!tracker,
|
|
421
465
|
tracker, config,
|
|
422
|
-
project: detected, team,
|
|
466
|
+
project: detected, team,
|
|
423
467
|
sessionUrl,
|
|
424
468
|
sessionId,
|
|
425
469
|
cwd: project.path,
|
|
426
470
|
apiKey,
|
|
427
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
|
+
},
|
|
428
479
|
onEvent: (() => {
|
|
429
480
|
// Stagger agent messages for real-time feel, flush on non-message events
|
|
430
481
|
const MSG_STAGGER_MS = 400;
|
|
@@ -450,6 +501,9 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
450
501
|
|
|
451
502
|
return (event) => {
|
|
452
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;
|
|
453
507
|
|
|
454
508
|
if (event.type === "agent:message") {
|
|
455
509
|
msgQueue.push(event);
|
|
@@ -474,12 +528,21 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
474
528
|
})(),
|
|
475
529
|
});
|
|
476
530
|
|
|
477
|
-
|
|
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
|
+
);
|
|
478
536
|
|
|
479
537
|
logSessionMetadata(sessionId, {
|
|
480
538
|
sessionId, projectId,
|
|
481
539
|
startedAt, endedAt: Date.now(),
|
|
482
|
-
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,
|
|
483
546
|
filePathsTouched: [...filePathsTouched],
|
|
484
547
|
});
|
|
485
548
|
|
|
@@ -493,24 +556,43 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
493
556
|
}
|
|
494
557
|
}
|
|
495
558
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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);
|
|
503
566
|
}
|
|
504
567
|
|
|
505
568
|
function handleCancelSession({ sessionId }) {
|
|
506
569
|
if (activeSession?.sessionId === sessionId) {
|
|
507
570
|
console.log(` ${yellow}Cancelling session${reset} ${dim}${sessionId}${reset}`);
|
|
508
571
|
const duration = `${((Date.now() - activeSession.startedAt) / 1000).toFixed(1)}s`;
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
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.
|
|
513
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?.();
|
|
514
596
|
}
|
|
515
597
|
}
|
|
516
598
|
|
|
@@ -522,7 +604,7 @@ async function confirmIncomingSession({ project, taskId, prompt }) {
|
|
|
522
604
|
clearTimeout(reconnectTimer);
|
|
523
605
|
|
|
524
606
|
if (activeSession) {
|
|
525
|
-
|
|
607
|
+
stopSessionWork(activeSession);
|
|
526
608
|
}
|
|
527
609
|
|
|
528
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
|
}
|