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