@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.
@@ -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 = [];
@@ -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>;
@@ -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
- const isActive = updatedAt > 0 && ageMs < 5 * 60 * 1000;
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: isActive ? "running" : "idle",
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>;
@@ -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 = [];
@@ -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 */
@@ -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
- const listOpts = { status: opts.status, all: opts.all };
219
- let sessions = [];
244
+ // Direct fallback — discover-first
245
+ let discovered = [];
220
246
  if (opts.adapter) {
221
247
  const adapter = getAdapter(opts.adapter);
222
- sessions = await adapter.list(listOpts);
248
+ discovered = await adapter.discover();
223
249
  }
224
250
  else {
225
251
  for (const adapter of getAllAdapters()) {
226
- const s = await adapter.list(listOpts);
227
- sessions.push(...s);
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(sessions.map(sessionToJson));
274
+ printJson(discovered.map(discoveredToJson));
232
275
  }
233
276
  else {
234
- const hasGroups = sessions.some((s) => s.group);
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 || opts.preMerge || opts.postMerge
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 Kind cluster fuse timers")
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
- Cluster: f.clusterName,
823
+ Label: f.label || "-",
865
824
  "Expires In": formatDuration(new Date(f.expiresAt).getTime() - Date.now()),
866
825
  })));
867
826
  }
@@ -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, SessionRecord, StateManager } from "./state.js";
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
- /** Derive cluster name from worktree directory. Returns null if not a mono worktree. */
14
- static deriveClusterName(directory: string): {
15
- clusterName: string;
16
- branch: string;
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 — delete the Kind cluster. */
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
- /** Derive cluster name from worktree directory. Returns null if not a mono worktree. */
17
- static deriveClusterName(directory) {
18
- const home = os.homedir();
19
- const monoPrefix = path.join(home, "code", "mono-");
20
- if (!directory.startsWith(monoPrefix))
21
- return null;
22
- const branch = directory.slice(monoPrefix.length);
23
- if (!branch)
24
- return null;
25
- return {
26
- clusterName: `kindo-charlie-${branch}`,
27
- branch,
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
- /** Called when a session exits. Starts fuse if applicable. */
31
- onSessionExit(session) {
32
- if (!session.cwd)
33
- return;
34
- const derived = FuseEngine.deriveClusterName(session.cwd);
35
- if (!derived)
36
- return;
37
- this.startFuse(session.cwd, derived.clusterName, derived.branch, session.id);
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() + this.defaultDurationMs);
42
+ const expiresAt = new Date(Date.now() + duration);
43
43
  const fuse = {
44
- directory,
45
- clusterName,
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), this.defaultDurationMs);
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 — delete the Kind cluster. */
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
- console.log(`Fuse fired: deleting cluster ${fuse.clusterName}`);
87
- try {
88
- // Best effort: yarn local:down first
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("yarn local:down", {
95
+ await execAsync(action.script, {
91
96
  cwd: fuse.directory,
92
- timeout: 60_000,
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
- // Ignore
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
- catch (err) {
105
- console.error(`Failed to delete cluster ${fuse.clusterName}:`, err);
126
+ // Emit named event
127
+ if (action.event) {
128
+ this.emitter?.emit(action.event, fuse);
106
129
  }
107
130
  }
108
131
  listActive() {
@@ -8,13 +8,12 @@ export declare class MetricsRegistry {
8
8
  sessionsTotalCompleted: number;
9
9
  sessionsTotalFailed: number;
10
10
  sessionsTotalStopped: number;
11
- fusesFiredTotal: number;
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
- recordFuseFired(): void;
17
+ recordFuseExpired(): void;
19
18
  generateMetrics(): string;
20
19
  }
@@ -5,8 +5,7 @@ export class MetricsRegistry {
5
5
  sessionsTotalCompleted = 0;
6
6
  sessionsTotalFailed = 0;
7
7
  sessionsTotalStopped = 0;
8
- fusesFiredTotal = 0;
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
- recordFuseFired() {
32
- this.fusesFiredTotal++;
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("agentctl_fuses_fired_total", "Total fuses fired", this.fusesFiredTotal);
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");
@@ -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.fired", () => {
66
- metrics.recordFuseFired();
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 and start fuse if applicable
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-returned sessions (the source of truth)
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
- const sessions = await adapter.list({ all: true });
50
- for (const session of sessions) {
51
- if (session.pid) {
52
- adapterPidToId.set(session.pid, session.id);
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(session.id);
55
- const record = sessionToRecord(session, adapterName);
55
+ const existing = this.state.getSession(disc.id);
56
+ const record = discoveredToRecord(disc, adapterName);
56
57
  if (!existing) {
57
- this.state.setSession(session.id, record);
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(session.id, {
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
+ }
@@ -28,10 +28,20 @@ export interface Lock {
28
28
  }
29
29
  export interface FuseTimer {
30
30
  directory: string;
31
- clusterName: string;
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
@@ -1,5 +1,5 @@
1
1
  import type { LifecycleHooks } from "./core/types.js";
2
- export type HookPhase = "onCreate" | "onComplete" | "preMerge" | "postMerge";
2
+ export type HookPhase = "onCreate" | "onComplete";
3
3
  export interface HookContext {
4
4
  sessionId: string;
5
5
  cwd: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orgloop/agentctl",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Universal agent supervision interface — monitor and control AI coding agents from a single CLI",
5
5
  "type": "module",
6
6
  "bin": {
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
- }