@orgloop/agentctl 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code.d.ts +3 -1
- package/dist/adapters/claude-code.js +65 -3
- package/dist/adapters/codex.d.ts +3 -1
- package/dist/adapters/codex.js +48 -3
- package/dist/adapters/openclaw.d.ts +3 -1
- package/dist/adapters/openclaw.js +61 -4
- package/dist/adapters/opencode.d.ts +3 -1
- package/dist/adapters/opencode.js +70 -3
- package/dist/adapters/pi-rust.d.ts +3 -1
- package/dist/adapters/pi-rust.js +87 -3
- package/dist/adapters/pi.d.ts +3 -1
- package/dist/adapters/pi.js +51 -3
- package/dist/cli.js +110 -97
- package/dist/core/types.d.ts +26 -2
- package/dist/daemon/fuse-engine.d.ts +13 -10
- package/dist/daemon/fuse-engine.js +69 -46
- package/dist/daemon/metrics.d.ts +2 -3
- package/dist/daemon/metrics.js +4 -7
- package/dist/daemon/server.js +136 -21
- package/dist/daemon/session-tracker.d.ts +13 -0
- package/dist/daemon/session-tracker.js +102 -10
- package/dist/daemon/state.d.ts +12 -2
- package/dist/hooks.d.ts +1 -1
- package/dist/utils/daemon-env.d.ts +16 -0
- package/dist/utils/daemon-env.js +85 -0
- package/dist/utils/resolve-binary.d.ts +14 -0
- package/dist/utils/resolve-binary.js +66 -0
- package/package.json +1 -1
- package/dist/merge.d.ts +0 -24
- package/dist/merge.js +0 -65
package/dist/adapters/pi.js
CHANGED
|
@@ -5,6 +5,8 @@ import * as fs from "node:fs/promises";
|
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
|
+
import { buildSpawnEnv } from "../utils/daemon-env.js";
|
|
9
|
+
import { resolveBinaryPath } from "../utils/resolve-binary.js";
|
|
8
10
|
const execFileAsync = promisify(execFile);
|
|
9
11
|
const DEFAULT_PI_DIR = path.join(os.homedir(), ".pi");
|
|
10
12
|
// Default: only show stopped sessions from the last 7 days
|
|
@@ -31,6 +33,44 @@ export class PiAdapter {
|
|
|
31
33
|
this.getPids = opts?.getPids || getPiPids;
|
|
32
34
|
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
33
35
|
}
|
|
36
|
+
async discover() {
|
|
37
|
+
const runningPids = await this.getPids();
|
|
38
|
+
const discovered = await this.discoverSessions();
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const disc of discovered) {
|
|
41
|
+
const isRunning = await this.isSessionRunning(disc, runningPids);
|
|
42
|
+
const { model, tokens, cost } = await this.parseSessionTail(disc.filePath, disc.header);
|
|
43
|
+
results.push({
|
|
44
|
+
id: disc.sessionId,
|
|
45
|
+
status: isRunning ? "running" : "stopped",
|
|
46
|
+
adapter: this.id,
|
|
47
|
+
cwd: disc.header.cwd,
|
|
48
|
+
model,
|
|
49
|
+
startedAt: disc.created,
|
|
50
|
+
stoppedAt: isRunning ? undefined : disc.modified,
|
|
51
|
+
pid: isRunning
|
|
52
|
+
? await this.findMatchingPid(disc, runningPids)
|
|
53
|
+
: undefined,
|
|
54
|
+
prompt: await this.getFirstPrompt(disc.filePath),
|
|
55
|
+
tokens,
|
|
56
|
+
cost,
|
|
57
|
+
nativeMetadata: {
|
|
58
|
+
provider: disc.header.provider,
|
|
59
|
+
thinkingLevel: disc.header.thinkingLevel,
|
|
60
|
+
version: disc.header.version,
|
|
61
|
+
cwdSlug: disc.cwdSlug,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
async isAlive(sessionId) {
|
|
68
|
+
const runningPids = await this.getPids();
|
|
69
|
+
const disc = await this.findSession(sessionId);
|
|
70
|
+
if (!disc)
|
|
71
|
+
return false;
|
|
72
|
+
return this.isSessionRunning(disc, runningPids);
|
|
73
|
+
}
|
|
34
74
|
async list(opts) {
|
|
35
75
|
const runningPids = await this.getPids();
|
|
36
76
|
const discovered = await this.discoverSessions();
|
|
@@ -163,18 +203,22 @@ export class PiAdapter {
|
|
|
163
203
|
if (opts.model) {
|
|
164
204
|
args.unshift("--model", opts.model);
|
|
165
205
|
}
|
|
166
|
-
const env =
|
|
206
|
+
const env = buildSpawnEnv(undefined, opts.env);
|
|
167
207
|
const cwd = opts.cwd || process.cwd();
|
|
168
208
|
// Write stdout to a log file so we can extract the session ID
|
|
169
209
|
await fs.mkdir(this.sessionsMetaDir, { recursive: true });
|
|
170
210
|
const logPath = path.join(this.sessionsMetaDir, `launch-${Date.now()}.log`);
|
|
171
211
|
const logFd = await fs.open(logPath, "w");
|
|
172
|
-
const
|
|
212
|
+
const piPath = await resolveBinaryPath("pi");
|
|
213
|
+
const child = spawn(piPath, args, {
|
|
173
214
|
cwd,
|
|
174
215
|
env,
|
|
175
216
|
stdio: ["ignore", logFd.fd, logFd.fd],
|
|
176
217
|
detached: true,
|
|
177
218
|
});
|
|
219
|
+
child.on("error", (err) => {
|
|
220
|
+
console.error(`[pi] spawn error: ${err.message}`);
|
|
221
|
+
});
|
|
178
222
|
// Fully detach: child runs in its own process group.
|
|
179
223
|
child.unref();
|
|
180
224
|
const pid = child.pid;
|
|
@@ -331,11 +375,15 @@ export class PiAdapter {
|
|
|
331
375
|
// Launch a new pi session in the same cwd with the continuation message.
|
|
332
376
|
const disc = await this.findSession(sessionId);
|
|
333
377
|
const cwd = disc?.header.cwd || process.cwd();
|
|
334
|
-
const
|
|
378
|
+
const piPath = await resolveBinaryPath("pi");
|
|
379
|
+
const child = spawn(piPath, ["-p", message], {
|
|
335
380
|
cwd,
|
|
336
381
|
stdio: ["pipe", "pipe", "pipe"],
|
|
337
382
|
detached: true,
|
|
338
383
|
});
|
|
384
|
+
child.on("error", (err) => {
|
|
385
|
+
console.error(`[pi] resume spawn error: ${err.message}`);
|
|
386
|
+
});
|
|
339
387
|
child.unref();
|
|
340
388
|
}
|
|
341
389
|
async *events() {
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,6 @@ import { DaemonClient } from "./client/daemon-client.js";
|
|
|
18
18
|
import { runHook } from "./hooks.js";
|
|
19
19
|
import { orchestrateLaunch, parseAdapterSlots, } from "./launch-orchestrator.js";
|
|
20
20
|
import { expandMatrix, parseMatrixFile } from "./matrix-parser.js";
|
|
21
|
-
import { mergeSession } from "./merge.js";
|
|
22
21
|
import { createWorktree } from "./worktree.js";
|
|
23
22
|
const adapters = {
|
|
24
23
|
"claude-code": new ClaudeCodeAdapter(),
|
|
@@ -84,6 +83,7 @@ function formatSession(s, showGroup) {
|
|
|
84
83
|
const row = {
|
|
85
84
|
ID: s.id.slice(0, 8),
|
|
86
85
|
Status: s.status,
|
|
86
|
+
Adapter: s.adapter || "-",
|
|
87
87
|
Model: s.model || "-",
|
|
88
88
|
};
|
|
89
89
|
if (showGroup)
|
|
@@ -94,10 +94,23 @@ function formatSession(s, showGroup) {
|
|
|
94
94
|
row.Prompt = (s.prompt || "-").slice(0, 60);
|
|
95
95
|
return row;
|
|
96
96
|
}
|
|
97
|
+
function formatDiscovered(s) {
|
|
98
|
+
return {
|
|
99
|
+
ID: s.id.slice(0, 8),
|
|
100
|
+
Adapter: s.adapter,
|
|
101
|
+
Status: s.status,
|
|
102
|
+
Model: s.model || "-",
|
|
103
|
+
CWD: s.cwd ? shortenPath(s.cwd) : "-",
|
|
104
|
+
PID: s.pid?.toString() || "-",
|
|
105
|
+
Started: s.startedAt ? timeAgo(s.startedAt) : "-",
|
|
106
|
+
Prompt: (s.prompt || "-").slice(0, 60),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
97
109
|
function formatRecord(s, showGroup) {
|
|
98
110
|
const row = {
|
|
99
111
|
ID: s.id.slice(0, 8),
|
|
100
112
|
Status: s.status,
|
|
113
|
+
Adapter: s.adapter || "-",
|
|
101
114
|
Model: s.model || "-",
|
|
102
115
|
};
|
|
103
116
|
if (showGroup)
|
|
@@ -176,6 +189,22 @@ function sessionToJson(s) {
|
|
|
176
189
|
meta: s.meta,
|
|
177
190
|
};
|
|
178
191
|
}
|
|
192
|
+
function discoveredToJson(s) {
|
|
193
|
+
return {
|
|
194
|
+
id: s.id,
|
|
195
|
+
adapter: s.adapter,
|
|
196
|
+
status: s.status,
|
|
197
|
+
startedAt: s.startedAt?.toISOString(),
|
|
198
|
+
stoppedAt: s.stoppedAt?.toISOString(),
|
|
199
|
+
cwd: s.cwd,
|
|
200
|
+
model: s.model,
|
|
201
|
+
prompt: s.prompt,
|
|
202
|
+
tokens: s.tokens,
|
|
203
|
+
cost: s.cost,
|
|
204
|
+
pid: s.pid,
|
|
205
|
+
nativeMetadata: s.nativeMetadata,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
179
208
|
// --- CLI ---
|
|
180
209
|
const program = new Command();
|
|
181
210
|
program
|
|
@@ -212,25 +241,40 @@ program
|
|
|
212
241
|
}
|
|
213
242
|
return;
|
|
214
243
|
}
|
|
215
|
-
// Direct fallback
|
|
216
|
-
|
|
217
|
-
let sessions = [];
|
|
244
|
+
// Direct fallback — discover-first
|
|
245
|
+
let discovered = [];
|
|
218
246
|
if (opts.adapter) {
|
|
219
247
|
const adapter = getAdapter(opts.adapter);
|
|
220
|
-
|
|
248
|
+
discovered = await adapter.discover();
|
|
221
249
|
}
|
|
222
250
|
else {
|
|
223
251
|
for (const adapter of getAllAdapters()) {
|
|
224
|
-
const
|
|
225
|
-
|
|
252
|
+
const d = await adapter.discover().catch(() => []);
|
|
253
|
+
discovered.push(...d);
|
|
226
254
|
}
|
|
227
255
|
}
|
|
256
|
+
// Apply status/all filters
|
|
257
|
+
if (opts.status) {
|
|
258
|
+
discovered = discovered.filter((s) => s.status === opts.status);
|
|
259
|
+
}
|
|
260
|
+
else if (!opts.all) {
|
|
261
|
+
discovered = discovered.filter((s) => s.status === "running");
|
|
262
|
+
}
|
|
263
|
+
// Sort: running first, then by most recent
|
|
264
|
+
discovered.sort((a, b) => {
|
|
265
|
+
if (a.status === "running" && b.status !== "running")
|
|
266
|
+
return -1;
|
|
267
|
+
if (b.status === "running" && a.status !== "running")
|
|
268
|
+
return 1;
|
|
269
|
+
const aTime = a.startedAt?.getTime() ?? 0;
|
|
270
|
+
const bTime = b.startedAt?.getTime() ?? 0;
|
|
271
|
+
return bTime - aTime;
|
|
272
|
+
});
|
|
228
273
|
if (opts.json) {
|
|
229
|
-
printJson(
|
|
274
|
+
printJson(discovered.map(discoveredToJson));
|
|
230
275
|
}
|
|
231
276
|
else {
|
|
232
|
-
|
|
233
|
-
printTable(sessions.map((s) => formatSession(s, hasGroups)));
|
|
277
|
+
printTable(discovered.map((s) => formatDiscovered(s)));
|
|
234
278
|
}
|
|
235
279
|
});
|
|
236
280
|
// status
|
|
@@ -418,18 +462,14 @@ program
|
|
|
418
462
|
.option("--matrix <file>", "YAML matrix file for advanced sweep launch")
|
|
419
463
|
.option("--on-create <script>", "Hook: run after session is created")
|
|
420
464
|
.option("--on-complete <script>", "Hook: run after session completes")
|
|
421
|
-
.option("--pre-merge <script>", "Hook: run before merge")
|
|
422
|
-
.option("--post-merge <script>", "Hook: run after merge")
|
|
423
465
|
.allowUnknownOption() // Allow interleaved --adapter/--model for parseAdapterSlots
|
|
424
466
|
.action(async (adapterName, opts) => {
|
|
425
467
|
let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
426
468
|
// Collect hooks
|
|
427
|
-
const hooks = opts.onCreate || opts.onComplete
|
|
469
|
+
const hooks = opts.onCreate || opts.onComplete
|
|
428
470
|
? {
|
|
429
471
|
onCreate: opts.onCreate,
|
|
430
472
|
onComplete: opts.onComplete,
|
|
431
|
-
preMerge: opts.preMerge,
|
|
432
|
-
postMerge: opts.postMerge,
|
|
433
473
|
}
|
|
434
474
|
: undefined;
|
|
435
475
|
// --- Multi-adapter / matrix detection ---
|
|
@@ -621,85 +661,6 @@ program
|
|
|
621
661
|
console.log(JSON.stringify(out));
|
|
622
662
|
}
|
|
623
663
|
});
|
|
624
|
-
// --- Merge command (FEAT-4) ---
|
|
625
|
-
program
|
|
626
|
-
.command("merge <id>")
|
|
627
|
-
.description("Commit, push, and open PR for a session's work")
|
|
628
|
-
.option("-m, --message <text>", "Commit message")
|
|
629
|
-
.option("--remove-worktree", "Remove worktree after merge")
|
|
630
|
-
.option("--repo <path>", "Main repo path (for worktree removal)")
|
|
631
|
-
.option("--pre-merge <script>", "Hook: run before merge")
|
|
632
|
-
.option("--post-merge <script>", "Hook: run after merge")
|
|
633
|
-
.action(async (id, opts) => {
|
|
634
|
-
// Find session
|
|
635
|
-
const daemonRunning = await ensureDaemon();
|
|
636
|
-
let sessionCwd;
|
|
637
|
-
let sessionAdapter;
|
|
638
|
-
if (daemonRunning) {
|
|
639
|
-
try {
|
|
640
|
-
const session = await client.call("session.status", {
|
|
641
|
-
id,
|
|
642
|
-
});
|
|
643
|
-
sessionCwd = session.cwd;
|
|
644
|
-
sessionAdapter = session.adapter;
|
|
645
|
-
}
|
|
646
|
-
catch {
|
|
647
|
-
// Fall through to adapter
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
if (!sessionCwd) {
|
|
651
|
-
const adapter = getAdapter();
|
|
652
|
-
try {
|
|
653
|
-
const session = await adapter.status(id);
|
|
654
|
-
sessionCwd = session.cwd;
|
|
655
|
-
sessionAdapter = session.adapter;
|
|
656
|
-
}
|
|
657
|
-
catch (err) {
|
|
658
|
-
console.error(`Session not found: ${err.message}`);
|
|
659
|
-
process.exit(1);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
if (!sessionCwd) {
|
|
663
|
-
console.error("Cannot determine session working directory");
|
|
664
|
-
process.exit(1);
|
|
665
|
-
}
|
|
666
|
-
const hooks = opts.preMerge || opts.postMerge
|
|
667
|
-
? { preMerge: opts.preMerge, postMerge: opts.postMerge }
|
|
668
|
-
: undefined;
|
|
669
|
-
// Pre-merge hook
|
|
670
|
-
if (hooks?.preMerge) {
|
|
671
|
-
await runHook(hooks, "preMerge", {
|
|
672
|
-
sessionId: id,
|
|
673
|
-
cwd: sessionCwd,
|
|
674
|
-
adapter: sessionAdapter || "claude-code",
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
const result = await mergeSession({
|
|
678
|
-
cwd: sessionCwd,
|
|
679
|
-
message: opts.message,
|
|
680
|
-
removeWorktree: opts.removeWorktree,
|
|
681
|
-
repoPath: opts.repo,
|
|
682
|
-
});
|
|
683
|
-
if (result.committed)
|
|
684
|
-
console.log("Changes committed");
|
|
685
|
-
if (result.pushed)
|
|
686
|
-
console.log("Pushed to remote");
|
|
687
|
-
if (result.prUrl)
|
|
688
|
-
console.log(`PR: ${result.prUrl}`);
|
|
689
|
-
if (result.worktreeRemoved)
|
|
690
|
-
console.log("Worktree removed");
|
|
691
|
-
if (!result.committed && !result.pushed) {
|
|
692
|
-
console.log("No changes to commit or push");
|
|
693
|
-
}
|
|
694
|
-
// Post-merge hook
|
|
695
|
-
if (hooks?.postMerge) {
|
|
696
|
-
await runHook(hooks, "postMerge", {
|
|
697
|
-
sessionId: id,
|
|
698
|
-
cwd: sessionCwd,
|
|
699
|
-
adapter: sessionAdapter || "claude-code",
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
664
|
// --- Worktree subcommand ---
|
|
704
665
|
const worktreeCmd = new Command("worktree").description("Manage agentctl-created worktrees");
|
|
705
666
|
worktreeCmd
|
|
@@ -844,7 +805,7 @@ program
|
|
|
844
805
|
// --- Fuses command ---
|
|
845
806
|
program
|
|
846
807
|
.command("fuses")
|
|
847
|
-
.description("List active
|
|
808
|
+
.description("List active fuse timers")
|
|
848
809
|
.option("--json", "Output as JSON")
|
|
849
810
|
.action(async (opts) => {
|
|
850
811
|
try {
|
|
@@ -859,7 +820,7 @@ program
|
|
|
859
820
|
}
|
|
860
821
|
printTable(fuses.map((f) => ({
|
|
861
822
|
Directory: shortenPath(f.directory),
|
|
862
|
-
|
|
823
|
+
Label: f.label || "-",
|
|
863
824
|
"Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
|
|
864
825
|
})));
|
|
865
826
|
}
|
|
@@ -868,6 +829,25 @@ program
|
|
|
868
829
|
process.exit(1);
|
|
869
830
|
}
|
|
870
831
|
});
|
|
832
|
+
// --- Prune command (#40) ---
|
|
833
|
+
program
|
|
834
|
+
.command("prune")
|
|
835
|
+
.description("Remove dead and stale sessions from daemon state")
|
|
836
|
+
.action(async () => {
|
|
837
|
+
const daemonRunning = await ensureDaemon();
|
|
838
|
+
if (!daemonRunning) {
|
|
839
|
+
console.error("Daemon not running. Start with: agentctl daemon start");
|
|
840
|
+
process.exit(1);
|
|
841
|
+
}
|
|
842
|
+
try {
|
|
843
|
+
const result = await client.call("session.prune");
|
|
844
|
+
console.log(`Pruned ${result.pruned} dead/stale sessions`);
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
console.error(err.message);
|
|
848
|
+
process.exit(1);
|
|
849
|
+
}
|
|
850
|
+
});
|
|
871
851
|
// --- Daemon subcommand ---
|
|
872
852
|
const daemonCmd = new Command("daemon").description("Manage the agentctl daemon");
|
|
873
853
|
daemonCmd
|
|
@@ -945,8 +925,9 @@ daemonCmd
|
|
|
945
925
|
});
|
|
946
926
|
daemonCmd
|
|
947
927
|
.command("status")
|
|
948
|
-
.description("Show daemon status")
|
|
928
|
+
.description("Show daemon status and all daemon-related processes")
|
|
949
929
|
.action(async () => {
|
|
930
|
+
// Show daemon status
|
|
950
931
|
try {
|
|
951
932
|
const status = await client.call("daemon.status");
|
|
952
933
|
console.log(`Daemon running (PID ${status.pid})`);
|
|
@@ -958,6 +939,38 @@ daemonCmd
|
|
|
958
939
|
catch {
|
|
959
940
|
console.log("Daemon not running");
|
|
960
941
|
}
|
|
942
|
+
// Show all daemon-related processes (#39)
|
|
943
|
+
const configDir = path.join(os.homedir(), ".agentctl");
|
|
944
|
+
const { getSupervisorPid } = await import("./daemon/supervisor.js");
|
|
945
|
+
const supPid = await getSupervisorPid();
|
|
946
|
+
let daemonPid = null;
|
|
947
|
+
try {
|
|
948
|
+
const raw = await fs.readFile(path.join(configDir, "agentctl.pid"), "utf-8");
|
|
949
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
950
|
+
try {
|
|
951
|
+
process.kill(pid, 0);
|
|
952
|
+
daemonPid = pid;
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// PID file is stale
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
// No PID file
|
|
960
|
+
}
|
|
961
|
+
console.log("\nDaemon-related processes:");
|
|
962
|
+
if (supPid) {
|
|
963
|
+
console.log(` Supervisor: PID ${supPid} (alive)`);
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
console.log(" Supervisor: not running");
|
|
967
|
+
}
|
|
968
|
+
if (daemonPid) {
|
|
969
|
+
console.log(` Daemon: PID ${daemonPid} (alive)`);
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
console.log(" Daemon: not running");
|
|
973
|
+
}
|
|
961
974
|
});
|
|
962
975
|
daemonCmd
|
|
963
976
|
.command("restart")
|
package/dist/core/types.d.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session discovered by an adapter's runtime — the ground truth for "what exists."
|
|
3
|
+
* This is the source of truth for session lifecycle in the discover-first model.
|
|
4
|
+
*/
|
|
5
|
+
export interface DiscoveredSession {
|
|
6
|
+
id: string;
|
|
7
|
+
status: "running" | "stopped";
|
|
8
|
+
adapter: string;
|
|
9
|
+
cwd?: string;
|
|
10
|
+
model?: string;
|
|
11
|
+
startedAt?: Date;
|
|
12
|
+
stoppedAt?: Date;
|
|
13
|
+
pid?: number;
|
|
14
|
+
prompt?: string;
|
|
15
|
+
tokens?: {
|
|
16
|
+
in: number;
|
|
17
|
+
out: number;
|
|
18
|
+
};
|
|
19
|
+
cost?: number;
|
|
20
|
+
/** Adapter-native fields — whatever the runtime provides */
|
|
21
|
+
nativeMetadata?: Record<string, unknown>;
|
|
22
|
+
}
|
|
1
23
|
export interface AgentAdapter {
|
|
2
24
|
id: string;
|
|
25
|
+
/** Find all sessions currently managed by this adapter's runtime. */
|
|
26
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
27
|
+
/** Check if a specific session is still alive. */
|
|
28
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
3
29
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
4
30
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
5
31
|
status(sessionId: string): Promise<AgentSession>;
|
|
@@ -64,6 +90,4 @@ export interface LaunchOpts {
|
|
|
64
90
|
export interface LifecycleHooks {
|
|
65
91
|
onCreate?: string;
|
|
66
92
|
onComplete?: string;
|
|
67
|
-
preMerge?: string;
|
|
68
|
-
postMerge?: string;
|
|
69
93
|
}
|
|
@@ -1,28 +1,31 @@
|
|
|
1
1
|
import type { EventEmitter } from "node:events";
|
|
2
|
-
import type { FuseTimer,
|
|
2
|
+
import type { FuseTimer, StateManager } from "./state.js";
|
|
3
3
|
export interface FuseEngineOpts {
|
|
4
4
|
defaultDurationMs: number;
|
|
5
5
|
emitter?: EventEmitter;
|
|
6
6
|
}
|
|
7
|
+
export interface SetFuseOpts {
|
|
8
|
+
directory: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
ttlMs?: number;
|
|
11
|
+
onExpire?: FuseTimer["onExpire"];
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
7
14
|
export declare class FuseEngine {
|
|
8
15
|
private timers;
|
|
9
16
|
private state;
|
|
10
17
|
private defaultDurationMs;
|
|
11
18
|
private emitter?;
|
|
12
19
|
constructor(state: StateManager, opts: FuseEngineOpts);
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} | null;
|
|
18
|
-
/** Called when a session exits. Starts fuse if applicable. */
|
|
19
|
-
onSessionExit(session: SessionRecord): void;
|
|
20
|
-
private startFuse;
|
|
20
|
+
/** Set a fuse for a directory. Called when a session exits or explicitly via API. */
|
|
21
|
+
setFuse(opts: SetFuseOpts): void;
|
|
22
|
+
/** Extend an existing fuse's TTL. Resets the timer to a new duration. */
|
|
23
|
+
extendFuse(directory: string, ttlMs?: number): boolean;
|
|
21
24
|
/** Cancel fuse for a directory. */
|
|
22
25
|
cancelFuse(directory: string, persist?: boolean): boolean;
|
|
23
26
|
/** Resume fuses from persisted state after daemon restart. */
|
|
24
27
|
resumeTimers(): void;
|
|
25
|
-
/** Fire a fuse —
|
|
28
|
+
/** Fire a fuse — execute the configured on-expire action. */
|
|
26
29
|
private fireFuse;
|
|
27
30
|
listActive(): FuseTimer[];
|
|
28
31
|
/** Clear all timers (for clean shutdown) */
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { exec } from "node:child_process";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
2
|
import { promisify } from "node:util";
|
|
5
3
|
const execAsync = promisify(exec);
|
|
6
4
|
export class FuseEngine {
|
|
@@ -13,43 +11,45 @@ export class FuseEngine {
|
|
|
13
11
|
this.defaultDurationMs = opts.defaultDurationMs;
|
|
14
12
|
this.emitter = opts.emitter;
|
|
15
13
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
/** Set a fuse for a directory. Called when a session exits or explicitly via API. */
|
|
15
|
+
setFuse(opts) {
|
|
16
|
+
const ttlMs = opts.ttlMs ?? this.defaultDurationMs;
|
|
17
|
+
// Cancel existing fuse for same directory
|
|
18
|
+
this.cancelFuse(opts.directory, false);
|
|
19
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
20
|
+
const fuse = {
|
|
21
|
+
directory: opts.directory,
|
|
22
|
+
ttlMs,
|
|
23
|
+
expiresAt: expiresAt.toISOString(),
|
|
24
|
+
sessionId: opts.sessionId,
|
|
25
|
+
onExpire: opts.onExpire,
|
|
26
|
+
label: opts.label,
|
|
28
27
|
};
|
|
28
|
+
this.state.addFuse(fuse);
|
|
29
|
+
const timeout = setTimeout(() => this.fireFuse(fuse), ttlMs);
|
|
30
|
+
this.timers.set(opts.directory, timeout);
|
|
31
|
+
this.emitter?.emit("fuse.set", fuse);
|
|
29
32
|
}
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (!
|
|
36
|
-
return;
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
startFuse(directory, clusterName, branch, sessionId) {
|
|
40
|
-
// Cancel existing fuse for same directory
|
|
33
|
+
/** Extend an existing fuse's TTL. Resets the timer to a new duration. */
|
|
34
|
+
extendFuse(directory, ttlMs) {
|
|
35
|
+
const existing = this.state
|
|
36
|
+
.getFuses()
|
|
37
|
+
.find((f) => f.directory === directory);
|
|
38
|
+
if (!existing)
|
|
39
|
+
return false;
|
|
40
|
+
const duration = ttlMs ?? existing.ttlMs;
|
|
41
41
|
this.cancelFuse(directory, false);
|
|
42
|
-
const expiresAt = new Date(Date.now() +
|
|
42
|
+
const expiresAt = new Date(Date.now() + duration);
|
|
43
43
|
const fuse = {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
branch,
|
|
44
|
+
...existing,
|
|
45
|
+
ttlMs: duration,
|
|
47
46
|
expiresAt: expiresAt.toISOString(),
|
|
48
|
-
sessionId,
|
|
49
47
|
};
|
|
50
48
|
this.state.addFuse(fuse);
|
|
51
|
-
const timeout = setTimeout(() => this.fireFuse(fuse),
|
|
49
|
+
const timeout = setTimeout(() => this.fireFuse(fuse), duration);
|
|
52
50
|
this.timers.set(directory, timeout);
|
|
51
|
+
this.emitter?.emit("fuse.extended", fuse);
|
|
52
|
+
return true;
|
|
53
53
|
}
|
|
54
54
|
/** Cancel fuse for a directory. */
|
|
55
55
|
cancelFuse(directory, persist = true) {
|
|
@@ -79,30 +79,53 @@ export class FuseEngine {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
/** Fire a fuse —
|
|
82
|
+
/** Fire a fuse — execute the configured on-expire action. */
|
|
83
83
|
async fireFuse(fuse) {
|
|
84
84
|
this.timers.delete(fuse.directory);
|
|
85
85
|
this.state.removeFuse(fuse.directory);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
const label = fuse.label || fuse.directory;
|
|
87
|
+
console.log(`Fuse expired: ${label}`);
|
|
88
|
+
this.emitter?.emit("fuse.expired", fuse);
|
|
89
|
+
const action = fuse.onExpire;
|
|
90
|
+
if (!action)
|
|
91
|
+
return;
|
|
92
|
+
// Execute on-expire script
|
|
93
|
+
if (action.script) {
|
|
89
94
|
try {
|
|
90
|
-
await execAsync(
|
|
95
|
+
await execAsync(action.script, {
|
|
91
96
|
cwd: fuse.directory,
|
|
92
|
-
timeout:
|
|
97
|
+
timeout: 120_000,
|
|
98
|
+
});
|
|
99
|
+
console.log(`Fuse action completed: ${label}`);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error(`Fuse action failed for ${label}:`, err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// POST to webhook
|
|
106
|
+
if (action.webhook) {
|
|
107
|
+
try {
|
|
108
|
+
const body = JSON.stringify({
|
|
109
|
+
type: "fuse.expired",
|
|
110
|
+
directory: fuse.directory,
|
|
111
|
+
sessionId: fuse.sessionId,
|
|
112
|
+
label: fuse.label,
|
|
113
|
+
expiredAt: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
await fetch(action.webhook, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body,
|
|
119
|
+
signal: AbortSignal.timeout(30_000),
|
|
93
120
|
});
|
|
94
121
|
}
|
|
95
|
-
catch {
|
|
96
|
-
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(`Fuse webhook failed for ${label}:`, err);
|
|
97
124
|
}
|
|
98
|
-
await execAsync(`kind delete cluster --name ${fuse.clusterName}`, {
|
|
99
|
-
timeout: 120_000,
|
|
100
|
-
});
|
|
101
|
-
console.log(`Cluster ${fuse.clusterName} deleted`);
|
|
102
|
-
this.emitter?.emit("fuse.fired", fuse);
|
|
103
125
|
}
|
|
104
|
-
|
|
105
|
-
|
|
126
|
+
// Emit named event
|
|
127
|
+
if (action.event) {
|
|
128
|
+
this.emitter?.emit(action.event, fuse);
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
listActive() {
|
package/dist/daemon/metrics.d.ts
CHANGED
|
@@ -8,13 +8,12 @@ export declare class MetricsRegistry {
|
|
|
8
8
|
sessionsTotalCompleted: number;
|
|
9
9
|
sessionsTotalFailed: number;
|
|
10
10
|
sessionsTotalStopped: number;
|
|
11
|
-
|
|
12
|
-
clustersDeletedTotal: number;
|
|
11
|
+
fusesExpiredTotal: number;
|
|
13
12
|
sessionDurations: number[];
|
|
14
13
|
constructor(sessionTracker: SessionTracker, lockManager: LockManager, fuseEngine: FuseEngine);
|
|
15
14
|
recordSessionCompleted(durationSeconds?: number): void;
|
|
16
15
|
recordSessionFailed(durationSeconds?: number): void;
|
|
17
16
|
recordSessionStopped(durationSeconds?: number): void;
|
|
18
|
-
|
|
17
|
+
recordFuseExpired(): void;
|
|
19
18
|
generateMetrics(): string;
|
|
20
19
|
}
|