codedeck 0.7.0 → 0.7.2
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/README.md +49 -34
- package/dist/cli/commands/claims.js +3 -2
- package/dist/cli/commands/claims.js.map +1 -1
- package/dist/cli/commands/doctor.js +8 -40
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/logs.js +2 -2
- package/dist/cli/commands/logs.js.map +1 -1
- package/dist/cli/commands/open.js +8 -9
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/ps.js +6 -6
- package/dist/cli/commands/ps.js.map +1 -1
- package/dist/cli/commands/review.js +36 -58
- package/dist/cli/commands/review.js.map +1 -1
- package/dist/cli/commands/run.js +27 -27
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/setup.js +96 -314
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +2 -1
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/ui.js +61 -0
- package/dist/cli/commands/ui.js.map +1 -0
- package/dist/cli/commands/usage.js +65 -42
- package/dist/cli/commands/usage.js.map +1 -1
- package/dist/cli/commands/wait.js +3 -2
- package/dist/cli/commands/wait.js.map +1 -1
- package/dist/cli/index.js +55 -39
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/web-launch.js +126 -0
- package/dist/cli/web-launch.js.map +1 -0
- package/dist/config/config.js +0 -70
- package/dist/config/config.js.map +1 -1
- package/dist/config/paths.js +1 -0
- package/dist/config/paths.js.map +1 -1
- package/dist/config/setup.js +248 -0
- package/dist/config/setup.js.map +1 -0
- package/dist/config/web-host.js +31 -0
- package/dist/config/web-host.js.map +1 -0
- package/dist/config/web-port.js +24 -0
- package/dist/config/web-port.js.map +1 -0
- package/dist/core/claude-transcript.js +87 -0
- package/dist/core/claude-transcript.js.map +1 -1
- package/dist/core/errors.js +19 -39
- package/dist/core/errors.js.map +1 -1
- package/dist/core/models.js +4 -1
- package/dist/core/models.js.map +1 -1
- package/dist/core/pricing.js +73 -50
- package/dist/core/pricing.js.map +1 -1
- package/dist/core/roles.js +6 -5
- package/dist/core/roles.js.map +1 -1
- package/dist/core/run-skills.js +114 -0
- package/dist/core/run-skills.js.map +1 -0
- package/dist/core/run-usage.js +4 -1
- package/dist/core/run-usage.js.map +1 -1
- package/dist/core/session.js +6 -16
- package/dist/core/session.js.map +1 -1
- package/dist/core/usage-query.js +41 -0
- package/dist/core/usage-query.js.map +1 -0
- package/dist/daemon/build-id.js +30 -0
- package/dist/daemon/build-id.js.map +1 -0
- package/dist/daemon/daemon.js +1113 -799
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/daemon/instance-lock.js +48 -0
- package/dist/daemon/instance-lock.js.map +1 -0
- package/dist/daemon/web-supervisor.js +251 -0
- package/dist/daemon/web-supervisor.js.map +1 -0
- package/dist/drivers/antigravity/parser.js +7 -10
- package/dist/drivers/antigravity/parser.js.map +1 -1
- package/dist/drivers/claude/driver.js +1 -0
- package/dist/drivers/claude/driver.js.map +1 -1
- package/dist/drivers/codex/driver.js +14 -3
- package/dist/drivers/codex/driver.js.map +1 -1
- package/dist/drivers/codex/host-skills.js +57 -0
- package/dist/drivers/codex/host-skills.js.map +1 -0
- package/dist/drivers/opencode/driver.js +6 -2
- package/dist/drivers/opencode/driver.js.map +1 -1
- package/dist/drivers/session-runtime.js +2 -0
- package/dist/drivers/session-runtime.js.map +1 -1
- package/dist/drivers/terminal.js +15 -0
- package/dist/drivers/terminal.js.map +1 -1
- package/dist/git/diff.js +29 -4
- package/dist/git/diff.js.map +1 -1
- package/dist/git/repository.js +0 -20
- package/dist/git/repository.js.map +1 -1
- package/dist/git/worktree.js +0 -14
- package/dist/git/worktree.js.map +1 -1
- package/dist/open/contract.js.map +1 -1
- package/dist/open/pty.js +330 -5
- package/dist/open/pty.js.map +1 -1
- package/dist/plugin/agents/general.md +33 -6
- package/dist/plugin/hooks/pane-ticker.ts +102 -0
- package/dist/plugin/hooks/register.tsx +33 -8
- package/dist/plugin/mods/agents/pane.ts +259 -136
- package/dist/plugin/mods/agents/types.ts +19 -3
- package/dist/plugin/statusline.sh +22 -34
- package/dist/store/database.js +5 -7
- package/dist/store/database.js.map +1 -1
- package/dist/store/sessions.js +114 -81
- package/dist/store/sessions.js.map +1 -1
- package/dist/utils/process.js +2 -49
- package/dist/utils/process.js.map +1 -1
- package/dist/web/brand.js +59 -0
- package/dist/web/brand.js.map +1 -0
- package/dist/web/child.js +67 -0
- package/dist/web/child.js.map +1 -0
- package/dist/web/home-page.js +48 -0
- package/dist/web/home-page.js.map +1 -0
- package/dist/web/review-page.js +12 -5
- package/dist/web/review-page.js.map +1 -1
- package/dist/web/security.js +170 -0
- package/dist/web/security.js.map +1 -0
- package/dist/web/server.js +198 -0
- package/dist/web/server.js.map +1 -0
- package/dist/web/setup-icons.js +37 -0
- package/dist/web/setup-icons.js.map +1 -0
- package/dist/web/setup-page.js +1795 -0
- package/dist/web/setup-page.js.map +1 -0
- package/dist/web/setup-routes.js +475 -0
- package/dist/web/setup-routes.js.map +1 -0
- package/dist/web/usage-page.js +609 -0
- package/dist/web/usage-page.js.map +1 -0
- package/dist/web/usage-routes.js +88 -0
- package/dist/web/usage-routes.js.map +1 -0
- package/dist/web/web-token.js +56 -0
- package/dist/web/web-token.js.map +1 -0
- package/package.json +1 -1
- package/dist/plugin/prompts/_partials/commit.md +0 -145
- package/dist/plugin/prompts/_partials/pr-writer.md +0 -186
package/dist/daemon/daemon.js
CHANGED
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
4
|
import { Database } from "../store/database.js";
|
|
6
|
-
import { SessionStore } from "../store/sessions.js";
|
|
5
|
+
import { AmbiguousSessionIdError, SessionStore } from "../store/sessions.js";
|
|
7
6
|
import { EventStore } from "../store/events.js";
|
|
8
7
|
import { ClaimsStore } from "../store/claims.js";
|
|
9
8
|
import { getPaths, ensureDirs } from "../config/paths.js";
|
|
10
9
|
import { createIpcServer } from "./ipc.js";
|
|
10
|
+
import { acquireInstanceLock } from "./instance-lock.js";
|
|
11
|
+
import { WebEnsureError, WebSupervisor } from "./web-supervisor.js";
|
|
11
12
|
import { getRegistry } from "../drivers/registry.js";
|
|
12
|
-
import { isTerminalStatus, liveStatus, normalizeAgentId } from "../core/session.js";
|
|
13
|
+
import { isActiveStatus, isTerminalStatus, liveStatus, normalizeAgentId } from "../core/session.js";
|
|
13
14
|
import { parseSandbox } from "../core/driver.js";
|
|
14
15
|
import { generateSessionId } from "../core/session.js";
|
|
15
16
|
import { getGitInfo } from "../git/repository.js";
|
|
16
17
|
import { createWorktree } from "../git/worktree.js";
|
|
17
18
|
import { getDiff } from "../git/diff.js";
|
|
18
|
-
import { killTree, processAlive, processStartTime,
|
|
19
|
+
import { killTree, processAlive, processStartTime, sleep } from "../utils/process.js";
|
|
19
20
|
import { readSessionProcessMetadata } from "../drivers/session-runtime.js";
|
|
20
21
|
import { loadConfig, resolveDefaultSandbox } from "../config/config.js";
|
|
21
|
-
import {
|
|
22
|
+
import { invalidWebHostMessage, resolveWebHost } from "../config/web-host.js";
|
|
23
|
+
import { invalidWebPortMessage, resolveWebPort } from "../config/web-port.js";
|
|
24
|
+
import { classifyFailure, errorMessage, isStoreBusy, RunAgentError } from "../core/errors.js";
|
|
25
|
+
import { parseRole } from "../core/roles.js";
|
|
22
26
|
import { getCachedOrDiscoverModels } from "../core/models.js";
|
|
23
27
|
import { aggregateRunUsage } from "../core/run-usage.js";
|
|
24
28
|
import { UsageLedger } from "../store/usage-ledger.js";
|
|
25
29
|
import { openSourceKey, workerSourceKey } from "../core/usage-source.js";
|
|
26
30
|
import { NativeLinkStore } from "../store/native-links.js";
|
|
27
|
-
import { findTranscript, readTranscriptUsage } from "../core/claude-transcript.js";
|
|
31
|
+
import { consumeTranscriptChunk, findTranscript, readTranscriptUsage, TRANSCRIPT_CHUNK_LIMIT_BYTES, } from "../core/claude-transcript.js";
|
|
28
32
|
// Daemon's view of power readiness for the doctor IPC result (field names
|
|
29
33
|
// fixed by cross-worker contract; the CLI falls back to local detection
|
|
30
34
|
// when the daemon is unreachable).
|
|
@@ -74,6 +78,16 @@ function livePidIdentity(s) {
|
|
|
74
78
|
processAlive(s.pid) &&
|
|
75
79
|
processStartTime(s.pid) === s.pidStartTime);
|
|
76
80
|
}
|
|
81
|
+
// Backoff for a busy store in the event loop; capped, never given up while
|
|
82
|
+
// the daemon runs (see persistDriverEvent).
|
|
83
|
+
const STORE_BUSY_RETRY_BASE_MS = 25;
|
|
84
|
+
const STORE_BUSY_RETRY_MAX_MS = 2000;
|
|
85
|
+
function appendDaemonLog(line) {
|
|
86
|
+
try {
|
|
87
|
+
fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] ${line}\n`);
|
|
88
|
+
}
|
|
89
|
+
catch { }
|
|
90
|
+
}
|
|
77
91
|
class Daemon {
|
|
78
92
|
db;
|
|
79
93
|
sessions;
|
|
@@ -83,20 +97,179 @@ class Daemon {
|
|
|
83
97
|
nativeLinks;
|
|
84
98
|
registry = getRegistry();
|
|
85
99
|
server;
|
|
100
|
+
boundPaths;
|
|
86
101
|
subscribers = new Map(); // sessionId -> sockets
|
|
87
102
|
startTime = Date.now();
|
|
88
103
|
startupReconcilePromise = Promise.resolve();
|
|
89
104
|
sessionLocks = new Set();
|
|
105
|
+
reservedSessionIds = new Set();
|
|
106
|
+
// Sessions recover() revived from a store-busy failure: the sequence the
|
|
107
|
+
// drain starts after, and the log mtime when the harness is already dead.
|
|
108
|
+
healing = new Map();
|
|
90
109
|
// Power-shutdown state. `shuttingDown` is set synchronously by the signal
|
|
91
110
|
// handler so concurrent handleRequest calls are refused during the drain.
|
|
92
111
|
shuttingDown = false;
|
|
93
112
|
// Exactly-once drain: concurrent handleShutdown callers share one promise.
|
|
94
113
|
shutdownPromise = null;
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
inhibitExitHookInstalled = false;
|
|
114
|
+
// Supervisor of the web console child, created on the first web.ensure.
|
|
115
|
+
web;
|
|
116
|
+
spawnWebChild;
|
|
99
117
|
inFlightModels = new Map();
|
|
118
|
+
inFlightOpenUsageReconciliations = new Map();
|
|
119
|
+
liveTranscriptCursors = new Map();
|
|
120
|
+
liveTranscriptQueues = new Map();
|
|
121
|
+
async withLiveTranscriptLock(nativeId, operation) {
|
|
122
|
+
const previous = this.liveTranscriptQueues.get(nativeId) ?? Promise.resolve();
|
|
123
|
+
let unlock;
|
|
124
|
+
const current = new Promise((resolve) => { unlock = resolve; });
|
|
125
|
+
this.liveTranscriptQueues.set(nativeId, current);
|
|
126
|
+
await previous;
|
|
127
|
+
try {
|
|
128
|
+
return await operation();
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
unlock();
|
|
132
|
+
if (this.liveTranscriptQueues.get(nativeId) === current) {
|
|
133
|
+
this.liveTranscriptQueues.delete(nativeId);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async ingestLiveTranscript(sessionId, nativeId, suppliedPath) {
|
|
138
|
+
return this.withLiveTranscriptLock(nativeId, async () => {
|
|
139
|
+
const isActiveClaudeOpenRow = () => {
|
|
140
|
+
const session = this.sessions.get(sessionId);
|
|
141
|
+
return session?.origin === "open" && session.agent === "claude" && isActiveStatus(session.status);
|
|
142
|
+
};
|
|
143
|
+
if (!isActiveClaudeOpenRow())
|
|
144
|
+
return [];
|
|
145
|
+
let resolvedPath;
|
|
146
|
+
let stat;
|
|
147
|
+
try {
|
|
148
|
+
const projectsRoot = await fs.promises.realpath(path.join(os.homedir(), ".claude", "projects"));
|
|
149
|
+
resolvedPath = await fs.promises.realpath(suppliedPath);
|
|
150
|
+
const relativePath = path.relative(projectsRoot, resolvedPath);
|
|
151
|
+
if (relativePath === "" ||
|
|
152
|
+
relativePath === ".." ||
|
|
153
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
154
|
+
path.isAbsolute(relativePath) ||
|
|
155
|
+
path.basename(resolvedPath) !== `${nativeId}.jsonl`)
|
|
156
|
+
return [];
|
|
157
|
+
stat = await fs.promises.stat(resolvedPath);
|
|
158
|
+
if (!stat.isFile())
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
let file;
|
|
165
|
+
try {
|
|
166
|
+
file = await fs.promises.open(resolvedPath, "r");
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
if (!isActiveClaudeOpenRow())
|
|
173
|
+
return [];
|
|
174
|
+
let previous = [];
|
|
175
|
+
try {
|
|
176
|
+
previous = this.nativeLinks.link(sessionId, nativeId).previous;
|
|
177
|
+
const saved = this.liveTranscriptCursors.get(nativeId);
|
|
178
|
+
const reset = saved !== undefined &&
|
|
179
|
+
(saved.device !== stat.dev || saved.inode !== stat.ino || stat.size < saved.byteOffset);
|
|
180
|
+
const state = {
|
|
181
|
+
pendingLine: reset ? new Uint8Array() : saved?.pendingLine ?? new Uint8Array(),
|
|
182
|
+
discardUntilNewline: reset ? false : saved?.discardUntilNewline ?? false,
|
|
183
|
+
inputTokens: saved?.inputTokens ?? 0,
|
|
184
|
+
outputTokens: saved?.outputTokens ?? 0,
|
|
185
|
+
cachedTokens: saved?.cachedTokens ?? 0,
|
|
186
|
+
seenMessageKeys: saved?.seenMessageKeys ?? new Set(),
|
|
187
|
+
};
|
|
188
|
+
const byteOffset = reset ? 0 : saved?.byteOffset ?? 0;
|
|
189
|
+
const bytesToRead = Math.min(TRANSCRIPT_CHUNK_LIMIT_BYTES, Math.max(0, stat.size - byteOffset));
|
|
190
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
191
|
+
const { bytesRead } = bytesToRead === 0
|
|
192
|
+
? { bytesRead: 0 }
|
|
193
|
+
: await file.read(buffer, 0, bytesToRead, byteOffset);
|
|
194
|
+
const nextState = consumeTranscriptChunk(state, buffer.subarray(0, bytesRead));
|
|
195
|
+
if (!isActiveClaudeOpenRow())
|
|
196
|
+
return previous;
|
|
197
|
+
const db = this.db.getHandle();
|
|
198
|
+
db.exec("BEGIN");
|
|
199
|
+
try {
|
|
200
|
+
this.usageLedger.observe(sessionId, openSourceKey(nativeId), {
|
|
201
|
+
inputTokens: nextState.inputTokens,
|
|
202
|
+
outputTokens: nextState.outputTokens,
|
|
203
|
+
cachedTokens: nextState.cachedTokens,
|
|
204
|
+
});
|
|
205
|
+
db.exec("COMMIT");
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
try {
|
|
209
|
+
db.exec("ROLLBACK");
|
|
210
|
+
}
|
|
211
|
+
catch { }
|
|
212
|
+
return previous;
|
|
213
|
+
}
|
|
214
|
+
this.liveTranscriptCursors.set(nativeId, {
|
|
215
|
+
...nextState,
|
|
216
|
+
byteOffset: byteOffset + bytesRead,
|
|
217
|
+
device: stat.dev,
|
|
218
|
+
inode: stat.ino,
|
|
219
|
+
});
|
|
220
|
+
return previous;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return previous;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await file.close().catch(() => { });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
queueOpenUsageReconciliation(sessionId, nativeIds) {
|
|
232
|
+
const uniqueIds = [...new Set(nativeIds)];
|
|
233
|
+
const keyFor = (nativeId) => JSON.stringify([sessionId, nativeId]);
|
|
234
|
+
const pendingIds = uniqueIds.filter((nativeId) => !this.inFlightOpenUsageReconciliations.has(keyFor(nativeId)));
|
|
235
|
+
if (pendingIds.length === 0)
|
|
236
|
+
return;
|
|
237
|
+
const keys = pendingIds.map(keyFor);
|
|
238
|
+
const reconciliation = Promise.resolve().then(() => this.reconcileOpenUsageSafely(sessionId, pendingIds));
|
|
239
|
+
for (const key of keys)
|
|
240
|
+
this.inFlightOpenUsageReconciliations.set(key, reconciliation);
|
|
241
|
+
const clearInFlight = () => {
|
|
242
|
+
for (const key of keys) {
|
|
243
|
+
if (this.inFlightOpenUsageReconciliations.get(key) === reconciliation) {
|
|
244
|
+
this.inFlightOpenUsageReconciliations.delete(key);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
void reconciliation.then(clearInFlight, clearInFlight);
|
|
249
|
+
}
|
|
250
|
+
async cleanupReleasedTranscriptCursors(sessionId) {
|
|
251
|
+
const nativeIds = new Set(this.nativeLinks.linksFor([sessionId]).map((link) => link.nativeId));
|
|
252
|
+
await Promise.all([...nativeIds].map((nativeId) => this.withLiveTranscriptLock(nativeId, async () => {
|
|
253
|
+
const activeClaudeOpenIds = this.sessions.listActive()
|
|
254
|
+
.filter((session) => session.origin === "open" && session.agent === "claude")
|
|
255
|
+
.map((session) => session.id);
|
|
256
|
+
const stillActive = this.nativeLinks.linksFor(activeClaudeOpenIds)
|
|
257
|
+
.some((link) => link.nativeId === nativeId);
|
|
258
|
+
if (!stillActive)
|
|
259
|
+
this.liveTranscriptCursors.delete(nativeId);
|
|
260
|
+
})));
|
|
261
|
+
}
|
|
262
|
+
queueReleasedTranscriptCursorCleanup(sessionId) {
|
|
263
|
+
queueMicrotask(() => {
|
|
264
|
+
void this.cleanupReleasedTranscriptCursors(sessionId).catch((error) => {
|
|
265
|
+
const detail = errorMessage(error);
|
|
266
|
+
try {
|
|
267
|
+
fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] usage cursor cleanup failed session=${sessionId}: ${detail}\n`);
|
|
268
|
+
}
|
|
269
|
+
catch { }
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
100
273
|
async fetchModels(agent, refresh) {
|
|
101
274
|
const key = `${agent || "all"}:${Boolean(refresh)}`;
|
|
102
275
|
const existing = this.inFlightModels.get(key);
|
|
@@ -108,7 +281,9 @@ class Daemon {
|
|
|
108
281
|
this.inFlightModels.set(key, promise);
|
|
109
282
|
return promise;
|
|
110
283
|
}
|
|
111
|
-
constructor() {
|
|
284
|
+
constructor(options = {}) {
|
|
285
|
+
this.web = options.webSupervisor;
|
|
286
|
+
this.spawnWebChild = options.spawnWebChild;
|
|
112
287
|
ensureDirs();
|
|
113
288
|
this.db = new Database();
|
|
114
289
|
const handle = this.db.getHandle();
|
|
@@ -141,7 +316,7 @@ class Daemon {
|
|
|
141
316
|
await this.handleRequest(req, socket);
|
|
142
317
|
}
|
|
143
318
|
catch (e) {
|
|
144
|
-
const res = { id: req.id, error: { code: "INTERNAL", message:
|
|
319
|
+
const res = { id: req.id, error: { code: "INTERNAL", message: errorMessage(e) } };
|
|
145
320
|
try {
|
|
146
321
|
socket.write(JSON.stringify(res) + "\n");
|
|
147
322
|
}
|
|
@@ -152,20 +327,26 @@ class Daemon {
|
|
|
152
327
|
this.server.listen(paths.daemonSock, () => resolve());
|
|
153
328
|
this.server.on("error", reject);
|
|
154
329
|
});
|
|
330
|
+
this.boundPaths = {
|
|
331
|
+
sock: paths.daemonSock,
|
|
332
|
+
pid: paths.daemonPid,
|
|
333
|
+
};
|
|
155
334
|
// Write pid file
|
|
156
335
|
try {
|
|
157
336
|
fs.writeFileSync(paths.daemonPid, String(process.pid), "utf-8");
|
|
158
337
|
}
|
|
159
338
|
catch { }
|
|
160
339
|
// Graceful power shutdown: exactly-once drain on power signals. SIGHUP
|
|
161
|
-
// covers
|
|
340
|
+
// covers logout/session teardown. Keep listeners installed so a
|
|
162
341
|
// repeated signal reaches the synchronous shuttingDown guard.
|
|
342
|
+
//
|
|
343
|
+
// No systemd-inhibit delay lock: one only helps a holder that reacts to
|
|
344
|
+
// logind's PrepareForShutdown and then releases it. The drain starts on
|
|
345
|
+
// SIGTERM, which arrives after the delay expires, so the lock stalled
|
|
346
|
+
// every poweroff and suspend for the full InhibitDelayMaxSec for nothing.
|
|
163
347
|
process.on("SIGTERM", () => this.onSignal("SIGTERM"));
|
|
164
348
|
process.on("SIGINT", () => this.onSignal("SIGINT"));
|
|
165
349
|
process.on("SIGHUP", () => this.onSignal("SIGHUP"));
|
|
166
|
-
// Best-effort delay lock so the system waits for the drain. Silent
|
|
167
|
-
// no-op when systemd-inhibit is absent (containers, macOS, CI).
|
|
168
|
-
this.maybeSpawnInhibit();
|
|
169
350
|
console.log(`[daemon] listening on ${paths.daemonSock} pid=${process.pid}`);
|
|
170
351
|
// Keep alive
|
|
171
352
|
// Write log
|
|
@@ -180,6 +361,7 @@ class Daemon {
|
|
|
180
361
|
// streaming; a dead one is drained and classified from its log tail.
|
|
181
362
|
async recover() {
|
|
182
363
|
const paths = getPaths();
|
|
364
|
+
this.reviveStoreBusyFailures(paths.logsDir);
|
|
183
365
|
const actives = this.sessions.listActive();
|
|
184
366
|
for (const s of actives) {
|
|
185
367
|
// Power-shutdown rows are terminal: never reattach, never flip. A
|
|
@@ -248,7 +430,10 @@ class Daemon {
|
|
|
248
430
|
// Never attach to or signal a PID whose identity changed while the
|
|
249
431
|
// daemon was away. The original harness is gone; the replacement is
|
|
250
432
|
// somebody else's process.
|
|
251
|
-
|
|
433
|
+
// A revived row died long ago: its PID may be anyone's by now, but its
|
|
434
|
+
// log is still its own, so drain it as dead instead of failing it.
|
|
435
|
+
const healing = this.healing.has(s.id);
|
|
436
|
+
if (pidReused && !healing) {
|
|
252
437
|
const failure = {
|
|
253
438
|
code: "HARNESS_CRASH",
|
|
254
439
|
blame: "harness",
|
|
@@ -269,13 +454,14 @@ class Daemon {
|
|
|
269
454
|
this.sessions.setStatus(s.id, "failed", { lastEvent: detail, failure });
|
|
270
455
|
continue;
|
|
271
456
|
}
|
|
272
|
-
|
|
457
|
+
// Only a healing row reaches here with a reused PID; it counts as dead.
|
|
458
|
+
const alive = processPresent && !pidReused;
|
|
273
459
|
const stdoutPath = path.join(paths.logsDir, `${s.id}.ndjson`);
|
|
274
460
|
const stderrPath = path.join(paths.logsDir, `${s.id}.stderr.log`);
|
|
275
461
|
const hasDetachedLogs = fs.existsSync(stdoutPath) || fs.existsSync(stderrPath);
|
|
276
462
|
// A live PID without a recorded identity is unsafe to attach: it may be
|
|
277
463
|
// a recycled process. A dead PID is safe to drain from its own log.
|
|
278
|
-
const identityVerified = !
|
|
464
|
+
const identityVerified = !alive || (recordedStart !== undefined && currentStart === recordedStart);
|
|
279
465
|
if (typeof driver.attach === "function" && pid != null && hasDetachedLogs && identityVerified) {
|
|
280
466
|
try {
|
|
281
467
|
await driver.attach({
|
|
@@ -321,6 +507,61 @@ class Daemon {
|
|
|
321
507
|
}
|
|
322
508
|
}
|
|
323
509
|
}
|
|
510
|
+
// A session the old event loop failed on "database is locked" was never
|
|
511
|
+
// over: the harness kept writing its log past the persisted offset. Flip
|
|
512
|
+
// such rows back to working so the reattach loop below drains the log
|
|
513
|
+
// (dead harness) or keeps tailing it (live one), exactly as after a restart.
|
|
514
|
+
reviveStoreBusyFailures(logsDir) {
|
|
515
|
+
for (const s of this.sessions.listStoreBusyFailures()) {
|
|
516
|
+
if (s.origin === "open")
|
|
517
|
+
continue;
|
|
518
|
+
const logs = [path.join(logsDir, `${s.id}.ndjson`), path.join(logsDir, `${s.id}.stderr.log`)]
|
|
519
|
+
.filter((file) => fs.existsSync(file));
|
|
520
|
+
const metadata = readSessionProcessMetadata(s.id);
|
|
521
|
+
const pid = metadata?.pid ?? s.pid;
|
|
522
|
+
if (logs.length === 0 || pid == null)
|
|
523
|
+
continue;
|
|
524
|
+
const recordedStart = metadata?.pidStartTime ?? s.pidStartTime;
|
|
525
|
+
const alive = processAlive(pid) && (recordedStart === undefined || processStartTime(pid) === recordedStart);
|
|
526
|
+
// Parsers stamp events with read time. For a dead harness the last log
|
|
527
|
+
// write is the best bound on when the backlog really happened.
|
|
528
|
+
const endedAt = alive
|
|
529
|
+
? undefined
|
|
530
|
+
: new Date(Math.max(...logs.map((file) => fs.statSync(file).mtimeMs)));
|
|
531
|
+
const last = this.db.getHandle().prepare(`SELECT COALESCE(MAX(sequence), 0) AS seq FROM events WHERE session_id = ?`).get(s.id);
|
|
532
|
+
this.healing.set(s.id, { fromSequence: last.seq, endedAt });
|
|
533
|
+
this.sessions.setStatus(s.id, "working", {
|
|
534
|
+
failure: null,
|
|
535
|
+
completedAt: null,
|
|
536
|
+
lastEvent: "healing after a store-busy failure",
|
|
537
|
+
});
|
|
538
|
+
appendDaemonLog(`healing ${s.id} from store-busy failure (${alive ? "live" : "dead"} harness)`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Once a revived session's stream ends, pin the drained backlog and the
|
|
542
|
+
// terminal time to the log mtime instead of the replay time.
|
|
543
|
+
finishHeal(sessionId) {
|
|
544
|
+
const heal = this.healing.get(sessionId);
|
|
545
|
+
if (!heal)
|
|
546
|
+
return;
|
|
547
|
+
this.healing.delete(sessionId);
|
|
548
|
+
if (!heal.endedAt)
|
|
549
|
+
return;
|
|
550
|
+
const endedAt = heal.endedAt.toISOString();
|
|
551
|
+
try {
|
|
552
|
+
this.db.getHandle().prepare(`
|
|
553
|
+
UPDATE events
|
|
554
|
+
SET timestamp = ?, normalized_payload = json_set(normalized_payload, '$.timestamp', ?)
|
|
555
|
+
WHERE session_id = ? AND sequence > ? AND timestamp > ?
|
|
556
|
+
`).run(endedAt, endedAt, sessionId, heal.fromSequence, endedAt);
|
|
557
|
+
const s = this.sessions.get(sessionId);
|
|
558
|
+
if (s && isTerminalStatus(s.status))
|
|
559
|
+
this.sessions.update(sessionId, { completedAt: heal.endedAt });
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
appendDaemonLog(`heal timestamps for ${sessionId} failed: ${errorMessage(error)}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
324
565
|
async reconcileOpenUsage(sessionId, nativeIds) {
|
|
325
566
|
const session = this.sessions.get(sessionId);
|
|
326
567
|
if (!session || session.origin !== "open" || session.agent !== "claude")
|
|
@@ -366,13 +607,45 @@ class Daemon {
|
|
|
366
607
|
async reconcileOpenUsageSafely(sessionId, nativeIds) {
|
|
367
608
|
try {
|
|
368
609
|
await this.reconcileOpenUsage(sessionId, nativeIds);
|
|
610
|
+
return true;
|
|
369
611
|
}
|
|
370
612
|
catch (error) {
|
|
371
|
-
const detail =
|
|
613
|
+
const detail = errorMessage(error);
|
|
372
614
|
try {
|
|
373
615
|
fs.appendFileSync(getPaths().daemonLog, `[${new Date().toISOString()}] usage reconcile failed session=${sessionId}: ${detail}\n`);
|
|
374
616
|
}
|
|
375
617
|
catch { }
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
nextSessionId() {
|
|
622
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
623
|
+
const sessionId = generateSessionId();
|
|
624
|
+
if (!this.sessions.getExact(sessionId) && !this.reservedSessionIds.has(sessionId)) {
|
|
625
|
+
this.reservedSessionIds.add(sessionId);
|
|
626
|
+
return sessionId;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
throw new Error("Unable to generate a unique session ID after 100 attempts");
|
|
630
|
+
}
|
|
631
|
+
releaseSessionId(sessionId) {
|
|
632
|
+
this.reservedSessionIds.delete(sessionId);
|
|
633
|
+
}
|
|
634
|
+
resolveRegisteredAgent(rawAgent) {
|
|
635
|
+
const agent = (normalizeAgentId(rawAgent) ?? rawAgent);
|
|
636
|
+
return { agent, registered: this.registry.has(agent) };
|
|
637
|
+
}
|
|
638
|
+
handleClaimRequest(requestedSessionId, operation, result, send) {
|
|
639
|
+
try {
|
|
640
|
+
const sessionId = this.sessions.get(requestedSessionId)?.id ?? requestedSessionId;
|
|
641
|
+
send(result(operation(sessionId)));
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
if (error instanceof RunAgentError) {
|
|
645
|
+
send({ error: { code: error.code, message: error.message, details: error.details } });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
376
649
|
}
|
|
377
650
|
}
|
|
378
651
|
async handleRequest(req, socket) {
|
|
@@ -383,746 +656,736 @@ class Daemon {
|
|
|
383
656
|
}
|
|
384
657
|
catch { }
|
|
385
658
|
};
|
|
659
|
+
const sendSessionNotFound = (sessionId) => send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${sessionId} not found` } });
|
|
660
|
+
const sendShuttingDown = () => send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
|
|
386
661
|
// Power-shutdown guard: while draining, refuse new work so a send/stop
|
|
387
662
|
// cannot race the interrupted persist. daemon.stop is the shutdown
|
|
388
663
|
// trigger itself, so it stays admitted (idempotent via handleShutdown).
|
|
389
664
|
if (this.shuttingDown && method !== "daemon.stop") {
|
|
390
|
-
|
|
665
|
+
sendShuttingDown();
|
|
391
666
|
return;
|
|
392
667
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const wantsWorktree = p.worktree === true || (p.worktree === undefined && cfg.worktree === true);
|
|
426
|
-
const wantsNoWorktree = p.noWorktree === true || p.worktree === false;
|
|
427
|
-
if (wantsWorktree && gitInfo) {
|
|
668
|
+
try {
|
|
669
|
+
switch (method) {
|
|
670
|
+
case "session.create": {
|
|
671
|
+
const p = params;
|
|
672
|
+
const prompt = p.prompt;
|
|
673
|
+
if (!prompt) {
|
|
674
|
+
send({ error: { code: "INVALID", message: "prompt required" } });
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const cwdIn = p.cwd || process.cwd();
|
|
678
|
+
const cfg = loadConfig();
|
|
679
|
+
const rawAgent = p.agent || cfg.defaultAgent || "claude";
|
|
680
|
+
const { agent, registered } = this.resolveRegisteredAgent(rawAgent);
|
|
681
|
+
if (!registered) {
|
|
682
|
+
send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const requestSandbox = resolveRequestSandbox(p.sandbox);
|
|
686
|
+
const configuredSandbox = resolveDefaultSandbox(cfg);
|
|
687
|
+
const sandbox = agent === "codex" ? requestSandbox ?? configuredSandbox : undefined;
|
|
688
|
+
let cwd = path.resolve(cwdIn);
|
|
689
|
+
// Only record an edge to a session the store knows: a stale or foreign
|
|
690
|
+
// CODEDECK_SESSION_ID must not invent a parent the tree cannot draw.
|
|
691
|
+
const parent = typeof p.parentId === "string" && p.parentId.length > 0
|
|
692
|
+
? this.sessions.getExact(p.parentId)
|
|
693
|
+
: null;
|
|
694
|
+
const sessionId = this.nextSessionId();
|
|
695
|
+
let worktree;
|
|
696
|
+
let branch;
|
|
697
|
+
let repository;
|
|
698
|
+
let baseCommit;
|
|
699
|
+
let gitInfo;
|
|
428
700
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
701
|
+
gitInfo = await getGitInfo(cwd);
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
this.releaseSessionId(sessionId);
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
if (this.shuttingDown) {
|
|
708
|
+
this.releaseSessionId(sessionId);
|
|
709
|
+
sendShuttingDown();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (gitInfo)
|
|
713
|
+
repository = gitInfo.root;
|
|
714
|
+
const wantsWorktree = p.worktree === true || (p.worktree === undefined && cfg.worktree === true);
|
|
715
|
+
const wantsNoWorktree = p.noWorktree === true || p.worktree === false;
|
|
716
|
+
if (wantsWorktree && gitInfo) {
|
|
717
|
+
try {
|
|
718
|
+
const wt = await createWorktree({ repoRoot: gitInfo.root, sessionId, prompt, name: p.name });
|
|
719
|
+
if (this.shuttingDown) {
|
|
720
|
+
this.releaseSessionId(sessionId);
|
|
721
|
+
sendShuttingDown();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
worktree = wt.path;
|
|
725
|
+
branch = wt.branch;
|
|
726
|
+
baseCommit = wt.baseCommit;
|
|
727
|
+
cwd = worktree;
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
this.releaseSessionId(sessionId);
|
|
731
|
+
send({ error: { code: "WORKTREE_FAILED", message: errorMessage(e) } });
|
|
432
732
|
return;
|
|
433
733
|
}
|
|
434
|
-
worktree = wt.path;
|
|
435
|
-
branch = wt.branch;
|
|
436
|
-
baseCommit = wt.baseCommit;
|
|
437
|
-
cwd = worktree;
|
|
438
734
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
return;
|
|
735
|
+
else if (gitInfo) {
|
|
736
|
+
baseCommit = gitInfo.head;
|
|
442
737
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
createdAt: now,
|
|
466
|
-
updatedAt: now,
|
|
467
|
-
};
|
|
468
|
-
this.sessions.create(session);
|
|
469
|
-
// Start driver async (don't block response too long)
|
|
470
|
-
// But we need to start before returning? Return quickly then start
|
|
471
|
-
send({ result: { session } });
|
|
472
|
-
// Now start driver in background
|
|
473
|
-
this.startDriverForSession(sessionId, prompt, p.model).catch((e) => {
|
|
474
|
-
if (this.shuttingDown)
|
|
475
|
-
return;
|
|
476
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
477
|
-
const failure = classifyFailure(msg);
|
|
478
|
-
const failEv = {
|
|
479
|
-
type: "session.failed",
|
|
480
|
-
sessionId,
|
|
481
|
-
timestamp: new Date().toISOString(),
|
|
482
|
-
error: msg,
|
|
483
|
-
failure,
|
|
484
|
-
raw: e,
|
|
738
|
+
const now = new Date();
|
|
739
|
+
const session = {
|
|
740
|
+
id: sessionId,
|
|
741
|
+
runId: typeof p.runId === "string" ? p.runId : parent?.runId ?? undefined,
|
|
742
|
+
parentId: parent?.id,
|
|
743
|
+
role: typeof p.role === "string" ? parseRole(p.role) : undefined,
|
|
744
|
+
name: p.name,
|
|
745
|
+
agent,
|
|
746
|
+
model: p.model,
|
|
747
|
+
effort: p.effort,
|
|
748
|
+
fast: !!p.fast,
|
|
749
|
+
sandbox,
|
|
750
|
+
dangerouslyBypassApprovalsAndSandbox: !!p.dangerouslyBypassApprovalsAndSandbox,
|
|
751
|
+
status: "starting",
|
|
752
|
+
repository,
|
|
753
|
+
cwd,
|
|
754
|
+
worktree,
|
|
755
|
+
branch,
|
|
756
|
+
baseCommit: baseCommit || undefined,
|
|
757
|
+
pid: undefined,
|
|
758
|
+
createdAt: now,
|
|
759
|
+
updatedAt: now,
|
|
485
760
|
};
|
|
486
761
|
try {
|
|
487
|
-
this.sessions.
|
|
762
|
+
this.sessions.create(session);
|
|
488
763
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
: undefined;
|
|
514
|
-
if (matchingSession &&
|
|
515
|
-
(isTerminalStatus(matchingSession.status) || !livePidIdentity(matchingSession))) {
|
|
516
|
-
const now = new Date();
|
|
517
|
-
this.sessions.setStatus(matchingSession.id, "working", {
|
|
518
|
-
...(p.model !== undefined ? { model: p.model } : {}),
|
|
519
|
-
...(p.effort !== undefined ? { effort: p.effort } : {}),
|
|
520
|
-
...(p.name !== undefined ? { name: p.name } : {}),
|
|
521
|
-
...(p.cwd !== undefined ? { cwd } : {}),
|
|
522
|
-
pid: null,
|
|
523
|
-
pidStartTime: null,
|
|
524
|
-
completedAt: null,
|
|
525
|
-
lastEvent: null,
|
|
526
|
-
failure: null,
|
|
527
|
-
updatedAt: now,
|
|
764
|
+
finally {
|
|
765
|
+
this.releaseSessionId(sessionId);
|
|
766
|
+
}
|
|
767
|
+
// Start driver async (don't block response too long)
|
|
768
|
+
send({ result: { session } });
|
|
769
|
+
// Now start driver in background
|
|
770
|
+
this.startDriverForSession(sessionId, prompt, p.model).catch((e) => {
|
|
771
|
+
if (this.shuttingDown)
|
|
772
|
+
return;
|
|
773
|
+
const msg = errorMessage(e);
|
|
774
|
+
const failure = classifyFailure(msg);
|
|
775
|
+
const failEv = {
|
|
776
|
+
type: "session.failed",
|
|
777
|
+
sessionId,
|
|
778
|
+
timestamp: new Date().toISOString(),
|
|
779
|
+
error: msg,
|
|
780
|
+
failure,
|
|
781
|
+
raw: e,
|
|
782
|
+
};
|
|
783
|
+
try {
|
|
784
|
+
this.sessions.setStatus(sessionId, "failed", { lastEvent: msg.slice(0, 200), failure });
|
|
785
|
+
}
|
|
786
|
+
catch { }
|
|
787
|
+
this.broadcast(sessionId, failEv);
|
|
528
788
|
});
|
|
529
|
-
const revived = this.sessions.get(matchingSession.id);
|
|
530
|
-
const event = {
|
|
531
|
-
type: "session.started",
|
|
532
|
-
sessionId: revived.id,
|
|
533
|
-
timestamp: now.toISOString(),
|
|
534
|
-
agent,
|
|
535
|
-
nativeSessionId: p.resume,
|
|
536
|
-
};
|
|
537
|
-
this.events.append(revived.id, event);
|
|
538
|
-
this.broadcast(revived.id, event);
|
|
539
|
-
send({ result: { session: revived } });
|
|
540
789
|
break;
|
|
541
790
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
const now = new Date();
|
|
552
|
-
const session = {
|
|
553
|
-
id: sessionId,
|
|
554
|
-
runId: sessionId,
|
|
555
|
-
origin: "open",
|
|
556
|
-
name: p.name,
|
|
557
|
-
agent,
|
|
558
|
-
model: p.model,
|
|
559
|
-
effort: p.effort,
|
|
560
|
-
status: "working",
|
|
561
|
-
repository,
|
|
562
|
-
cwd,
|
|
563
|
-
worktree: p.worktree,
|
|
564
|
-
branch: p.branch,
|
|
565
|
-
baseCommit: baseCommit || undefined,
|
|
566
|
-
createdAt: now,
|
|
567
|
-
updatedAt: now,
|
|
568
|
-
};
|
|
569
|
-
this.sessions.create(session);
|
|
570
|
-
send({ result: { session } });
|
|
571
|
-
break;
|
|
572
|
-
}
|
|
573
|
-
case "session.patch": {
|
|
574
|
-
const p = params;
|
|
575
|
-
const s = this.sessions.get(p.id);
|
|
576
|
-
if (!s) {
|
|
577
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
let pidStartTime = p.pidStartTime;
|
|
581
|
-
if (p.pid !== undefined && pidStartTime === undefined) {
|
|
582
|
-
pidStartTime = processStartTime(p.pid);
|
|
583
|
-
}
|
|
584
|
-
const updates = {};
|
|
585
|
-
if (p.pid !== undefined)
|
|
586
|
-
updates.pid = p.pid;
|
|
587
|
-
if (pidStartTime !== undefined)
|
|
588
|
-
updates.pidStartTime = pidStartTime;
|
|
589
|
-
if (p.worktree !== undefined)
|
|
590
|
-
updates.worktree = p.worktree;
|
|
591
|
-
if (p.branch !== undefined)
|
|
592
|
-
updates.branch = p.branch;
|
|
593
|
-
if (p.baseCommit !== undefined)
|
|
594
|
-
updates.baseCommit = p.baseCommit;
|
|
595
|
-
if (p.cwd !== undefined)
|
|
596
|
-
updates.cwd = p.cwd;
|
|
597
|
-
this.sessions.update(s.id, updates);
|
|
598
|
-
const updated = this.sessions.get(s.id);
|
|
599
|
-
send({ result: { session: updated } });
|
|
600
|
-
break;
|
|
601
|
-
}
|
|
602
|
-
case "session.linkNative": {
|
|
603
|
-
const p = (params || {});
|
|
604
|
-
if (typeof p.id !== "string" || typeof p.nativeId !== "string" || p.nativeId.length === 0) {
|
|
605
|
-
send({ error: { code: "INVALID", message: "id and nativeId required" } });
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
const session = this.sessions.get(p.id);
|
|
609
|
-
if (!session) {
|
|
610
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
if (session.origin !== "open") {
|
|
614
|
-
send({ result: { created: false } });
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
const { created, previous } = this.nativeLinks.link(session.id, p.nativeId);
|
|
618
|
-
if (previous.length > 0)
|
|
619
|
-
await this.reconcileOpenUsageSafely(session.id, previous);
|
|
620
|
-
send({ result: { created } });
|
|
621
|
-
break;
|
|
622
|
-
}
|
|
623
|
-
case "session.release": {
|
|
624
|
-
const p = params;
|
|
625
|
-
const s = this.sessions.get(p.id);
|
|
626
|
-
if (!s) {
|
|
627
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
// Release owns the terminal outcome: a queued message must not
|
|
631
|
-
// survive as a phantom "na fila" on every exit, including the
|
|
632
|
-
// terminal early return below.
|
|
633
|
-
this.clearPending(s.id);
|
|
634
|
-
// Interrupted is an archival terminal: a power-shutdown row stays
|
|
635
|
-
// actionable and release may finalize it to completed/failed — unless
|
|
636
|
-
// its process is still alive under the same identity, which keeps
|
|
637
|
-
// the send path's stop-first guard instead of orphaning it.
|
|
638
|
-
if (s.status === "interrupted" && livePidIdentity(s)) {
|
|
639
|
-
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
|
|
640
|
-
return;
|
|
641
|
-
}
|
|
642
|
-
if (isTerminalStatus(s.status) && s.status !== "interrupted") {
|
|
643
|
-
send({ result: { session: this.sessions.get(s.id) } });
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
const targetStatus = p.status === "failed" ? "failed" : "completed";
|
|
647
|
-
const extra = {};
|
|
648
|
-
if (p.nativeSessionId !== undefined) {
|
|
649
|
-
extra.nativeSessionId = p.nativeSessionId;
|
|
650
|
-
}
|
|
651
|
-
if (targetStatus === "failed") {
|
|
652
|
-
extra.lastEvent = (p.error || "failed").slice(0, 200);
|
|
653
|
-
}
|
|
654
|
-
else {
|
|
655
|
-
extra.lastEvent = "released";
|
|
656
|
-
}
|
|
657
|
-
this.sessions.setStatus(s.id, targetStatus, extra);
|
|
658
|
-
if (s.origin === "open" && s.agent === "claude") {
|
|
659
|
-
if (typeof p.nativeSessionId === "string" && p.nativeSessionId.length > 0) {
|
|
660
|
-
this.nativeLinks.link(s.id, p.nativeSessionId);
|
|
791
|
+
case "session.adopt": {
|
|
792
|
+
const p = params;
|
|
793
|
+
const cwdIn = p.cwd || process.cwd();
|
|
794
|
+
const rawAgent = p.agent || "claude";
|
|
795
|
+
const { agent, registered } = this.resolveRegisteredAgent(rawAgent);
|
|
796
|
+
if (!registered) {
|
|
797
|
+
send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${agent}` } });
|
|
798
|
+
return;
|
|
661
799
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
error: p.error || "session failed",
|
|
800
|
+
const cwd = path.resolve(cwdIn);
|
|
801
|
+
let repository;
|
|
802
|
+
let baseCommit = p.baseCommit;
|
|
803
|
+
const gitInfo = await getGitInfo(cwd);
|
|
804
|
+
if (this.shuttingDown) {
|
|
805
|
+
sendShuttingDown();
|
|
806
|
+
return;
|
|
670
807
|
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const evCount = this.events.count(s.id);
|
|
701
|
-
const recent = this.events.list(s.id, 10);
|
|
702
|
-
send({ result: { session: s, events: recent, eventCount: evCount } });
|
|
703
|
-
break;
|
|
704
|
-
}
|
|
705
|
-
case "session.rename": {
|
|
706
|
-
const p = params;
|
|
707
|
-
const s = this.sessions.get(p.id);
|
|
708
|
-
if (!s) {
|
|
709
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
if (!p.name || !p.name.trim()) {
|
|
713
|
-
send({ error: { code: "INVALID", message: "name required" } });
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
this.sessions.update(s.id, { name: p.name });
|
|
717
|
-
send({ result: { ok: true } });
|
|
718
|
-
break;
|
|
719
|
-
}
|
|
720
|
-
case "session.send": {
|
|
721
|
-
const p = params;
|
|
722
|
-
const validated = validateSendMessage(p.message);
|
|
723
|
-
if ("code" in validated) {
|
|
724
|
-
send({ error: { code: validated.code, message: validated.error } });
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
const message = validated.message;
|
|
728
|
-
const s = this.sessions.get(p.id);
|
|
729
|
-
if (!s) {
|
|
730
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
if (s.origin === "open") {
|
|
734
|
-
send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} is an interactive head session and does not support headless prompt turns` } });
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
const driver = this.registry.get(s.agent);
|
|
738
|
-
if (!driver.capabilities().resume) {
|
|
739
|
-
send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
if (s.status === "interrupted" && !s.nativeSessionId) {
|
|
743
|
-
send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
// Power-interrupted sessions with a live process keep the old rule:
|
|
747
|
-
// stop first, never queue behind a running harness.
|
|
748
|
-
if (s.status === "interrupted" && livePidIdentity(s)) {
|
|
749
|
-
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
|
|
750
|
-
return;
|
|
751
|
-
}
|
|
752
|
-
// A lifecycle operation owns the session: retry instead of queueing,
|
|
753
|
-
// so a send racing stop cannot strand a phantom slot with no stream
|
|
754
|
-
// tail left to consume it (stop cancels the queue on exit).
|
|
755
|
-
if (this.sessionLocks.has(s.id)) {
|
|
756
|
-
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
|
|
757
|
-
return;
|
|
758
|
-
}
|
|
759
|
-
// Do not start a second harness while the current one is still live:
|
|
760
|
-
// both runtimes would tail the same per-session file and duplicate
|
|
761
|
-
// every event from the follow-up turn. Queue behind a live turn only.
|
|
762
|
-
const busyHandle = driver.getHandle?.(s.id);
|
|
763
|
-
const busyState = busyHandle && typeof busyHandle === "object"
|
|
764
|
-
? busyHandle
|
|
765
|
-
: undefined;
|
|
766
|
-
const runtimeBusy = busyHandle !== undefined && (busyState?.done !== true || busyState?.drained !== true);
|
|
767
|
-
const busy = s.status === "starting" || runtimeBusy || livePidIdentity(s);
|
|
768
|
-
if (busy) {
|
|
769
|
-
// One-slot queue, last-wins: persist without holding the lifecycle
|
|
770
|
-
// lock, then converge — the turn may have ended between the busy
|
|
771
|
-
// read and this persist, in which case tryDispatch starts now.
|
|
772
|
-
const pendingAt = new Date().toISOString();
|
|
773
|
-
try {
|
|
774
|
-
this.sessions.update(s.id, { pendingMessage: message, pendingAt });
|
|
808
|
+
const matchingSession = typeof p.resume === "string" && p.resume.length > 0
|
|
809
|
+
? this.sessions.listOpenByNativeId(p.resume).find((session) => session.agent === agent)
|
|
810
|
+
: undefined;
|
|
811
|
+
if (matchingSession &&
|
|
812
|
+
(isTerminalStatus(matchingSession.status) || !livePidIdentity(matchingSession))) {
|
|
813
|
+
const now = new Date();
|
|
814
|
+
this.sessions.setStatus(matchingSession.id, "working", {
|
|
815
|
+
...(p.model !== undefined ? { model: p.model } : {}),
|
|
816
|
+
...(p.effort !== undefined ? { effort: p.effort } : {}),
|
|
817
|
+
...(p.name !== undefined ? { name: p.name } : {}),
|
|
818
|
+
...(p.cwd !== undefined ? { cwd } : {}),
|
|
819
|
+
pid: null,
|
|
820
|
+
pidStartTime: null,
|
|
821
|
+
completedAt: null,
|
|
822
|
+
lastEvent: null,
|
|
823
|
+
failure: null,
|
|
824
|
+
updatedAt: now,
|
|
825
|
+
});
|
|
826
|
+
const revived = this.sessions.get(matchingSession.id);
|
|
827
|
+
const event = {
|
|
828
|
+
type: "session.started",
|
|
829
|
+
sessionId: revived.id,
|
|
830
|
+
timestamp: now.toISOString(),
|
|
831
|
+
agent,
|
|
832
|
+
nativeSessionId: p.resume,
|
|
833
|
+
};
|
|
834
|
+
this.emit(revived.id, event);
|
|
835
|
+
send({ result: { session: revived } });
|
|
836
|
+
break;
|
|
775
837
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
838
|
+
const sessionId = this.nextSessionId();
|
|
839
|
+
if (gitInfo) {
|
|
840
|
+
repository = gitInfo.root;
|
|
841
|
+
if (!baseCommit)
|
|
842
|
+
baseCommit = gitInfo.head;
|
|
779
843
|
}
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
844
|
+
const now = new Date();
|
|
845
|
+
const session = {
|
|
846
|
+
id: sessionId,
|
|
847
|
+
runId: sessionId,
|
|
848
|
+
origin: "open",
|
|
849
|
+
role: typeof p.role === "string" ? parseRole(p.role) : undefined,
|
|
850
|
+
name: p.name,
|
|
851
|
+
agent,
|
|
852
|
+
model: p.model,
|
|
853
|
+
effort: p.effort,
|
|
854
|
+
status: "working",
|
|
855
|
+
repository,
|
|
856
|
+
cwd,
|
|
857
|
+
worktree: p.worktree,
|
|
858
|
+
branch: p.branch,
|
|
859
|
+
baseCommit: baseCommit || undefined,
|
|
860
|
+
createdAt: now,
|
|
861
|
+
updatedAt: now,
|
|
786
862
|
};
|
|
787
863
|
try {
|
|
788
|
-
this.
|
|
864
|
+
this.sessions.create(session);
|
|
789
865
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
try {
|
|
793
|
-
this.sessions.update(s.id, { lastEvent: `queued: ${message.slice(0, 80)}` });
|
|
866
|
+
finally {
|
|
867
|
+
this.releaseSessionId(sessionId);
|
|
794
868
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
send({ result: { ok: true, queued: true } });
|
|
798
|
-
return;
|
|
869
|
+
send({ result: { session } });
|
|
870
|
+
break;
|
|
799
871
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
|
|
872
|
+
case "session.patch": {
|
|
873
|
+
const p = params;
|
|
874
|
+
const s = this.sessions.get(p.id);
|
|
875
|
+
if (!s) {
|
|
876
|
+
sendSessionNotFound(p.id);
|
|
877
|
+
return;
|
|
807
878
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
879
|
+
let pidStartTime = p.pidStartTime;
|
|
880
|
+
if (p.pid !== undefined && pidStartTime === undefined) {
|
|
881
|
+
pidStartTime = processStartTime(p.pid);
|
|
882
|
+
}
|
|
883
|
+
const updates = {};
|
|
884
|
+
if (p.pid !== undefined)
|
|
885
|
+
updates.pid = p.pid;
|
|
886
|
+
if (pidStartTime !== undefined)
|
|
887
|
+
updates.pidStartTime = pidStartTime;
|
|
888
|
+
if (p.worktree !== undefined)
|
|
889
|
+
updates.worktree = p.worktree;
|
|
890
|
+
if (p.branch !== undefined)
|
|
891
|
+
updates.branch = p.branch;
|
|
892
|
+
if (p.baseCommit !== undefined)
|
|
893
|
+
updates.baseCommit = p.baseCommit;
|
|
894
|
+
if (p.cwd !== undefined)
|
|
895
|
+
updates.cwd = p.cwd;
|
|
896
|
+
this.sessions.update(s.id, updates);
|
|
897
|
+
const updated = this.sessions.get(s.id);
|
|
898
|
+
send({ result: { session: updated } });
|
|
899
|
+
break;
|
|
811
900
|
}
|
|
812
|
-
|
|
813
|
-
const
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
: this.shuttingDown ? "SERVICE_UNAVAILABLE" : "SEND_FAILED";
|
|
817
|
-
if (code === "SERVICE_UNAVAILABLE") {
|
|
818
|
-
send({ error: { code, message: errText } });
|
|
901
|
+
case "session.linkNative": {
|
|
902
|
+
const p = (params || {});
|
|
903
|
+
if (typeof p.id !== "string" || typeof p.nativeId !== "string" || p.nativeId.length === 0) {
|
|
904
|
+
send({ error: { code: "INVALID", message: "id and nativeId required" } });
|
|
819
905
|
return;
|
|
820
906
|
}
|
|
821
|
-
|
|
822
|
-
|
|
907
|
+
const session = this.sessions.get(p.id);
|
|
908
|
+
if (!session) {
|
|
909
|
+
sendSessionNotFound(p.id);
|
|
910
|
+
return;
|
|
823
911
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
case "session.stop": {
|
|
834
|
-
const p = params;
|
|
835
|
-
const s = this.sessions.get(p.id);
|
|
836
|
-
if (!s) {
|
|
837
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
838
|
-
return;
|
|
839
|
-
}
|
|
840
|
-
// Stop cancels a queued message on every exit (explicit cancel),
|
|
841
|
-
// including the BUSY/NOT_RUNNING/UNSAFE early returns below.
|
|
842
|
-
this.clearPending(s.id);
|
|
843
|
-
const driver = this.registry.get(s.agent);
|
|
844
|
-
if (this.sessionLocks.has(s.id)) {
|
|
845
|
-
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
const handle = driver.getHandle?.(s.id);
|
|
849
|
-
const hasRuntime = handle !== undefined;
|
|
850
|
-
const liveIdentity = s.pid != null &&
|
|
851
|
-
s.pidStartTime != null &&
|
|
852
|
-
processAlive(s.pid) &&
|
|
853
|
-
processStartTime(s.pid) === s.pidStartTime;
|
|
854
|
-
if (isTerminalStatus(s.status) && !hasRuntime && !liveIdentity) {
|
|
855
|
-
send({ error: { code: "SESSION_NOT_RUNNING", message: `Session ${s.id} is ${s.status}` } });
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
if (s.pid != null && !hasRuntime && !s.pidStartTime) {
|
|
859
|
-
send({ error: { code: "STOP_UNSAFE", message: `Cannot safely stop session ${s.id}: PID identity is unavailable` } });
|
|
860
|
-
return;
|
|
912
|
+
if (session.origin !== "open") {
|
|
913
|
+
send({ result: { created: false } });
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const { created, previous } = this.nativeLinks.link(session.id, p.nativeId);
|
|
917
|
+
if (previous.length > 0)
|
|
918
|
+
await this.reconcileOpenUsageSafely(session.id, previous);
|
|
919
|
+
send({ result: { created } });
|
|
920
|
+
break;
|
|
861
921
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
// Mark active sessions first so an attached event loop that ends
|
|
868
|
-
// during stop cannot synthesize a failure or overwrite the outcome.
|
|
869
|
-
if (!hadTerminalStatus)
|
|
870
|
-
this.sessions.setStatus(s.id, "stopped");
|
|
871
|
-
await driver.stop({
|
|
872
|
-
id: s.id,
|
|
873
|
-
nativeSessionId: s.nativeSessionId,
|
|
874
|
-
cwd: s.worktree || s.cwd,
|
|
875
|
-
pid: s.pid,
|
|
876
|
-
pidStartTime: s.pidStartTime,
|
|
877
|
-
});
|
|
878
|
-
if (this.shuttingDown) {
|
|
879
|
-
send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
|
|
922
|
+
case "session.release": {
|
|
923
|
+
const p = params;
|
|
924
|
+
const s = this.sessions.get(p.id);
|
|
925
|
+
if (!s) {
|
|
926
|
+
sendSessionNotFound(p.id);
|
|
880
927
|
return;
|
|
881
928
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
929
|
+
// Release owns the terminal outcome: a queued message must not
|
|
930
|
+
// survive as a phantom "na fila" on every exit, including the
|
|
931
|
+
// terminal early return below.
|
|
932
|
+
this.clearPending(s.id);
|
|
933
|
+
// Interrupted is an archival terminal: a power-shutdown row stays
|
|
934
|
+
// actionable and release may finalize it to completed/failed — unless
|
|
935
|
+
// its process is still alive under the same identity, which keeps
|
|
936
|
+
// the send path's stop-first guard instead of orphaning it.
|
|
937
|
+
if (s.status === "interrupted" && livePidIdentity(s)) {
|
|
938
|
+
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (isTerminalStatus(s.status) && s.status !== "interrupted") {
|
|
942
|
+
send({ result: { session: this.sessions.get(s.id) } });
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const targetStatus = p.status === "failed" ? "failed" : "completed";
|
|
946
|
+
const extra = {};
|
|
947
|
+
if (p.nativeSessionId !== undefined) {
|
|
948
|
+
extra.nativeSessionId = p.nativeSessionId;
|
|
949
|
+
}
|
|
950
|
+
if (targetStatus === "failed") {
|
|
951
|
+
extra.lastEvent = (p.error || "failed").slice(0, 200);
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
extra.lastEvent = "released";
|
|
955
|
+
}
|
|
956
|
+
this.sessions.setStatus(s.id, targetStatus, extra);
|
|
957
|
+
if (s.origin === "open" && s.agent === "claude") {
|
|
958
|
+
if (typeof p.nativeSessionId === "string" && p.nativeSessionId.length > 0) {
|
|
959
|
+
this.nativeLinks.link(s.id, p.nativeSessionId);
|
|
891
960
|
}
|
|
961
|
+
const reconciled = await this.reconcileOpenUsageSafely(s.id);
|
|
962
|
+
if (reconciled)
|
|
963
|
+
this.queueReleasedTranscriptCursorCleanup(s.id);
|
|
892
964
|
}
|
|
893
|
-
|
|
894
|
-
|
|
965
|
+
const ev = targetStatus === "failed"
|
|
966
|
+
? {
|
|
967
|
+
type: "session.failed",
|
|
968
|
+
sessionId: s.id,
|
|
969
|
+
timestamp: new Date().toISOString(),
|
|
970
|
+
error: p.error || "session failed",
|
|
971
|
+
}
|
|
972
|
+
: {
|
|
895
973
|
type: "session.completed",
|
|
896
974
|
sessionId: s.id,
|
|
897
975
|
timestamp: new Date().toISOString(),
|
|
898
|
-
reason: "
|
|
899
|
-
exitCode: 130,
|
|
976
|
+
reason: "released",
|
|
900
977
|
};
|
|
901
|
-
|
|
902
|
-
|
|
978
|
+
this.emit(s.id, ev);
|
|
979
|
+
const updated = this.sessions.get(s.id);
|
|
980
|
+
send({ result: { session: updated } });
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
case "session.list": {
|
|
984
|
+
const p = params;
|
|
985
|
+
const all = p?.all;
|
|
986
|
+
const list = this.sessions.list(100, all).map(withLiveStatus);
|
|
987
|
+
const hidden = all ? 0 : this.sessions.countHiddenByWindow();
|
|
988
|
+
send({ result: { sessions: list, hidden } });
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
case "session.get": {
|
|
992
|
+
const p = params;
|
|
993
|
+
const stored = this.sessions.get(p.id);
|
|
994
|
+
if (!stored) {
|
|
995
|
+
sendSessionNotFound(p.id);
|
|
996
|
+
return;
|
|
903
997
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
this.
|
|
907
|
-
send({ result: {
|
|
998
|
+
const s = withLiveStatus(stored);
|
|
999
|
+
const evCount = this.events.count(s.id);
|
|
1000
|
+
const recent = this.events.list(s.id, 10);
|
|
1001
|
+
send({ result: { session: s, events: recent, eventCount: evCount } });
|
|
1002
|
+
break;
|
|
908
1003
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1004
|
+
case "session.rename": {
|
|
1005
|
+
const p = params;
|
|
1006
|
+
const s = this.sessions.get(p.id);
|
|
1007
|
+
if (!s) {
|
|
1008
|
+
sendSessionNotFound(p.id);
|
|
912
1009
|
return;
|
|
913
1010
|
}
|
|
914
|
-
|
|
915
|
-
|
|
1011
|
+
if (!p.name || !p.name.trim()) {
|
|
1012
|
+
send({ error: { code: "INVALID", message: "name required" } });
|
|
1013
|
+
return;
|
|
916
1014
|
}
|
|
917
|
-
|
|
918
|
-
send({
|
|
919
|
-
|
|
920
|
-
finally {
|
|
921
|
-
if (!this.shuttingDown)
|
|
922
|
-
this.sessionLocks.delete(s.id);
|
|
923
|
-
}
|
|
924
|
-
break;
|
|
925
|
-
}
|
|
926
|
-
case "session.logs": {
|
|
927
|
-
const p = params;
|
|
928
|
-
const s = this.sessions.get(p.id);
|
|
929
|
-
if (!s) {
|
|
930
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
if (p.follow) {
|
|
934
|
-
// For follow, we upgrade to subscribe behavior? But spec says logs --follow streams
|
|
935
|
-
// We'll handle by keeping socket open and streaming
|
|
936
|
-
// Send existing events as result then keep streaming live
|
|
937
|
-
const all = this.events.list(s.id, 1000);
|
|
938
|
-
send({ result: { session: s, events: all } });
|
|
939
|
-
// Then subscribe
|
|
940
|
-
this.addSubscriber(s.id, socket);
|
|
941
|
-
// Don't close socket
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
else {
|
|
945
|
-
const all = this.events.list(s.id, 1000);
|
|
946
|
-
send({ result: { session: s, events: all } });
|
|
947
|
-
}
|
|
948
|
-
break;
|
|
949
|
-
}
|
|
950
|
-
case "session.subscribe": {
|
|
951
|
-
const p = params;
|
|
952
|
-
const s = this.sessions.get(p.id);
|
|
953
|
-
if (!s) {
|
|
954
|
-
send({ error: { code: "SESSION_NOT_FOUND", message: `Session ${p.id} not found` } });
|
|
955
|
-
return;
|
|
1015
|
+
this.sessions.update(s.id, { name: p.name });
|
|
1016
|
+
send({ result: { ok: true } });
|
|
1017
|
+
break;
|
|
956
1018
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1019
|
+
case "session.send": {
|
|
1020
|
+
const p = params;
|
|
1021
|
+
const validated = validateSendMessage(p.message);
|
|
1022
|
+
if ("code" in validated) {
|
|
1023
|
+
send({ error: { code: validated.code, message: validated.error } });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const message = validated.message;
|
|
1027
|
+
const s = this.sessions.get(p.id);
|
|
1028
|
+
if (!s) {
|
|
1029
|
+
sendSessionNotFound(p.id);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (s.origin === "open") {
|
|
1033
|
+
send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} is an interactive head session and does not support headless prompt turns` } });
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const driver = this.registry.get(s.agent);
|
|
1037
|
+
if (!driver.capabilities().resume) {
|
|
1038
|
+
send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (s.status === "interrupted" && !s.nativeSessionId) {
|
|
1042
|
+
send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
// Power-interrupted sessions with a live process keep the old rule:
|
|
1046
|
+
// stop first, never queue behind a running harness.
|
|
1047
|
+
if (s.status === "interrupted" && livePidIdentity(s)) {
|
|
1048
|
+
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
// A lifecycle operation owns the session: retry instead of queueing,
|
|
1052
|
+
// so a send racing stop cannot strand a phantom slot with no stream
|
|
1053
|
+
// tail left to consume it (stop cancels the queue on exit).
|
|
1054
|
+
if (this.sessionLocks.has(s.id)) {
|
|
1055
|
+
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
// Do not start a second harness while the current one is still live:
|
|
1059
|
+
// both runtimes would tail the same per-session file and duplicate
|
|
1060
|
+
// every event from the follow-up turn. Queue behind a live turn only.
|
|
1061
|
+
const busyHandle = driver.getHandle?.(s.id);
|
|
1062
|
+
const busyState = busyHandle && typeof busyHandle === "object"
|
|
1063
|
+
? busyHandle
|
|
1064
|
+
: undefined;
|
|
1065
|
+
const runtimeBusy = busyHandle !== undefined && (busyState?.done !== true || busyState?.drained !== true);
|
|
1066
|
+
const busy = s.status === "starting" || runtimeBusy || livePidIdentity(s);
|
|
1067
|
+
if (busy) {
|
|
1068
|
+
// One-slot queue, last-wins: persist without holding the lifecycle
|
|
1069
|
+
// lock, then converge — the turn may have ended between the busy
|
|
1070
|
+
// read and this persist, in which case tryDispatch starts now.
|
|
1071
|
+
const pendingAt = new Date().toISOString();
|
|
1072
|
+
try {
|
|
1073
|
+
this.sessions.update(s.id, { pendingMessage: message, pendingAt });
|
|
1074
|
+
}
|
|
1075
|
+
catch (e) {
|
|
1076
|
+
send({ error: { code: "SEND_FAILED", message: errorMessage(e) } });
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const queuedEvent = {
|
|
1080
|
+
type: "message.queued",
|
|
1081
|
+
sessionId: s.id,
|
|
1082
|
+
timestamp: pendingAt,
|
|
1083
|
+
prompt: message,
|
|
1084
|
+
pendingAt,
|
|
1085
|
+
};
|
|
1086
|
+
try {
|
|
1087
|
+
this.events.append(s.id, queuedEvent);
|
|
1088
|
+
}
|
|
1089
|
+
catch { }
|
|
1090
|
+
this.broadcast(s.id, queuedEvent);
|
|
963
1091
|
try {
|
|
964
|
-
|
|
1092
|
+
this.sessions.update(s.id, { lastEvent: `queued: ${message.slice(0, 80)}` });
|
|
965
1093
|
}
|
|
966
1094
|
catch { }
|
|
1095
|
+
void this.tryDispatch(s.id);
|
|
1096
|
+
send({ result: { ok: true, queued: true } });
|
|
1097
|
+
return;
|
|
967
1098
|
}
|
|
1099
|
+
// Resumable now (terminal completed/failed/stopped/orphaned, or
|
|
1100
|
+
// interrupted without a live process): a stale queued slot loses to
|
|
1101
|
+
// the new message, which starts immediately.
|
|
1102
|
+
this.sessionLocks.add(s.id);
|
|
968
1103
|
try {
|
|
969
|
-
|
|
1104
|
+
try {
|
|
1105
|
+
this.sessions.update(s.id, { pendingMessage: null, pendingAt: null });
|
|
1106
|
+
}
|
|
1107
|
+
catch { }
|
|
1108
|
+
await this.runResumeTurn(s, message);
|
|
1109
|
+
send({ result: { ok: true, queued: false } });
|
|
970
1110
|
}
|
|
971
|
-
catch {
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
const diff = await getDiff({ cwd: s.cwd, worktree: s.worktree, baseCommit: s.baseCommit, repository: s.repository });
|
|
994
|
-
send({ result: diff });
|
|
995
|
-
break;
|
|
996
|
-
}
|
|
997
|
-
case "claims.add": {
|
|
998
|
-
const p = params;
|
|
999
|
-
try {
|
|
1000
|
-
const claim = this.claims.add(p.sessionId, p.pathGlob, p.reason);
|
|
1001
|
-
send({ result: { claim } });
|
|
1111
|
+
catch (e) {
|
|
1112
|
+
const errText = errorMessage(e);
|
|
1113
|
+
const code = e instanceof Error && e.code === "SERVICE_UNAVAILABLE"
|
|
1114
|
+
? "SERVICE_UNAVAILABLE"
|
|
1115
|
+
: this.shuttingDown ? "SERVICE_UNAVAILABLE" : "SEND_FAILED";
|
|
1116
|
+
if (code === "SERVICE_UNAVAILABLE") {
|
|
1117
|
+
send({ error: { code, message: errText } });
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
this.sessions.setStatus(s.id, "failed");
|
|
1122
|
+
}
|
|
1123
|
+
catch { }
|
|
1124
|
+
send({ error: { code: "SEND_FAILED", message: errText } });
|
|
1125
|
+
}
|
|
1126
|
+
finally {
|
|
1127
|
+
if (!this.shuttingDown)
|
|
1128
|
+
this.sessionLocks.delete(s.id);
|
|
1129
|
+
}
|
|
1130
|
+
break;
|
|
1002
1131
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1132
|
+
case "session.stop": {
|
|
1133
|
+
const p = params;
|
|
1134
|
+
const s = this.sessions.get(p.id);
|
|
1135
|
+
if (!s) {
|
|
1136
|
+
sendSessionNotFound(p.id);
|
|
1006
1137
|
return;
|
|
1007
1138
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
try {
|
|
1015
|
-
const claims = this.claims.query(p.sessionId, p.path);
|
|
1016
|
-
send({ result: { claims } });
|
|
1017
|
-
}
|
|
1018
|
-
catch (error) {
|
|
1019
|
-
if (error instanceof RunAgentError) {
|
|
1020
|
-
send({ error: { code: error.code, message: error.message, details: error.details } });
|
|
1139
|
+
// Stop cancels a queued message on every exit (explicit cancel),
|
|
1140
|
+
// including the BUSY/NOT_RUNNING/UNSAFE early returns below.
|
|
1141
|
+
this.clearPending(s.id);
|
|
1142
|
+
const driver = this.registry.get(s.agent);
|
|
1143
|
+
if (this.sessionLocks.has(s.id)) {
|
|
1144
|
+
send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
|
|
1021
1145
|
return;
|
|
1022
1146
|
}
|
|
1023
|
-
|
|
1147
|
+
const handle = driver.getHandle?.(s.id);
|
|
1148
|
+
const hasRuntime = handle !== undefined;
|
|
1149
|
+
const liveIdentity = livePidIdentity(s);
|
|
1150
|
+
if (isTerminalStatus(s.status) && !hasRuntime && !liveIdentity) {
|
|
1151
|
+
send({ error: { code: "SESSION_NOT_RUNNING", message: `Session ${s.id} is ${s.status}` } });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
if (s.pid != null && !hasRuntime && !s.pidStartTime) {
|
|
1155
|
+
send({ error: { code: "STOP_UNSAFE", message: `Cannot safely stop session ${s.id}: PID identity is unavailable` } });
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this.sessionLocks.add(s.id);
|
|
1159
|
+
const previousStatus = s.status;
|
|
1160
|
+
const hadTerminalStatus = isTerminalStatus(s.status);
|
|
1161
|
+
const eventsBeforeStop = this.events.count(s.id);
|
|
1162
|
+
try {
|
|
1163
|
+
// Mark active sessions first so an attached event loop that ends
|
|
1164
|
+
// during stop cannot synthesize a failure or overwrite the outcome.
|
|
1165
|
+
if (!hadTerminalStatus)
|
|
1166
|
+
this.sessions.setStatus(s.id, "stopped");
|
|
1167
|
+
await driver.stop({
|
|
1168
|
+
id: s.id,
|
|
1169
|
+
nativeSessionId: s.nativeSessionId,
|
|
1170
|
+
cwd: s.worktree || s.cwd,
|
|
1171
|
+
pid: s.pid,
|
|
1172
|
+
pidStartTime: s.pidStartTime,
|
|
1173
|
+
});
|
|
1174
|
+
if (this.shuttingDown) {
|
|
1175
|
+
sendShuttingDown();
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
const last = this.events.last(s.id);
|
|
1179
|
+
const terminalArrivedDuringStop = this.events.count(s.id) > eventsBeforeStop &&
|
|
1180
|
+
(last?.type === "session.completed" || last?.type === "session.failed");
|
|
1181
|
+
if (terminalArrivedDuringStop) {
|
|
1182
|
+
if (last.type === "session.completed") {
|
|
1183
|
+
this.sessions.setStatus(s.id, "completed", { lastEvent: last.reason || "completed" });
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
this.sessions.setStatus(s.id, "failed", { lastEvent: last.error.slice(0, 200), failure: last.failure });
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
else if (!hadTerminalStatus) {
|
|
1190
|
+
const ev = {
|
|
1191
|
+
type: "session.completed",
|
|
1192
|
+
sessionId: s.id,
|
|
1193
|
+
timestamp: new Date().toISOString(),
|
|
1194
|
+
reason: "stopped",
|
|
1195
|
+
exitCode: 130,
|
|
1196
|
+
};
|
|
1197
|
+
this.emit(s.id, ev);
|
|
1198
|
+
}
|
|
1199
|
+
// A send may have persisted a slot after the entry clear (it saw a
|
|
1200
|
+
// live turn before this stop took the lock): stop wins, clear again.
|
|
1201
|
+
this.clearPending(s.id);
|
|
1202
|
+
send({ result: { ok: true } });
|
|
1203
|
+
}
|
|
1204
|
+
catch (e) {
|
|
1205
|
+
if (this.shuttingDown) {
|
|
1206
|
+
sendShuttingDown();
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
try {
|
|
1210
|
+
this.sessions.setStatus(s.id, previousStatus);
|
|
1211
|
+
}
|
|
1212
|
+
catch { }
|
|
1213
|
+
send({ error: { code: "STOP_FAILED", message: errorMessage(e) } });
|
|
1214
|
+
}
|
|
1215
|
+
finally {
|
|
1216
|
+
if (!this.shuttingDown)
|
|
1217
|
+
this.sessionLocks.delete(s.id);
|
|
1218
|
+
}
|
|
1219
|
+
break;
|
|
1024
1220
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1221
|
+
case "session.logs": {
|
|
1222
|
+
const p = params;
|
|
1223
|
+
const s = this.sessions.get(p.id);
|
|
1224
|
+
if (!s) {
|
|
1225
|
+
sendSessionNotFound(p.id);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
if (p.follow) {
|
|
1229
|
+
// For follow, we upgrade to subscribe behavior? But spec says logs --follow streams
|
|
1230
|
+
// We'll handle by keeping socket open and streaming
|
|
1231
|
+
// Send existing events as result then keep streaming live
|
|
1232
|
+
const all = this.events.list(s.id, 1000);
|
|
1233
|
+
send({ result: { session: s, events: all } });
|
|
1234
|
+
// Then subscribe
|
|
1235
|
+
this.addSubscriber(s.id, socket);
|
|
1236
|
+
// Don't close socket
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
const all = this.events.list(s.id, 1000);
|
|
1241
|
+
send({ result: { session: s, events: all } });
|
|
1242
|
+
}
|
|
1243
|
+
break;
|
|
1032
1244
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1245
|
+
case "session.subscribe": {
|
|
1246
|
+
const p = params;
|
|
1247
|
+
const s = this.sessions.get(p.id);
|
|
1248
|
+
if (!s) {
|
|
1249
|
+
sendSessionNotFound(p.id);
|
|
1036
1250
|
return;
|
|
1037
1251
|
}
|
|
1038
|
-
|
|
1252
|
+
// A late subscriber must not wait forever for a broadcast that already
|
|
1253
|
+
// happened. Replay the terminal event when available, then close the
|
|
1254
|
+
// stream just like a live terminal broadcast.
|
|
1255
|
+
if (isTerminalStatus(s.status)) {
|
|
1256
|
+
const last = this.events.last(s.id);
|
|
1257
|
+
if (last?.type === "session.completed" || last?.type === "session.failed") {
|
|
1258
|
+
try {
|
|
1259
|
+
socket.write(JSON.stringify({ type: "event", event: last, id: s.id }) + "\n");
|
|
1260
|
+
}
|
|
1261
|
+
catch { }
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
socket.write(JSON.stringify({ type: "done", id: s.id }) + "\n");
|
|
1265
|
+
}
|
|
1266
|
+
catch { }
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
this.addSubscriber(s.id, socket);
|
|
1270
|
+
return;
|
|
1039
1271
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
let gitInstalled = false;
|
|
1051
|
-
let gitVersion;
|
|
1052
|
-
try {
|
|
1053
|
-
const { execFile } = await import("node:child_process");
|
|
1054
|
-
const { promisify } = await import("node:util");
|
|
1055
|
-
const execFileAsync = promisify(execFile);
|
|
1056
|
-
const { stdout } = await execFileAsync("git", ["--version"], { timeout: 3000 });
|
|
1057
|
-
gitInstalled = true;
|
|
1058
|
-
gitVersion = stdout.trim();
|
|
1272
|
+
case "session.diff": {
|
|
1273
|
+
const p = params;
|
|
1274
|
+
const s = this.sessions.get(p.id);
|
|
1275
|
+
if (!s) {
|
|
1276
|
+
sendSessionNotFound(p.id);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const diff = await getDiff({ cwd: s.cwd, worktree: s.worktree, baseCommit: s.baseCommit, repository: s.repository });
|
|
1280
|
+
send({ result: diff });
|
|
1281
|
+
break;
|
|
1059
1282
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1283
|
+
case "claims.add": {
|
|
1284
|
+
const p = params;
|
|
1285
|
+
this.handleClaimRequest(p.sessionId, (sessionId) => this.claims.add(sessionId, p.pathGlob, p.reason), (claim) => ({ result: { claim } }), send);
|
|
1286
|
+
break;
|
|
1062
1287
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1288
|
+
case "claims.query": {
|
|
1289
|
+
const p = params;
|
|
1290
|
+
this.handleClaimRequest(p.sessionId, (sessionId) => this.claims.query(sessionId, p.path), (claims) => ({ result: { claims } }), send);
|
|
1291
|
+
break;
|
|
1067
1292
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1293
|
+
case "claims.release": {
|
|
1294
|
+
const p = params;
|
|
1295
|
+
this.handleClaimRequest(p.sessionId, (sessionId) => this.claims.release(sessionId, p.claimId), (claim) => ({ result: { claim } }), send);
|
|
1296
|
+
break;
|
|
1071
1297
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1298
|
+
case "doctor": {
|
|
1299
|
+
const nodeVersion = process.version;
|
|
1300
|
+
let gitInstalled = false;
|
|
1301
|
+
let gitVersion;
|
|
1302
|
+
try {
|
|
1303
|
+
const { execFile } = await import("node:child_process");
|
|
1304
|
+
const { promisify } = await import("node:util");
|
|
1305
|
+
const execFileAsync = promisify(execFile);
|
|
1306
|
+
const { stdout } = await execFileAsync("git", ["--version"], { timeout: 3000 });
|
|
1307
|
+
gitInstalled = true;
|
|
1308
|
+
gitVersion = stdout.trim();
|
|
1309
|
+
}
|
|
1310
|
+
catch {
|
|
1311
|
+
gitInstalled = false;
|
|
1312
|
+
}
|
|
1313
|
+
const agents = await this.registry.detectAll();
|
|
1314
|
+
const caps = {};
|
|
1315
|
+
for (const [k, drv] of this.registry.drivers.entries()) {
|
|
1316
|
+
caps[k] = drv.capabilities();
|
|
1317
|
+
}
|
|
1318
|
+
const agentsWithCaps = {};
|
|
1319
|
+
for (const [k, v] of Object.entries(agents)) {
|
|
1320
|
+
agentsWithCaps[k] = { ...v, capabilities: caps[k] };
|
|
1321
|
+
}
|
|
1322
|
+
const paths = getPaths();
|
|
1323
|
+
const dbExists = fs.existsSync(paths.db);
|
|
1324
|
+
send({
|
|
1325
|
+
result: {
|
|
1326
|
+
node: { version: nodeVersion },
|
|
1327
|
+
git: { installed: gitInstalled, version: gitVersion },
|
|
1328
|
+
agents: agentsWithCaps,
|
|
1329
|
+
daemon: { running: true, pid: process.pid, uptime: Date.now() - this.startTime },
|
|
1330
|
+
database: { path: paths.db, exists: dbExists },
|
|
1331
|
+
// Power readiness from the daemon's view (field names fixed by
|
|
1332
|
+
// cross-worker contract; CLI falls back to local detection).
|
|
1333
|
+
power: {
|
|
1334
|
+
serviceInstalled: powerServiceInstalled(),
|
|
1335
|
+
},
|
|
1086
1336
|
},
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
break;
|
|
1090
|
-
}
|
|
1091
|
-
case "models.list": {
|
|
1092
|
-
const p = (params || {});
|
|
1093
|
-
if (p.agent && !this.registry.has(p.agent)) {
|
|
1094
|
-
send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${p.agent}` } });
|
|
1095
|
-
return;
|
|
1337
|
+
});
|
|
1338
|
+
break;
|
|
1096
1339
|
}
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
send({
|
|
1105
|
-
|
|
1340
|
+
case "models.list": {
|
|
1341
|
+
const p = (params || {});
|
|
1342
|
+
if (p.agent && !this.registry.has(p.agent)) {
|
|
1343
|
+
send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${p.agent}` } });
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const agents = await this.fetchModels(p.agent, p.refresh);
|
|
1347
|
+
send({ result: { agents } });
|
|
1348
|
+
break;
|
|
1106
1349
|
}
|
|
1107
|
-
|
|
1108
|
-
const
|
|
1109
|
-
if (typeof
|
|
1110
|
-
|
|
1111
|
-
typeof costUsd !== "number" ||
|
|
1112
|
-
!Number.isFinite(costUsd) ||
|
|
1113
|
-
costUsd < 0) {
|
|
1114
|
-
send({ error: { code: "INVALID", message: "observe requires nativeId and a finite non-negative costUsd" } });
|
|
1350
|
+
case "usage.get": {
|
|
1351
|
+
const p = (params || {});
|
|
1352
|
+
if (typeof p.runId !== "string" || p.runId.length === 0) {
|
|
1353
|
+
send({ error: { code: "INVALID", message: "runId required" } });
|
|
1115
1354
|
return;
|
|
1116
1355
|
}
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1356
|
+
const runId = p.runId;
|
|
1357
|
+
const resolvedRunId = this.sessions.getByRunId(runId).length > 0
|
|
1358
|
+
? runId
|
|
1359
|
+
: this.sessions.get(runId)?.id ?? runId;
|
|
1360
|
+
let observation;
|
|
1361
|
+
if (p.observe !== undefined) {
|
|
1362
|
+
const raw = p.observe;
|
|
1363
|
+
const nativeId = typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
|
1364
|
+
? raw.nativeId
|
|
1365
|
+
: undefined;
|
|
1366
|
+
const costUsd = typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
|
1367
|
+
? raw.costUsd
|
|
1368
|
+
: undefined;
|
|
1369
|
+
if (typeof nativeId !== "string" ||
|
|
1370
|
+
nativeId.length === 0 ||
|
|
1371
|
+
typeof costUsd !== "number" ||
|
|
1372
|
+
!Number.isFinite(costUsd) ||
|
|
1373
|
+
costUsd < 0) {
|
|
1374
|
+
send({ error: { code: "INVALID", message: "observe requires nativeId and a finite non-negative costUsd" } });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
observation = { nativeId, costUsd };
|
|
1378
|
+
}
|
|
1379
|
+
const session = this.sessions.getByRunId(resolvedRunId)
|
|
1380
|
+
.find((candidate) => candidate.id === resolvedRunId && candidate.origin === "open");
|
|
1381
|
+
const earlierNativeIds = new Set();
|
|
1382
|
+
if (session && observation) {
|
|
1120
1383
|
const db = this.db.getHandle();
|
|
1121
1384
|
db.exec("BEGIN");
|
|
1122
|
-
let previous = [];
|
|
1123
1385
|
try {
|
|
1124
|
-
previous = this.nativeLinks.link(session.id, nativeId)
|
|
1125
|
-
|
|
1386
|
+
const { previous } = this.nativeLinks.link(session.id, observation.nativeId);
|
|
1387
|
+
previous.forEach((nativeId) => earlierNativeIds.add(nativeId));
|
|
1388
|
+
this.usageLedger.observe(session.id, openSourceKey(observation.nativeId), { cost: observation.costUsd });
|
|
1126
1389
|
db.exec("COMMIT");
|
|
1127
1390
|
}
|
|
1128
1391
|
catch (error) {
|
|
@@ -1132,35 +1395,74 @@ class Daemon {
|
|
|
1132
1395
|
catch { }
|
|
1133
1396
|
throw error;
|
|
1134
1397
|
}
|
|
1135
|
-
if (previous.length > 0)
|
|
1136
|
-
await this.reconcileOpenUsageSafely(session.id, previous);
|
|
1137
1398
|
}
|
|
1399
|
+
const rawTranscript = p.transcript;
|
|
1400
|
+
const transcript = typeof rawTranscript === "object" && rawTranscript !== null && !Array.isArray(rawTranscript)
|
|
1401
|
+
? rawTranscript
|
|
1402
|
+
: undefined;
|
|
1403
|
+
if (session &&
|
|
1404
|
+
session.agent === "claude" &&
|
|
1405
|
+
isActiveStatus(session.status) &&
|
|
1406
|
+
transcript &&
|
|
1407
|
+
typeof transcript.nativeId === "string" &&
|
|
1408
|
+
transcript.nativeId.length > 0 &&
|
|
1409
|
+
typeof transcript.path === "string" &&
|
|
1410
|
+
transcript.path.length > 0 &&
|
|
1411
|
+
(!observation || observation.nativeId === transcript.nativeId)) {
|
|
1412
|
+
const previous = await this.ingestLiveTranscript(session.id, transcript.nativeId, transcript.path);
|
|
1413
|
+
previous.forEach((nativeId) => earlierNativeIds.add(nativeId));
|
|
1414
|
+
}
|
|
1415
|
+
const sessions = this.sessions.getByRunId(resolvedRunId);
|
|
1416
|
+
const sessionIds = sessions.map((session) => session.id);
|
|
1417
|
+
const attributions = this.usageLedger.attributionsFor(sessionIds);
|
|
1418
|
+
const linkStates = this.nativeLinks.linksFor(sessionIds).map(({ sessionId, state }) => ({ sessionId, state }));
|
|
1419
|
+
send({ result: aggregateRunUsage(resolvedRunId, sessions, attributions, linkStates) });
|
|
1420
|
+
if (session && earlierNativeIds.size > 0) {
|
|
1421
|
+
this.queueOpenUsageReconciliation(session.id, [...earlierNativeIds]);
|
|
1422
|
+
}
|
|
1423
|
+
break;
|
|
1138
1424
|
}
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const result = this.sessions.queryUsage(p);
|
|
1150
|
-
send({ result });
|
|
1425
|
+
case "usage.query": {
|
|
1426
|
+
const p = (params || {});
|
|
1427
|
+
try {
|
|
1428
|
+
const result = this.sessions.queryUsage(p);
|
|
1429
|
+
send({ result });
|
|
1430
|
+
}
|
|
1431
|
+
catch (error) {
|
|
1432
|
+
send({ error: { code: "USAGE_QUERY_ERROR", message: errorMessage(error) } });
|
|
1433
|
+
}
|
|
1434
|
+
break;
|
|
1151
1435
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1436
|
+
case "web.ensure": {
|
|
1437
|
+
const p = (params || {});
|
|
1438
|
+
try {
|
|
1439
|
+
send({ result: await this.webHost().ensure(p) });
|
|
1440
|
+
}
|
|
1441
|
+
catch (error) {
|
|
1442
|
+
if (error instanceof WebEnsureError) {
|
|
1443
|
+
send({ error: { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) } });
|
|
1444
|
+
}
|
|
1445
|
+
else {
|
|
1446
|
+
send({ error: { code: "WEB_START_FAILED", message: errorMessage(error) } });
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
break;
|
|
1154
1450
|
}
|
|
1155
|
-
|
|
1451
|
+
case "daemon.stop": {
|
|
1452
|
+
send({ result: { ok: true } });
|
|
1453
|
+
void this.handleShutdown("daemon.stop").finally(() => process.exit(0));
|
|
1454
|
+
break;
|
|
1455
|
+
}
|
|
1456
|
+
default:
|
|
1457
|
+
send({ error: { code: "UNKNOWN_METHOD", message: `Unknown method ${method}` } });
|
|
1156
1458
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1459
|
+
}
|
|
1460
|
+
catch (error) {
|
|
1461
|
+
if (error instanceof AmbiguousSessionIdError) {
|
|
1462
|
+
send({ error: { code: error.code, message: error.message } });
|
|
1463
|
+
return;
|
|
1161
1464
|
}
|
|
1162
|
-
|
|
1163
|
-
send({ error: { code: "UNKNOWN_METHOD", message: `Unknown method ${method}` } });
|
|
1465
|
+
throw error;
|
|
1164
1466
|
}
|
|
1165
1467
|
}
|
|
1166
1468
|
addSubscriber(sessionId, socket) {
|
|
@@ -1197,6 +1499,30 @@ class Daemon {
|
|
|
1197
1499
|
}
|
|
1198
1500
|
}
|
|
1199
1501
|
}
|
|
1502
|
+
emit(sessionId, event) {
|
|
1503
|
+
this.events.append(sessionId, event);
|
|
1504
|
+
this.broadcast(sessionId, event);
|
|
1505
|
+
}
|
|
1506
|
+
webHost() {
|
|
1507
|
+
this.web ??= new WebSupervisor({ log: appendDaemonLog, spawnChild: this.spawnWebChild });
|
|
1508
|
+
return this.web;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Start the web console with the daemon so its address answers without a
|
|
1512
|
+
* prior web command. Not awaited: IPC never waits on the web stack.
|
|
1513
|
+
*/
|
|
1514
|
+
autostartWeb() {
|
|
1515
|
+
const config = loadConfig();
|
|
1516
|
+
const { port, invalid } = resolveWebPort(config);
|
|
1517
|
+
const { host, invalid: invalidHost } = resolveWebHost(config);
|
|
1518
|
+
if (invalid !== undefined)
|
|
1519
|
+
appendDaemonLog(invalidWebPortMessage(invalid));
|
|
1520
|
+
if (invalidHost !== undefined)
|
|
1521
|
+
appendDaemonLog(invalidWebHostMessage(invalidHost));
|
|
1522
|
+
this.webHost()
|
|
1523
|
+
.ensure({ preferredPort: port, preferredHost: host })
|
|
1524
|
+
.catch((error) => appendDaemonLog(`web autostart failed: ${errorMessage(error)}`));
|
|
1525
|
+
}
|
|
1200
1526
|
async startDriverForSession(sessionId, prompt, model) {
|
|
1201
1527
|
if (this.shuttingDown)
|
|
1202
1528
|
return;
|
|
@@ -1210,8 +1536,7 @@ class Daemon {
|
|
|
1210
1536
|
return;
|
|
1211
1537
|
this.sessions.update(sessionId, { status: "working" });
|
|
1212
1538
|
const turnEvent = { type: "turn.started", sessionId, timestamp: new Date().toISOString(), prompt };
|
|
1213
|
-
this.
|
|
1214
|
-
this.broadcast(sessionId, turnEvent);
|
|
1539
|
+
this.emit(sessionId, turnEvent);
|
|
1215
1540
|
const drvSession = await driver.start({
|
|
1216
1541
|
sessionId,
|
|
1217
1542
|
prompt,
|
|
@@ -1278,10 +1603,10 @@ class Daemon {
|
|
|
1278
1603
|
catch { }
|
|
1279
1604
|
}
|
|
1280
1605
|
/**
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1606
|
+
* Starts a resumed turn; caller MUST hold sessionLocks for the session.
|
|
1607
|
+
* Shared by session.send (immediate) and tryDispatch (queued) so both
|
|
1608
|
+
* paths emit the same frames in the same order.
|
|
1609
|
+
*/
|
|
1285
1610
|
async runResumeTurn(s, message) {
|
|
1286
1611
|
const driver = this.registry.get(s.agent);
|
|
1287
1612
|
if (this.shuttingDown) {
|
|
@@ -1296,8 +1621,7 @@ class Daemon {
|
|
|
1296
1621
|
timestamp: new Date().toISOString(),
|
|
1297
1622
|
prompt: message,
|
|
1298
1623
|
};
|
|
1299
|
-
this.
|
|
1300
|
-
this.broadcast(s.id, turnEvent);
|
|
1624
|
+
this.emit(s.id, turnEvent);
|
|
1301
1625
|
const drvSession = {
|
|
1302
1626
|
id: s.id,
|
|
1303
1627
|
runId: s.runId ?? undefined,
|
|
@@ -1402,7 +1726,7 @@ class Daemon {
|
|
|
1402
1726
|
return true;
|
|
1403
1727
|
}
|
|
1404
1728
|
catch (e) {
|
|
1405
|
-
const errText =
|
|
1729
|
+
const errText = errorMessage(e);
|
|
1406
1730
|
const unavailable = e?.code === "SERVICE_UNAVAILABLE" || this.shuttingDown;
|
|
1407
1731
|
if (!unavailable) {
|
|
1408
1732
|
const failure = classifyFailure(errText);
|
|
@@ -1438,19 +1762,21 @@ class Daemon {
|
|
|
1438
1762
|
this.sessionLocks.delete(sessionId);
|
|
1439
1763
|
}
|
|
1440
1764
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1765
|
+
// Commits one event with its cursor. A busy store is the daemon's problem,
|
|
1766
|
+
// not the session's: the harness keeps running and its output stays in the
|
|
1767
|
+
// log file, so retry the same event until the store frees up instead of
|
|
1768
|
+
// failing a live session. Returns undefined when shutdown interrupts the
|
|
1769
|
+
// wait (the drain owns the outcome); any other error propagates.
|
|
1770
|
+
async persistDriverEvent(sessionId, driver, ev) {
|
|
1771
|
+
const db = this.db.getHandle();
|
|
1772
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1773
|
+
try {
|
|
1774
|
+
// IMMEDIATE takes the write lock up front, so busy_timeout applies.
|
|
1775
|
+
// A deferred BEGIN read first and then got SQLITE_BUSY_SNAPSHOT on
|
|
1776
|
+
// the upgrade, which no busy handler retries.
|
|
1777
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1452
1778
|
try {
|
|
1453
|
-
inserted = this.events.append(sessionId, ev, ev.raw);
|
|
1779
|
+
const inserted = this.events.append(sessionId, ev, ev.raw);
|
|
1454
1780
|
const offsets = driver.getOffsets?.(sessionId);
|
|
1455
1781
|
if (offsets) {
|
|
1456
1782
|
this.sessions.update(sessionId, { logOffset: offsets.log, stderrOffset: offsets.stderr });
|
|
@@ -1460,6 +1786,7 @@ class Daemon {
|
|
|
1460
1786
|
this.updateSessionFromEvent(sessionId, ev, inserted);
|
|
1461
1787
|
}
|
|
1462
1788
|
db.exec("COMMIT");
|
|
1789
|
+
return inserted;
|
|
1463
1790
|
}
|
|
1464
1791
|
catch (error) {
|
|
1465
1792
|
try {
|
|
@@ -1468,6 +1795,29 @@ class Daemon {
|
|
|
1468
1795
|
catch { }
|
|
1469
1796
|
throw error;
|
|
1470
1797
|
}
|
|
1798
|
+
}
|
|
1799
|
+
catch (error) {
|
|
1800
|
+
if (!isStoreBusy(error))
|
|
1801
|
+
throw error;
|
|
1802
|
+
if (this.shuttingDown)
|
|
1803
|
+
return undefined;
|
|
1804
|
+
if (attempt === 0)
|
|
1805
|
+
appendDaemonLog(`store busy persisting ${ev.type} for ${sessionId}; retrying`);
|
|
1806
|
+
await sleep(Math.min(STORE_BUSY_RETRY_BASE_MS * 2 ** attempt, STORE_BUSY_RETRY_MAX_MS));
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
async attachDriverEvents(sessionId, driver, drvSession) {
|
|
1811
|
+
try {
|
|
1812
|
+
for await (const ev of driver.events(drvSession)) {
|
|
1813
|
+
// A stop operation owns the terminal outcome. A terminal frame already
|
|
1814
|
+
// buffered by the harness must not race it into the event log.
|
|
1815
|
+
if (this.sessionLocks.has(sessionId) && (ev.type === "session.completed" || ev.type === "session.failed")) {
|
|
1816
|
+
continue;
|
|
1817
|
+
}
|
|
1818
|
+
const inserted = await this.persistDriverEvent(sessionId, driver, ev);
|
|
1819
|
+
if (inserted === undefined)
|
|
1820
|
+
return;
|
|
1471
1821
|
// Broadcast only after the durable event+cursor commit, and never
|
|
1472
1822
|
// broadcast a replay that was already in the event store.
|
|
1473
1823
|
if (inserted !== 0)
|
|
@@ -1479,7 +1829,7 @@ class Daemon {
|
|
|
1479
1829
|
return;
|
|
1480
1830
|
// A driver exception mid-stream is a harness/pipe failure, not task
|
|
1481
1831
|
// output — classify so agents can retry instead of blaming the work.
|
|
1482
|
-
const msg =
|
|
1832
|
+
const msg = errorMessage(e);
|
|
1483
1833
|
const failure = classifyFailure(msg);
|
|
1484
1834
|
const errEv = {
|
|
1485
1835
|
type: "session.failed",
|
|
@@ -1489,8 +1839,7 @@ class Daemon {
|
|
|
1489
1839
|
failure,
|
|
1490
1840
|
raw: e,
|
|
1491
1841
|
};
|
|
1492
|
-
this.
|
|
1493
|
-
this.broadcast(sessionId, errEv);
|
|
1842
|
+
this.emit(sessionId, errEv);
|
|
1494
1843
|
this.sessions.setStatus(sessionId, "failed", { lastEvent: msg.slice(0, 200), failure });
|
|
1495
1844
|
return;
|
|
1496
1845
|
}
|
|
@@ -1522,11 +1871,11 @@ class Daemon {
|
|
|
1522
1871
|
};
|
|
1523
1872
|
const error = sess.lastEvent || "event stream ended without a terminal event";
|
|
1524
1873
|
const errEv = { type: "session.failed", sessionId, timestamp: new Date().toISOString(), error, failure };
|
|
1525
|
-
this.
|
|
1526
|
-
this.broadcast(sessionId, errEv);
|
|
1874
|
+
this.emit(sessionId, errEv);
|
|
1527
1875
|
this.sessions.setStatus(sessionId, "failed", { lastEvent: error.slice(0, 200), failure });
|
|
1528
1876
|
}
|
|
1529
1877
|
}
|
|
1878
|
+
this.finishHeal(sessionId);
|
|
1530
1879
|
// A message queued while the turn ran starts now as the next turn.
|
|
1531
1880
|
// tryDispatch rechecks resumability under the lifecycle lock; a stale
|
|
1532
1881
|
// or unresumable slot stays put for a manual send.
|
|
@@ -1640,52 +1989,6 @@ class Daemon {
|
|
|
1640
1989
|
return;
|
|
1641
1990
|
void this.handleShutdown(reason).finally(() => process.exit(0));
|
|
1642
1991
|
}
|
|
1643
|
-
// Best-effort delay lock: while this child lives, systemd/logind delays
|
|
1644
|
-
// shutdown up to InhibitDelayMaxSec so the drain can finish. Silent no-op
|
|
1645
|
-
// when the binary is absent. The binary resolves through fixed absolute
|
|
1646
|
-
// paths only, never PATH (typescript:S4036). Public so tests can drive it
|
|
1647
|
-
// without start() (no socket binding); binOverride is the test seam for
|
|
1648
|
-
// a fake binary.
|
|
1649
|
-
maybeSpawnInhibit(binOverride) {
|
|
1650
|
-
if (this.inhibitChild)
|
|
1651
|
-
return;
|
|
1652
|
-
const bin = binOverride ?? resolveInhibitBin();
|
|
1653
|
-
if (!bin)
|
|
1654
|
-
return;
|
|
1655
|
-
// Absent binary (removed between resolve and spawn, or a bogus test
|
|
1656
|
-
// seam path) is a silent no-op: spawn(2) failure would only surface
|
|
1657
|
-
// as an async error event, leaving a dead child behind.
|
|
1658
|
-
try {
|
|
1659
|
-
if (!fs.existsSync(bin))
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
catch {
|
|
1663
|
-
return;
|
|
1664
|
-
}
|
|
1665
|
-
try {
|
|
1666
|
-
const child = spawn(bin, ["--what=shutdown:sleep", "--who=CodeDeck", "--why=flush sessions", "--mode=delay", "sleep", "infinity"], { stdio: "ignore", detached: true });
|
|
1667
|
-
child.on("error", () => { });
|
|
1668
|
-
child.unref();
|
|
1669
|
-
this.inhibitChild = child;
|
|
1670
|
-
if (!this.inhibitExitHookInstalled) {
|
|
1671
|
-
this.inhibitExitHookInstalled = true;
|
|
1672
|
-
process.once("exit", () => this.killInhibitChild());
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
catch {
|
|
1676
|
-
this.inhibitChild = null;
|
|
1677
|
-
}
|
|
1678
|
-
}
|
|
1679
|
-
killInhibitChild() {
|
|
1680
|
-
const child = this.inhibitChild;
|
|
1681
|
-
this.inhibitChild = null;
|
|
1682
|
-
if (!child)
|
|
1683
|
-
return;
|
|
1684
|
-
try {
|
|
1685
|
-
child.kill();
|
|
1686
|
-
}
|
|
1687
|
-
catch { }
|
|
1688
|
-
}
|
|
1689
1992
|
// Graceful power shutdown. Exposed as a method so tests can drive the
|
|
1690
1993
|
// drain without signals/systemd: exactly-once (concurrent callers share
|
|
1691
1994
|
// one drain), never throws, never exits — callers decide on exit.
|
|
@@ -1697,6 +2000,11 @@ class Daemon {
|
|
|
1697
2000
|
return this.shutdownPromise;
|
|
1698
2001
|
}
|
|
1699
2002
|
async runShutdown(reason) {
|
|
2003
|
+
// SIGTERM only; the web child holds no state worth waiting for.
|
|
2004
|
+
try {
|
|
2005
|
+
this.web?.close();
|
|
2006
|
+
}
|
|
2007
|
+
catch { }
|
|
1700
2008
|
const handle = this.db.getHandle();
|
|
1701
2009
|
// Never close mid-transaction: roll back a stale BEGIN (best-effort)
|
|
1702
2010
|
// BEFORE persisting, so the interrupted writes below stay durable.
|
|
@@ -1728,27 +2036,23 @@ class Daemon {
|
|
|
1728
2036
|
handle.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1729
2037
|
}
|
|
1730
2038
|
catch { }
|
|
1731
|
-
|
|
1732
|
-
this.killInhibitChild();
|
|
1733
|
-
try {
|
|
1734
|
-
const paths = getPaths();
|
|
1735
|
-
try {
|
|
1736
|
-
fs.unlinkSync(paths.daemonSock);
|
|
1737
|
-
}
|
|
1738
|
-
catch { }
|
|
2039
|
+
if (this.boundPaths) {
|
|
1739
2040
|
try {
|
|
1740
|
-
fs.unlinkSync(
|
|
2041
|
+
fs.unlinkSync(this.boundPaths.sock);
|
|
1741
2042
|
}
|
|
1742
2043
|
catch { }
|
|
1743
2044
|
try {
|
|
1744
|
-
this.
|
|
1745
|
-
}
|
|
1746
|
-
catch { }
|
|
1747
|
-
try {
|
|
1748
|
-
this.db.close();
|
|
2045
|
+
fs.unlinkSync(this.boundPaths.pid);
|
|
1749
2046
|
}
|
|
1750
2047
|
catch { }
|
|
1751
2048
|
}
|
|
2049
|
+
try {
|
|
2050
|
+
this.server?.close();
|
|
2051
|
+
}
|
|
2052
|
+
catch { }
|
|
2053
|
+
try {
|
|
2054
|
+
this.db.close();
|
|
2055
|
+
}
|
|
1752
2056
|
catch { }
|
|
1753
2057
|
}
|
|
1754
2058
|
safeListActive() {
|
|
@@ -1791,7 +2095,7 @@ class Daemon {
|
|
|
1791
2095
|
async markInterrupted(s, reason) {
|
|
1792
2096
|
try {
|
|
1793
2097
|
// Serialize against an in-flight send/stop owner (bounded: the drain
|
|
1794
|
-
// must fit
|
|
2098
|
+
// must fit before systemd's SIGKILL), then hold the lock so attached
|
|
1795
2099
|
// event loops discard terminal frames instead of overwriting
|
|
1796
2100
|
// `interrupted` (see attachDriverEvents).
|
|
1797
2101
|
const deadline = Date.now() + 500;
|
|
@@ -1814,17 +2118,27 @@ class Daemon {
|
|
|
1814
2118
|
failure,
|
|
1815
2119
|
raw: { reason },
|
|
1816
2120
|
};
|
|
1817
|
-
this.
|
|
1818
|
-
this.broadcast(s.id, event);
|
|
2121
|
+
this.emit(s.id, event);
|
|
1819
2122
|
this.sessions.setStatus(s.id, "interrupted", { lastEvent: detail.slice(0, 200), failure });
|
|
1820
2123
|
}
|
|
1821
2124
|
catch { }
|
|
1822
2125
|
}
|
|
1823
2126
|
}
|
|
2127
|
+
// Held for the process lifetime (instance-lock keeps the connection alive
|
|
2128
|
+
// against GC); the kernel drops it on exit, so shutdown does not release it.
|
|
2129
|
+
let instanceLock = null;
|
|
1824
2130
|
// Entry
|
|
1825
2131
|
if (process.argv.includes("--daemon")) {
|
|
2132
|
+
// Take the lock before opening run-agent.db: a losing daemon must not
|
|
2133
|
+
// migrate, recover or bind the socket. Exit 0 so the spawning CLI keeps
|
|
2134
|
+
// polling and connects to the daemon that already runs.
|
|
2135
|
+
instanceLock = acquireInstanceLock(getPaths().daemonLock);
|
|
2136
|
+
if (!instanceLock) {
|
|
2137
|
+
appendDaemonLog(`pid ${process.pid}: another daemon holds ${getPaths().daemonLock}; exiting`);
|
|
2138
|
+
process.exit(0);
|
|
2139
|
+
}
|
|
1826
2140
|
const d = new Daemon();
|
|
1827
|
-
d.start().
|
|
2141
|
+
d.start().then(() => d.autostartWeb(), (e) => {
|
|
1828
2142
|
console.error("[daemon] failed to start", e);
|
|
1829
2143
|
process.exit(1);
|
|
1830
2144
|
});
|