@orgloop/agentctl 1.2.1 → 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 +51 -0
- package/dist/adapters/codex.d.ts +3 -1
- package/dist/adapters/codex.js +35 -0
- 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 +57 -0
- package/dist/adapters/pi-rust.d.ts +3 -1
- package/dist/adapters/pi-rust.js +74 -0
- package/dist/adapters/pi.d.ts +3 -1
- package/dist/adapters/pi.js +38 -0
- package/dist/cli.js +55 -96
- 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 +18 -4
- package/dist/daemon/session-tracker.js +29 -10
- package/dist/daemon/state.d.ts +12 -2
- package/dist/hooks.d.ts +1 -1
- package/package.json +1 -1
- package/dist/merge.d.ts +0 -24
- package/dist/merge.js +0 -65
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
2
|
export interface PidInfo {
|
|
3
3
|
pid: number;
|
|
4
4
|
cwd: string;
|
|
@@ -38,6 +38,8 @@ export declare class ClaudeCodeAdapter implements AgentAdapter {
|
|
|
38
38
|
private readonly getPids;
|
|
39
39
|
private readonly isProcessAlive;
|
|
40
40
|
constructor(opts?: ClaudeCodeAdapterOpts);
|
|
41
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
42
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
41
43
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
42
44
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
43
45
|
status(sessionId: string): Promise<AgentSession>;
|
|
@@ -32,6 +32,57 @@ export class ClaudeCodeAdapter {
|
|
|
32
32
|
this.getPids = opts?.getPids || getClaudePids;
|
|
33
33
|
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
34
34
|
}
|
|
35
|
+
async discover() {
|
|
36
|
+
const runningPids = await this.getPids();
|
|
37
|
+
const results = [];
|
|
38
|
+
let projectDirs;
|
|
39
|
+
try {
|
|
40
|
+
projectDirs = await fs.readdir(this.projectsDir);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
for (const projDir of projectDirs) {
|
|
46
|
+
const projPath = path.join(this.projectsDir, projDir);
|
|
47
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
48
|
+
if (!stat?.isDirectory())
|
|
49
|
+
continue;
|
|
50
|
+
const entries = await this.getEntriesForProject(projPath, projDir);
|
|
51
|
+
for (const { entry, index } of entries) {
|
|
52
|
+
if (entry.isSidechain)
|
|
53
|
+
continue;
|
|
54
|
+
const isRunning = await this.isSessionRunning(entry, index, runningPids);
|
|
55
|
+
const { model, tokens } = await this.parseSessionTail(entry.fullPath);
|
|
56
|
+
results.push({
|
|
57
|
+
id: entry.sessionId,
|
|
58
|
+
status: isRunning ? "running" : "stopped",
|
|
59
|
+
adapter: this.id,
|
|
60
|
+
cwd: index.originalPath || entry.projectPath,
|
|
61
|
+
model,
|
|
62
|
+
startedAt: new Date(entry.created),
|
|
63
|
+
stoppedAt: isRunning ? undefined : new Date(entry.modified),
|
|
64
|
+
pid: isRunning
|
|
65
|
+
? await this.findMatchingPid(entry, index, runningPids)
|
|
66
|
+
: undefined,
|
|
67
|
+
prompt: entry.firstPrompt?.slice(0, 200),
|
|
68
|
+
tokens,
|
|
69
|
+
nativeMetadata: {
|
|
70
|
+
projectDir: index.originalPath || entry.projectPath,
|
|
71
|
+
gitBranch: entry.gitBranch,
|
|
72
|
+
messageCount: entry.messageCount,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return results;
|
|
78
|
+
}
|
|
79
|
+
async isAlive(sessionId) {
|
|
80
|
+
const runningPids = await this.getPids();
|
|
81
|
+
const entry = await this.findIndexEntry(sessionId);
|
|
82
|
+
if (!entry)
|
|
83
|
+
return false;
|
|
84
|
+
return this.isSessionRunning(entry.entry, entry.index, runningPids);
|
|
85
|
+
}
|
|
35
86
|
async list(opts) {
|
|
36
87
|
const runningPids = await this.getPids();
|
|
37
88
|
const sessions = [];
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
2
|
export interface CodexPidInfo {
|
|
3
3
|
pid: number;
|
|
4
4
|
cwd: string;
|
|
@@ -34,6 +34,8 @@ export declare class CodexAdapter implements AgentAdapter {
|
|
|
34
34
|
private readonly getPids;
|
|
35
35
|
private readonly isProcessAlive;
|
|
36
36
|
constructor(opts?: CodexAdapterOpts);
|
|
37
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
38
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
37
39
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
38
40
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
39
41
|
status(sessionId: string): Promise<AgentSession>;
|
package/dist/adapters/codex.js
CHANGED
|
@@ -30,6 +30,41 @@ export class CodexAdapter {
|
|
|
30
30
|
this.getPids = opts?.getPids || getCodexPids;
|
|
31
31
|
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
32
32
|
}
|
|
33
|
+
async discover() {
|
|
34
|
+
const runningPids = await this.getPids();
|
|
35
|
+
const sessionInfos = await this.discoverSessions();
|
|
36
|
+
const results = [];
|
|
37
|
+
for (const info of sessionInfos) {
|
|
38
|
+
const isRunning = this.isSessionRunning(info, runningPids);
|
|
39
|
+
const pid = isRunning
|
|
40
|
+
? this.findMatchingPid(info, runningPids)
|
|
41
|
+
: undefined;
|
|
42
|
+
results.push({
|
|
43
|
+
id: info.id,
|
|
44
|
+
status: isRunning ? "running" : "stopped",
|
|
45
|
+
adapter: this.id,
|
|
46
|
+
cwd: info.cwd,
|
|
47
|
+
model: info.model,
|
|
48
|
+
startedAt: info.created,
|
|
49
|
+
stoppedAt: isRunning ? undefined : info.modified,
|
|
50
|
+
pid,
|
|
51
|
+
prompt: info.firstPrompt,
|
|
52
|
+
tokens: info.tokens,
|
|
53
|
+
nativeMetadata: {
|
|
54
|
+
cliVersion: info.cliVersion,
|
|
55
|
+
lastMessage: info.lastMessage,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
async isAlive(sessionId) {
|
|
62
|
+
const runningPids = await this.getPids();
|
|
63
|
+
const info = await this.findSession(sessionId);
|
|
64
|
+
if (!info)
|
|
65
|
+
return false;
|
|
66
|
+
return this.isSessionRunning(info, runningPids);
|
|
67
|
+
}
|
|
33
68
|
async list(opts) {
|
|
34
69
|
const runningPids = await this.getPids();
|
|
35
70
|
const sessionInfos = await this.discoverSessions();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
2
|
export interface DeviceIdentity {
|
|
3
3
|
deviceId: string;
|
|
4
4
|
publicKeyPem: string;
|
|
@@ -122,6 +122,8 @@ export declare class OpenClawAdapter implements AgentAdapter {
|
|
|
122
122
|
private readonly rpcCall;
|
|
123
123
|
constructor(opts?: OpenClawAdapterOpts);
|
|
124
124
|
private tryLoadDeviceIdentity;
|
|
125
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
126
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
125
127
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
126
128
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
127
129
|
status(sessionId: string): Promise<AgentSession>;
|
|
@@ -197,6 +197,62 @@ export class OpenClawAdapter {
|
|
|
197
197
|
return null;
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
|
+
async discover() {
|
|
201
|
+
if (!this.authToken)
|
|
202
|
+
return [];
|
|
203
|
+
let result;
|
|
204
|
+
try {
|
|
205
|
+
result = (await this.rpcCall("sessions.list", {
|
|
206
|
+
includeDerivedTitles: true,
|
|
207
|
+
includeLastMessage: true,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
return result.sessions.map((row) => {
|
|
214
|
+
const updatedAt = row.updatedAt ?? 0;
|
|
215
|
+
const model = row.model || result.defaults.model || undefined;
|
|
216
|
+
const input = row.inputTokens ?? 0;
|
|
217
|
+
const output = row.outputTokens ?? 0;
|
|
218
|
+
// Gateway is the source of truth: if a session is in the list, it's alive.
|
|
219
|
+
// Don't guess from timestamps — sessions can be idle for hours between messages.
|
|
220
|
+
return {
|
|
221
|
+
id: row.sessionId || row.key,
|
|
222
|
+
status: "running",
|
|
223
|
+
adapter: this.id,
|
|
224
|
+
model,
|
|
225
|
+
startedAt: updatedAt > 0 ? new Date(updatedAt) : new Date(),
|
|
226
|
+
prompt: row.derivedTitle || row.displayName || row.label,
|
|
227
|
+
tokens: input || output ? { in: input, out: output } : undefined,
|
|
228
|
+
nativeMetadata: {
|
|
229
|
+
key: row.key,
|
|
230
|
+
kind: row.kind,
|
|
231
|
+
channel: row.channel,
|
|
232
|
+
displayName: row.displayName,
|
|
233
|
+
modelProvider: row.modelProvider || result.defaults.modelProvider,
|
|
234
|
+
lastMessagePreview: row.lastMessagePreview,
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
async isAlive(sessionId) {
|
|
240
|
+
if (!this.authToken)
|
|
241
|
+
return false;
|
|
242
|
+
try {
|
|
243
|
+
const result = (await this.rpcCall("sessions.list", {
|
|
244
|
+
search: sessionId,
|
|
245
|
+
}));
|
|
246
|
+
// Gateway is the source of truth: if a session is in the list, it's alive.
|
|
247
|
+
return result.sessions.some((s) => s.sessionId === sessionId ||
|
|
248
|
+
s.key === sessionId ||
|
|
249
|
+
s.sessionId?.startsWith(sessionId) ||
|
|
250
|
+
s.key.startsWith(sessionId));
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
200
256
|
async list(opts) {
|
|
201
257
|
if (!this.authToken) {
|
|
202
258
|
console.warn("Warning: OPENCLAW_GATEWAY_TOKEN is not set — OpenClaw adapter cannot authenticate. " +
|
|
@@ -342,17 +398,18 @@ export class OpenClawAdapter {
|
|
|
342
398
|
}
|
|
343
399
|
// --- Private helpers ---
|
|
344
400
|
mapRowToSession(row, defaults) {
|
|
345
|
-
const now = Date.now();
|
|
346
401
|
const updatedAt = row.updatedAt ?? 0;
|
|
347
|
-
const ageMs = now - updatedAt;
|
|
348
|
-
|
|
402
|
+
const ageMs = Date.now() - updatedAt;
|
|
403
|
+
// Gateway is the source of truth: if a session is in the list, it's alive.
|
|
404
|
+
// Use "running" for recently active sessions, "idle" for quiet ones.
|
|
405
|
+
const status = updatedAt > 0 && ageMs < 5 * 60 * 1000 ? "running" : "idle";
|
|
349
406
|
const model = row.model || defaults.model || undefined;
|
|
350
407
|
const input = row.inputTokens ?? 0;
|
|
351
408
|
const output = row.outputTokens ?? 0;
|
|
352
409
|
return {
|
|
353
410
|
id: row.sessionId || row.key,
|
|
354
411
|
adapter: this.id,
|
|
355
|
-
status
|
|
412
|
+
status,
|
|
356
413
|
startedAt: updatedAt > 0 ? new Date(updatedAt) : new Date(),
|
|
357
414
|
cwd: undefined,
|
|
358
415
|
model,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
2
|
export interface PidInfo {
|
|
3
3
|
pid: number;
|
|
4
4
|
cwd: string;
|
|
@@ -95,6 +95,8 @@ export declare class OpenCodeAdapter implements AgentAdapter {
|
|
|
95
95
|
private readonly getPids;
|
|
96
96
|
private readonly isProcessAlive;
|
|
97
97
|
constructor(opts?: OpenCodeAdapterOpts);
|
|
98
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
99
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
98
100
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
99
101
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
100
102
|
status(sessionId: string): Promise<AgentSession>;
|
|
@@ -39,6 +39,63 @@ export class OpenCodeAdapter {
|
|
|
39
39
|
this.getPids = opts?.getPids || getOpenCodePids;
|
|
40
40
|
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
41
41
|
}
|
|
42
|
+
async discover() {
|
|
43
|
+
const runningPids = await this.getPids();
|
|
44
|
+
const results = [];
|
|
45
|
+
let projectDirs;
|
|
46
|
+
try {
|
|
47
|
+
projectDirs = await fs.readdir(this.sessionDir);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
for (const projHash of projectDirs) {
|
|
53
|
+
const projPath = path.join(this.sessionDir, projHash);
|
|
54
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
55
|
+
if (!stat?.isDirectory())
|
|
56
|
+
continue;
|
|
57
|
+
const sessionFiles = await this.getSessionFilesForProject(projPath);
|
|
58
|
+
for (const sessionData of sessionFiles) {
|
|
59
|
+
const isRunning = await this.isSessionRunning(sessionData, runningPids);
|
|
60
|
+
const { model, tokens, cost } = await this.aggregateMessageStats(sessionData.id);
|
|
61
|
+
const createdAt = sessionData.time?.created
|
|
62
|
+
? new Date(sessionData.time.created)
|
|
63
|
+
: new Date();
|
|
64
|
+
const updatedAt = sessionData.time?.updated
|
|
65
|
+
? new Date(sessionData.time.updated)
|
|
66
|
+
: undefined;
|
|
67
|
+
results.push({
|
|
68
|
+
id: sessionData.id,
|
|
69
|
+
status: isRunning ? "running" : "stopped",
|
|
70
|
+
adapter: this.id,
|
|
71
|
+
cwd: sessionData.directory,
|
|
72
|
+
model,
|
|
73
|
+
startedAt: createdAt,
|
|
74
|
+
stoppedAt: isRunning ? undefined : updatedAt,
|
|
75
|
+
pid: isRunning
|
|
76
|
+
? await this.findMatchingPid(sessionData, runningPids)
|
|
77
|
+
: undefined,
|
|
78
|
+
prompt: sessionData.title?.slice(0, 200),
|
|
79
|
+
tokens,
|
|
80
|
+
cost,
|
|
81
|
+
nativeMetadata: {
|
|
82
|
+
projectID: sessionData.projectID,
|
|
83
|
+
slug: sessionData.slug,
|
|
84
|
+
summary: sessionData.summary,
|
|
85
|
+
version: sessionData.version,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
}
|
|
92
|
+
async isAlive(sessionId) {
|
|
93
|
+
const runningPids = await this.getPids();
|
|
94
|
+
const resolved = await this.resolveSessionId(sessionId);
|
|
95
|
+
if (!resolved)
|
|
96
|
+
return false;
|
|
97
|
+
return this.isSessionRunning(resolved, runningPids);
|
|
98
|
+
}
|
|
42
99
|
async list(opts) {
|
|
43
100
|
const runningPids = await this.getPids();
|
|
44
101
|
const sessions = [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
2
|
export interface PidInfo {
|
|
3
3
|
pid: number;
|
|
4
4
|
cwd: string;
|
|
@@ -41,6 +41,8 @@ export declare class PiRustAdapter implements AgentAdapter {
|
|
|
41
41
|
private readonly getPids;
|
|
42
42
|
private readonly isProcessAlive;
|
|
43
43
|
constructor(opts?: PiRustAdapterOpts);
|
|
44
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
45
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
44
46
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
45
47
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
46
48
|
status(sessionId: string): Promise<AgentSession>;
|
package/dist/adapters/pi-rust.js
CHANGED
|
@@ -33,6 +33,80 @@ export class PiRustAdapter {
|
|
|
33
33
|
this.getPids = opts?.getPids || getPiRustPids;
|
|
34
34
|
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
35
35
|
}
|
|
36
|
+
async discover() {
|
|
37
|
+
const runningPids = await this.getPids();
|
|
38
|
+
const results = [];
|
|
39
|
+
let projectDirs;
|
|
40
|
+
try {
|
|
41
|
+
const entries = await fs.readdir(this.sessionDir);
|
|
42
|
+
projectDirs = entries.filter((e) => e.startsWith("--"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
for (const projDir of projectDirs) {
|
|
48
|
+
const projPath = path.join(this.sessionDir, projDir);
|
|
49
|
+
const stat = await fs.stat(projPath).catch(() => null);
|
|
50
|
+
if (!stat?.isDirectory())
|
|
51
|
+
continue;
|
|
52
|
+
const projectCwd = decodeProjDir(projDir);
|
|
53
|
+
const sessionFiles = await this.getSessionFiles(projPath);
|
|
54
|
+
for (const file of sessionFiles) {
|
|
55
|
+
const filePath = path.join(projPath, file);
|
|
56
|
+
const header = await this.readSessionHeader(filePath);
|
|
57
|
+
if (!header)
|
|
58
|
+
continue;
|
|
59
|
+
const isRunning = await this.isSessionRunning(header, projectCwd, runningPids);
|
|
60
|
+
const { model, tokens, cost } = await this.parseSessionTail(filePath);
|
|
61
|
+
const firstPrompt = await this.readFirstPrompt(filePath);
|
|
62
|
+
let fileStat;
|
|
63
|
+
try {
|
|
64
|
+
fileStat = await fs.stat(filePath);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
results.push({
|
|
70
|
+
id: header.id,
|
|
71
|
+
status: isRunning ? "running" : "stopped",
|
|
72
|
+
adapter: this.id,
|
|
73
|
+
cwd: header.cwd || projectCwd,
|
|
74
|
+
model: model || header.modelId,
|
|
75
|
+
startedAt: new Date(header.timestamp),
|
|
76
|
+
stoppedAt: isRunning
|
|
77
|
+
? undefined
|
|
78
|
+
: fileStat
|
|
79
|
+
? new Date(Number(fileStat.mtimeMs))
|
|
80
|
+
: undefined,
|
|
81
|
+
pid: isRunning
|
|
82
|
+
? await this.findMatchingPid(header, projectCwd, runningPids)
|
|
83
|
+
: undefined,
|
|
84
|
+
prompt: firstPrompt?.slice(0, 200),
|
|
85
|
+
tokens,
|
|
86
|
+
cost: cost ?? undefined,
|
|
87
|
+
nativeMetadata: {
|
|
88
|
+
provider: header.provider,
|
|
89
|
+
thinkingLevel: header.thinkingLevel,
|
|
90
|
+
projectDir: projectCwd,
|
|
91
|
+
sessionFile: filePath,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return results;
|
|
97
|
+
}
|
|
98
|
+
async isAlive(sessionId) {
|
|
99
|
+
const runningPids = await this.getPids();
|
|
100
|
+
const filePath = await this.findSessionFile(sessionId);
|
|
101
|
+
if (!filePath)
|
|
102
|
+
return false;
|
|
103
|
+
const header = await this.readSessionHeader(filePath);
|
|
104
|
+
if (!header)
|
|
105
|
+
return false;
|
|
106
|
+
const projDir = path.basename(path.dirname(filePath));
|
|
107
|
+
const projectCwd = decodeProjDir(projDir);
|
|
108
|
+
return this.isSessionRunning(header, projectCwd, runningPids);
|
|
109
|
+
}
|
|
36
110
|
async list(opts) {
|
|
37
111
|
const runningPids = await this.getPids();
|
|
38
112
|
const sessions = [];
|
package/dist/adapters/pi.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
1
|
+
import type { AgentAdapter, AgentSession, DiscoveredSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
2
|
export interface PidInfo {
|
|
3
3
|
pid: number;
|
|
4
4
|
cwd: string;
|
|
@@ -41,6 +41,8 @@ export declare class PiAdapter implements AgentAdapter {
|
|
|
41
41
|
private readonly getPids;
|
|
42
42
|
private readonly isProcessAlive;
|
|
43
43
|
constructor(opts?: PiAdapterOpts);
|
|
44
|
+
discover(): Promise<DiscoveredSession[]>;
|
|
45
|
+
isAlive(sessionId: string): Promise<boolean>;
|
|
44
46
|
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
45
47
|
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
46
48
|
/** Extract assistant messages from a JSONL session file */
|
package/dist/adapters/pi.js
CHANGED
|
@@ -33,6 +33,44 @@ export class PiAdapter {
|
|
|
33
33
|
this.getPids = opts?.getPids || getPiPids;
|
|
34
34
|
this.isProcessAlive = opts?.isProcessAlive || defaultIsProcessAlive;
|
|
35
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
|
+
}
|
|
36
74
|
async list(opts) {
|
|
37
75
|
const runningPids = await this.getPids();
|
|
38
76
|
const discovered = await this.discoverSessions();
|
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(),
|
|
@@ -95,6 +94,18 @@ function formatSession(s, showGroup) {
|
|
|
95
94
|
row.Prompt = (s.prompt || "-").slice(0, 60);
|
|
96
95
|
return row;
|
|
97
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
|
+
}
|
|
98
109
|
function formatRecord(s, showGroup) {
|
|
99
110
|
const row = {
|
|
100
111
|
ID: s.id.slice(0, 8),
|
|
@@ -178,6 +189,22 @@ function sessionToJson(s) {
|
|
|
178
189
|
meta: s.meta,
|
|
179
190
|
};
|
|
180
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
|
+
}
|
|
181
208
|
// --- CLI ---
|
|
182
209
|
const program = new Command();
|
|
183
210
|
program
|
|
@@ -214,25 +241,40 @@ program
|
|
|
214
241
|
}
|
|
215
242
|
return;
|
|
216
243
|
}
|
|
217
|
-
// Direct fallback
|
|
218
|
-
|
|
219
|
-
let sessions = [];
|
|
244
|
+
// Direct fallback — discover-first
|
|
245
|
+
let discovered = [];
|
|
220
246
|
if (opts.adapter) {
|
|
221
247
|
const adapter = getAdapter(opts.adapter);
|
|
222
|
-
|
|
248
|
+
discovered = await adapter.discover();
|
|
223
249
|
}
|
|
224
250
|
else {
|
|
225
251
|
for (const adapter of getAllAdapters()) {
|
|
226
|
-
const
|
|
227
|
-
|
|
252
|
+
const d = await adapter.discover().catch(() => []);
|
|
253
|
+
discovered.push(...d);
|
|
228
254
|
}
|
|
229
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
|
+
});
|
|
230
273
|
if (opts.json) {
|
|
231
|
-
printJson(
|
|
274
|
+
printJson(discovered.map(discoveredToJson));
|
|
232
275
|
}
|
|
233
276
|
else {
|
|
234
|
-
|
|
235
|
-
printTable(sessions.map((s) => formatSession(s, hasGroups)));
|
|
277
|
+
printTable(discovered.map((s) => formatDiscovered(s)));
|
|
236
278
|
}
|
|
237
279
|
});
|
|
238
280
|
// status
|
|
@@ -420,18 +462,14 @@ program
|
|
|
420
462
|
.option("--matrix <file>", "YAML matrix file for advanced sweep launch")
|
|
421
463
|
.option("--on-create <script>", "Hook: run after session is created")
|
|
422
464
|
.option("--on-complete <script>", "Hook: run after session completes")
|
|
423
|
-
.option("--pre-merge <script>", "Hook: run before merge")
|
|
424
|
-
.option("--post-merge <script>", "Hook: run after merge")
|
|
425
465
|
.allowUnknownOption() // Allow interleaved --adapter/--model for parseAdapterSlots
|
|
426
466
|
.action(async (adapterName, opts) => {
|
|
427
467
|
let cwd = opts.cwd ? path.resolve(opts.cwd) : process.cwd();
|
|
428
468
|
// Collect hooks
|
|
429
|
-
const hooks = opts.onCreate || opts.onComplete
|
|
469
|
+
const hooks = opts.onCreate || opts.onComplete
|
|
430
470
|
? {
|
|
431
471
|
onCreate: opts.onCreate,
|
|
432
472
|
onComplete: opts.onComplete,
|
|
433
|
-
preMerge: opts.preMerge,
|
|
434
|
-
postMerge: opts.postMerge,
|
|
435
473
|
}
|
|
436
474
|
: undefined;
|
|
437
475
|
// --- Multi-adapter / matrix detection ---
|
|
@@ -623,85 +661,6 @@ program
|
|
|
623
661
|
console.log(JSON.stringify(out));
|
|
624
662
|
}
|
|
625
663
|
});
|
|
626
|
-
// --- Merge command (FEAT-4) ---
|
|
627
|
-
program
|
|
628
|
-
.command("merge <id>")
|
|
629
|
-
.description("Commit, push, and open PR for a session's work")
|
|
630
|
-
.option("-m, --message <text>", "Commit message")
|
|
631
|
-
.option("--remove-worktree", "Remove worktree after merge")
|
|
632
|
-
.option("--repo <path>", "Main repo path (for worktree removal)")
|
|
633
|
-
.option("--pre-merge <script>", "Hook: run before merge")
|
|
634
|
-
.option("--post-merge <script>", "Hook: run after merge")
|
|
635
|
-
.action(async (id, opts) => {
|
|
636
|
-
// Find session
|
|
637
|
-
const daemonRunning = await ensureDaemon();
|
|
638
|
-
let sessionCwd;
|
|
639
|
-
let sessionAdapter;
|
|
640
|
-
if (daemonRunning) {
|
|
641
|
-
try {
|
|
642
|
-
const session = await client.call("session.status", {
|
|
643
|
-
id,
|
|
644
|
-
});
|
|
645
|
-
sessionCwd = session.cwd;
|
|
646
|
-
sessionAdapter = session.adapter;
|
|
647
|
-
}
|
|
648
|
-
catch {
|
|
649
|
-
// Fall through to adapter
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
if (!sessionCwd) {
|
|
653
|
-
const adapter = getAdapter();
|
|
654
|
-
try {
|
|
655
|
-
const session = await adapter.status(id);
|
|
656
|
-
sessionCwd = session.cwd;
|
|
657
|
-
sessionAdapter = session.adapter;
|
|
658
|
-
}
|
|
659
|
-
catch (err) {
|
|
660
|
-
console.error(`Session not found: ${err.message}`);
|
|
661
|
-
process.exit(1);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
if (!sessionCwd) {
|
|
665
|
-
console.error("Cannot determine session working directory");
|
|
666
|
-
process.exit(1);
|
|
667
|
-
}
|
|
668
|
-
const hooks = opts.preMerge || opts.postMerge
|
|
669
|
-
? { preMerge: opts.preMerge, postMerge: opts.postMerge }
|
|
670
|
-
: undefined;
|
|
671
|
-
// Pre-merge hook
|
|
672
|
-
if (hooks?.preMerge) {
|
|
673
|
-
await runHook(hooks, "preMerge", {
|
|
674
|
-
sessionId: id,
|
|
675
|
-
cwd: sessionCwd,
|
|
676
|
-
adapter: sessionAdapter || "claude-code",
|
|
677
|
-
});
|
|
678
|
-
}
|
|
679
|
-
const result = await mergeSession({
|
|
680
|
-
cwd: sessionCwd,
|
|
681
|
-
message: opts.message,
|
|
682
|
-
removeWorktree: opts.removeWorktree,
|
|
683
|
-
repoPath: opts.repo,
|
|
684
|
-
});
|
|
685
|
-
if (result.committed)
|
|
686
|
-
console.log("Changes committed");
|
|
687
|
-
if (result.pushed)
|
|
688
|
-
console.log("Pushed to remote");
|
|
689
|
-
if (result.prUrl)
|
|
690
|
-
console.log(`PR: ${result.prUrl}`);
|
|
691
|
-
if (result.worktreeRemoved)
|
|
692
|
-
console.log("Worktree removed");
|
|
693
|
-
if (!result.committed && !result.pushed) {
|
|
694
|
-
console.log("No changes to commit or push");
|
|
695
|
-
}
|
|
696
|
-
// Post-merge hook
|
|
697
|
-
if (hooks?.postMerge) {
|
|
698
|
-
await runHook(hooks, "postMerge", {
|
|
699
|
-
sessionId: id,
|
|
700
|
-
cwd: sessionCwd,
|
|
701
|
-
adapter: sessionAdapter || "claude-code",
|
|
702
|
-
});
|
|
703
|
-
}
|
|
704
|
-
});
|
|
705
664
|
// --- Worktree subcommand ---
|
|
706
665
|
const worktreeCmd = new Command("worktree").description("Manage agentctl-created worktrees");
|
|
707
666
|
worktreeCmd
|
|
@@ -846,7 +805,7 @@ program
|
|
|
846
805
|
// --- Fuses command ---
|
|
847
806
|
program
|
|
848
807
|
.command("fuses")
|
|
849
|
-
.description("List active
|
|
808
|
+
.description("List active fuse timers")
|
|
850
809
|
.option("--json", "Output as JSON")
|
|
851
810
|
.action(async (opts) => {
|
|
852
811
|
try {
|
|
@@ -861,7 +820,7 @@ program
|
|
|
861
820
|
}
|
|
862
821
|
printTable(fuses.map((f) => ({
|
|
863
822
|
Directory: shortenPath(f.directory),
|
|
864
|
-
|
|
823
|
+
Label: f.label || "-",
|
|
865
824
|
"Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
|
|
866
825
|
})));
|
|
867
826
|
}
|
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
|
}
|
package/dist/daemon/metrics.js
CHANGED
|
@@ -5,8 +5,7 @@ export class MetricsRegistry {
|
|
|
5
5
|
sessionsTotalCompleted = 0;
|
|
6
6
|
sessionsTotalFailed = 0;
|
|
7
7
|
sessionsTotalStopped = 0;
|
|
8
|
-
|
|
9
|
-
clustersDeletedTotal = 0;
|
|
8
|
+
fusesExpiredTotal = 0;
|
|
10
9
|
sessionDurations = []; // seconds
|
|
11
10
|
constructor(sessionTracker, lockManager, fuseEngine) {
|
|
12
11
|
this.sessionTracker = sessionTracker;
|
|
@@ -28,9 +27,8 @@ export class MetricsRegistry {
|
|
|
28
27
|
if (durationSeconds != null)
|
|
29
28
|
this.sessionDurations.push(durationSeconds);
|
|
30
29
|
}
|
|
31
|
-
|
|
32
|
-
this.
|
|
33
|
-
this.clustersDeletedTotal++;
|
|
30
|
+
recordFuseExpired() {
|
|
31
|
+
this.fusesExpiredTotal++;
|
|
34
32
|
}
|
|
35
33
|
generateMetrics() {
|
|
36
34
|
const lines = [];
|
|
@@ -54,8 +52,7 @@ export class MetricsRegistry {
|
|
|
54
52
|
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalCompleted, 'status="completed"');
|
|
55
53
|
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalFailed, 'status="failed"');
|
|
56
54
|
c("agentctl_sessions_total", "Total sessions by status", this.sessionsTotalStopped, 'status="stopped"');
|
|
57
|
-
c("
|
|
58
|
-
c("agentctl_kind_clusters_deleted_total", "Total Kind clusters deleted", this.clustersDeletedTotal);
|
|
55
|
+
c("agentctl_fuses_expired_total", "Total fuses expired", this.fusesExpiredTotal);
|
|
59
56
|
// Histogram (session duration)
|
|
60
57
|
lines.push("# HELP agentctl_session_duration_seconds Session duration histogram");
|
|
61
58
|
lines.push("# TYPE agentctl_session_duration_seconds histogram");
|
package/dist/daemon/server.js
CHANGED
|
@@ -62,8 +62,8 @@ export async function startDaemon(opts = {}) {
|
|
|
62
62
|
const sessionTracker = new SessionTracker(state, { adapters });
|
|
63
63
|
const metrics = new MetricsRegistry(sessionTracker, lockManager, fuseEngine);
|
|
64
64
|
// Wire up events
|
|
65
|
-
emitter.on("fuse.
|
|
66
|
-
metrics.
|
|
65
|
+
emitter.on("fuse.expired", () => {
|
|
66
|
+
metrics.recordFuseExpired();
|
|
67
67
|
});
|
|
68
68
|
// 9. Validate all sessions on startup — mark dead ones as stopped (#40)
|
|
69
69
|
sessionTracker.validateAllSessions();
|
|
@@ -335,10 +335,9 @@ function createRequestHandler(ctx) {
|
|
|
335
335
|
});
|
|
336
336
|
// Remove auto-lock
|
|
337
337
|
ctx.lockManager.autoUnlock(session.id);
|
|
338
|
-
// Mark stopped
|
|
338
|
+
// Mark stopped
|
|
339
339
|
const stopped = ctx.sessionTracker.onSessionExit(session.id);
|
|
340
340
|
if (stopped) {
|
|
341
|
-
ctx.fuseEngine.onSessionExit(stopped);
|
|
342
341
|
ctx.metrics.recordSessionStopped();
|
|
343
342
|
}
|
|
344
343
|
return null;
|
|
@@ -367,6 +366,21 @@ function createRequestHandler(ctx) {
|
|
|
367
366
|
return null;
|
|
368
367
|
case "fuse.list":
|
|
369
368
|
return ctx.fuseEngine.listActive();
|
|
369
|
+
case "fuse.set":
|
|
370
|
+
ctx.fuseEngine.setFuse({
|
|
371
|
+
directory: params.directory,
|
|
372
|
+
sessionId: params.sessionId,
|
|
373
|
+
ttlMs: params.ttlMs,
|
|
374
|
+
onExpire: params.onExpire,
|
|
375
|
+
label: params.label,
|
|
376
|
+
});
|
|
377
|
+
return null;
|
|
378
|
+
case "fuse.extend": {
|
|
379
|
+
const extended = ctx.fuseEngine.extendFuse(params.directory, params.ttlMs);
|
|
380
|
+
if (!extended)
|
|
381
|
+
throw new Error(`No active fuse for directory: ${params.directory}`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
370
384
|
case "fuse.cancel":
|
|
371
385
|
ctx.fuseEngine.cancelFuse(params.directory);
|
|
372
386
|
return null;
|
|
@@ -42,24 +42,25 @@ export class SessionTracker {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
async poll() {
|
|
45
|
-
// Collect PIDs from all adapter-
|
|
45
|
+
// Collect PIDs from all adapter-discovered sessions (the source of truth)
|
|
46
46
|
const adapterPidToId = new Map();
|
|
47
47
|
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
48
48
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
// Discover-first: adapter.discover() is the ground truth
|
|
50
|
+
const discovered = await adapter.discover();
|
|
51
|
+
for (const disc of discovered) {
|
|
52
|
+
if (disc.pid) {
|
|
53
|
+
adapterPidToId.set(disc.pid, disc.id);
|
|
53
54
|
}
|
|
54
|
-
const existing = this.state.getSession(
|
|
55
|
-
const record =
|
|
55
|
+
const existing = this.state.getSession(disc.id);
|
|
56
|
+
const record = discoveredToRecord(disc, adapterName);
|
|
56
57
|
if (!existing) {
|
|
57
|
-
this.state.setSession(
|
|
58
|
+
this.state.setSession(disc.id, record);
|
|
58
59
|
}
|
|
59
60
|
else if (existing.status !== record.status ||
|
|
60
61
|
(!existing.model && record.model)) {
|
|
61
|
-
// Status changed or model resolved — update
|
|
62
|
-
this.state.setSession(
|
|
62
|
+
// Status changed or model resolved — update, preserving metadata
|
|
63
|
+
this.state.setSession(disc.id, {
|
|
63
64
|
...existing,
|
|
64
65
|
status: record.status,
|
|
65
66
|
stoppedAt: record.stoppedAt,
|
|
@@ -67,6 +68,7 @@ export class SessionTracker {
|
|
|
67
68
|
tokens: record.tokens,
|
|
68
69
|
cost: record.cost,
|
|
69
70
|
prompt: record.prompt || existing.prompt,
|
|
71
|
+
pid: record.pid,
|
|
70
72
|
});
|
|
71
73
|
}
|
|
72
74
|
}
|
|
@@ -338,3 +340,20 @@ function sessionToRecord(session, adapterName) {
|
|
|
338
340
|
meta: session.meta,
|
|
339
341
|
};
|
|
340
342
|
}
|
|
343
|
+
/** Convert a DiscoveredSession (adapter ground truth) to a SessionRecord for state */
|
|
344
|
+
function discoveredToRecord(disc, adapterName) {
|
|
345
|
+
return {
|
|
346
|
+
id: disc.id,
|
|
347
|
+
adapter: adapterName,
|
|
348
|
+
status: disc.status,
|
|
349
|
+
startedAt: disc.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
350
|
+
stoppedAt: disc.stoppedAt?.toISOString(),
|
|
351
|
+
cwd: disc.cwd,
|
|
352
|
+
model: disc.model,
|
|
353
|
+
prompt: disc.prompt,
|
|
354
|
+
tokens: disc.tokens,
|
|
355
|
+
cost: disc.cost,
|
|
356
|
+
pid: disc.pid,
|
|
357
|
+
meta: disc.nativeMetadata ?? {},
|
|
358
|
+
};
|
|
359
|
+
}
|
package/dist/daemon/state.d.ts
CHANGED
|
@@ -28,10 +28,20 @@ export interface Lock {
|
|
|
28
28
|
}
|
|
29
29
|
export interface FuseTimer {
|
|
30
30
|
directory: string;
|
|
31
|
-
|
|
32
|
-
branch: string;
|
|
31
|
+
ttlMs: number;
|
|
33
32
|
expiresAt: string;
|
|
34
33
|
sessionId: string;
|
|
34
|
+
/** On-expire action: shell command, webhook URL, or event name */
|
|
35
|
+
onExpire?: FuseAction;
|
|
36
|
+
label?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface FuseAction {
|
|
39
|
+
/** Shell script to run when fuse expires. CWD is the fuse directory. */
|
|
40
|
+
script?: string;
|
|
41
|
+
/** Webhook URL to POST to when fuse expires */
|
|
42
|
+
webhook?: string;
|
|
43
|
+
/** Event name to emit when fuse expires */
|
|
44
|
+
event?: string;
|
|
35
45
|
}
|
|
36
46
|
export interface PersistedState {
|
|
37
47
|
sessions: Record<string, SessionRecord>;
|
package/dist/hooks.d.ts
CHANGED
package/package.json
CHANGED
package/dist/merge.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export interface MergeOpts {
|
|
2
|
-
/** Working directory of the session */
|
|
3
|
-
cwd: string;
|
|
4
|
-
/** Commit message (auto-generated if omitted) */
|
|
5
|
-
message?: string;
|
|
6
|
-
/** Whether to remove worktree after push */
|
|
7
|
-
removeWorktree?: boolean;
|
|
8
|
-
/** The main repo path (needed for worktree removal) */
|
|
9
|
-
repoPath?: string;
|
|
10
|
-
}
|
|
11
|
-
export interface MergeResult {
|
|
12
|
-
committed: boolean;
|
|
13
|
-
pushed: boolean;
|
|
14
|
-
prUrl?: string;
|
|
15
|
-
worktreeRemoved: boolean;
|
|
16
|
-
}
|
|
17
|
-
/**
|
|
18
|
-
* Merge + cleanup workflow:
|
|
19
|
-
* 1. Commit uncommitted changes
|
|
20
|
-
* 2. Push to remote
|
|
21
|
-
* 3. Open PR via `gh`
|
|
22
|
-
* 4. Optionally remove worktree
|
|
23
|
-
*/
|
|
24
|
-
export declare function mergeSession(opts: MergeOpts): Promise<MergeResult>;
|
package/dist/merge.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { exec, execFile } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
const execAsync = promisify(exec);
|
|
4
|
-
const execFileAsync = promisify(execFile);
|
|
5
|
-
/**
|
|
6
|
-
* Merge + cleanup workflow:
|
|
7
|
-
* 1. Commit uncommitted changes
|
|
8
|
-
* 2. Push to remote
|
|
9
|
-
* 3. Open PR via `gh`
|
|
10
|
-
* 4. Optionally remove worktree
|
|
11
|
-
*/
|
|
12
|
-
export async function mergeSession(opts) {
|
|
13
|
-
const { cwd } = opts;
|
|
14
|
-
const result = {
|
|
15
|
-
committed: false,
|
|
16
|
-
pushed: false,
|
|
17
|
-
worktreeRemoved: false,
|
|
18
|
-
};
|
|
19
|
-
// 1. Check for uncommitted changes
|
|
20
|
-
const { stdout: status } = await execFileAsync("git", ["status", "--porcelain"], { cwd });
|
|
21
|
-
if (status.trim()) {
|
|
22
|
-
// Stage all changes and commit
|
|
23
|
-
await execFileAsync("git", ["add", "-A"], { cwd });
|
|
24
|
-
const message = opts.message || "chore: commit agent session work (via agentctl merge)";
|
|
25
|
-
await execFileAsync("git", ["commit", "-m", message], { cwd });
|
|
26
|
-
result.committed = true;
|
|
27
|
-
}
|
|
28
|
-
// 2. Get current branch name
|
|
29
|
-
const { stdout: branchRaw } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd });
|
|
30
|
-
const branch = branchRaw.trim();
|
|
31
|
-
// 3. Push to remote
|
|
32
|
-
try {
|
|
33
|
-
await execFileAsync("git", ["push", "-u", "origin", branch], { cwd });
|
|
34
|
-
result.pushed = true;
|
|
35
|
-
}
|
|
36
|
-
catch (err) {
|
|
37
|
-
console.error("Push failed:", err.message);
|
|
38
|
-
return result;
|
|
39
|
-
}
|
|
40
|
-
// 4. Open PR via gh (best effort)
|
|
41
|
-
try {
|
|
42
|
-
const { stdout: prOut } = await execAsync(`gh pr create --fill --head ${branch} 2>&1 || gh pr view --json url -q .url 2>&1`, { cwd });
|
|
43
|
-
// Extract URL from output
|
|
44
|
-
const urlMatch = prOut.match(/https:\/\/github\.com\/[^\s]+/);
|
|
45
|
-
if (urlMatch) {
|
|
46
|
-
result.prUrl = urlMatch[0];
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
// gh not available or PR already exists
|
|
51
|
-
}
|
|
52
|
-
// 5. Optionally remove worktree
|
|
53
|
-
if (opts.removeWorktree && opts.repoPath) {
|
|
54
|
-
try {
|
|
55
|
-
await execFileAsync("git", ["worktree", "remove", "--force", cwd], {
|
|
56
|
-
cwd: opts.repoPath,
|
|
57
|
-
});
|
|
58
|
-
result.worktreeRemoved = true;
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
console.error("Worktree removal failed:", err.message);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return result;
|
|
65
|
-
}
|