@orgloop/agentctl 1.2.1 → 1.4.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 +8 -6
- package/dist/daemon/metrics.js +15 -11
- package/dist/daemon/server.js +159 -43
- package/dist/daemon/session-tracker.d.ts +42 -43
- package/dist/daemon/session-tracker.js +141 -255
- 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,218 +1,53 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Grace period for recently-launched sessions.
|
|
3
|
+
* If a session was launched less than this many ms ago and the adapter
|
|
4
|
+
* doesn't return it yet, don't mark it stopped — the adapter may not
|
|
5
|
+
* have discovered it yet.
|
|
6
|
+
*/
|
|
7
|
+
const LAUNCH_GRACE_PERIOD_MS = 30_000;
|
|
8
|
+
/**
|
|
9
|
+
* Simplified session tracker for the stateless daemon core (ADR 004).
|
|
10
|
+
*
|
|
11
|
+
* Adapters own session truth. The daemon only tracks:
|
|
12
|
+
* - Launch metadata (prompt, group, spec, cwd) for sessions launched via agentctl
|
|
13
|
+
* - Locks and fuses (handled by LockManager / FuseEngine)
|
|
14
|
+
*
|
|
15
|
+
* The old polling loop, pruning, and state-based session registry are removed.
|
|
16
|
+
* session.list now fans out adapter.discover() at call time.
|
|
17
|
+
*/
|
|
3
18
|
export class SessionTracker {
|
|
4
19
|
state;
|
|
5
20
|
adapters;
|
|
6
|
-
pollIntervalMs;
|
|
7
|
-
pollHandle = null;
|
|
8
|
-
polling = false;
|
|
9
21
|
isProcessAlive;
|
|
22
|
+
cleanupHandle = null;
|
|
10
23
|
constructor(state, opts) {
|
|
11
24
|
this.state = state;
|
|
12
25
|
this.adapters = opts.adapters;
|
|
13
|
-
this.pollIntervalMs = opts.pollIntervalMs ?? 5000;
|
|
14
26
|
this.isProcessAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
|
|
15
27
|
}
|
|
16
|
-
startPolling() {
|
|
17
|
-
if (this.pollHandle)
|
|
18
|
-
return;
|
|
19
|
-
// Prune old stopped sessions on startup
|
|
20
|
-
this.pruneOldSessions();
|
|
21
|
-
// Initial poll
|
|
22
|
-
this.guardedPoll();
|
|
23
|
-
this.pollHandle = setInterval(() => {
|
|
24
|
-
this.guardedPoll();
|
|
25
|
-
}, this.pollIntervalMs);
|
|
26
|
-
}
|
|
27
|
-
/** Run poll() with a guard to skip if the previous cycle is still running */
|
|
28
|
-
guardedPoll() {
|
|
29
|
-
if (this.polling)
|
|
30
|
-
return;
|
|
31
|
-
this.polling = true;
|
|
32
|
-
this.poll()
|
|
33
|
-
.catch((err) => console.error("Poll error:", err))
|
|
34
|
-
.finally(() => {
|
|
35
|
-
this.polling = false;
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
stopPolling() {
|
|
39
|
-
if (this.pollHandle) {
|
|
40
|
-
clearInterval(this.pollHandle);
|
|
41
|
-
this.pollHandle = null;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
async poll() {
|
|
45
|
-
// Collect PIDs from all adapter-returned sessions (the source of truth)
|
|
46
|
-
const adapterPidToId = new Map();
|
|
47
|
-
for (const [adapterName, adapter] of Object.entries(this.adapters)) {
|
|
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);
|
|
53
|
-
}
|
|
54
|
-
const existing = this.state.getSession(session.id);
|
|
55
|
-
const record = sessionToRecord(session, adapterName);
|
|
56
|
-
if (!existing) {
|
|
57
|
-
this.state.setSession(session.id, record);
|
|
58
|
-
}
|
|
59
|
-
else if (existing.status !== record.status ||
|
|
60
|
-
(!existing.model && record.model)) {
|
|
61
|
-
// Status changed or model resolved — update
|
|
62
|
-
this.state.setSession(session.id, {
|
|
63
|
-
...existing,
|
|
64
|
-
status: record.status,
|
|
65
|
-
stoppedAt: record.stoppedAt,
|
|
66
|
-
model: record.model || existing.model,
|
|
67
|
-
tokens: record.tokens,
|
|
68
|
-
cost: record.cost,
|
|
69
|
-
prompt: record.prompt || existing.prompt,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
// Adapter unavailable — skip
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
// Reap stale entries from daemon state
|
|
79
|
-
this.reapStaleEntries(adapterPidToId);
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Clean up ghost sessions in the daemon state:
|
|
83
|
-
* - pending-* entries whose PID matches a resolved session → remove pending
|
|
84
|
-
* - Any "running"/"idle" session in state whose PID is dead → mark stopped
|
|
85
|
-
*/
|
|
86
|
-
reapStaleEntries(adapterPidToId) {
|
|
87
|
-
const sessions = this.state.getSessions();
|
|
88
|
-
for (const [id, record] of Object.entries(sessions)) {
|
|
89
|
-
// Bug 2: If this is a pending-* entry and a real session has the same PID,
|
|
90
|
-
// the pending entry is stale — remove it
|
|
91
|
-
if (id.startsWith("pending-") && record.pid) {
|
|
92
|
-
const resolvedId = adapterPidToId.get(record.pid);
|
|
93
|
-
if (resolvedId && resolvedId !== id) {
|
|
94
|
-
this.state.removeSession(id);
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
// Bug 1: If session is "running"/"idle" but PID is dead, mark stopped
|
|
99
|
-
if ((record.status === "running" || record.status === "idle") &&
|
|
100
|
-
record.pid) {
|
|
101
|
-
// Only reap if the adapter didn't return this session as running
|
|
102
|
-
// (adapter is the source of truth for sessions it knows about)
|
|
103
|
-
const adapterId = adapterPidToId.get(record.pid);
|
|
104
|
-
if (adapterId === id)
|
|
105
|
-
continue; // Adapter confirmed this PID is active
|
|
106
|
-
if (!this.isProcessAlive(record.pid)) {
|
|
107
|
-
this.state.setSession(id, {
|
|
108
|
-
...record,
|
|
109
|
-
status: "stopped",
|
|
110
|
-
stoppedAt: new Date().toISOString(),
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
28
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* ghost sessions across daemon restarts.
|
|
29
|
+
* Start periodic PID liveness check for daemon-launched sessions.
|
|
30
|
+
* This is a lightweight check (no adapter fan-out) that runs every 30s
|
|
31
|
+
* to detect dead sessions and return their IDs for lock cleanup.
|
|
121
32
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
this.state.setSession(id, {
|
|
131
|
-
...record,
|
|
132
|
-
status: "stopped",
|
|
133
|
-
stoppedAt: new Date().toISOString(),
|
|
134
|
-
});
|
|
135
|
-
cleaned++;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
// No PID recorded — can't verify, mark as stopped
|
|
140
|
-
this.state.setSession(id, {
|
|
141
|
-
...record,
|
|
142
|
-
status: "stopped",
|
|
143
|
-
stoppedAt: new Date().toISOString(),
|
|
144
|
-
});
|
|
145
|
-
cleaned++;
|
|
33
|
+
startLaunchCleanup(onDead) {
|
|
34
|
+
if (this.cleanupHandle)
|
|
35
|
+
return;
|
|
36
|
+
this.cleanupHandle = setInterval(() => {
|
|
37
|
+
const dead = this.cleanupDeadLaunches();
|
|
38
|
+
if (onDead) {
|
|
39
|
+
for (const id of dead)
|
|
40
|
+
onDead(id);
|
|
146
41
|
}
|
|
147
|
-
}
|
|
148
|
-
if (cleaned > 0) {
|
|
149
|
-
console.error(`Validated sessions on startup: marked ${cleaned} dead sessions as stopped`);
|
|
150
|
-
}
|
|
42
|
+
}, 30_000);
|
|
151
43
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
*/
|
|
157
|
-
pruneDeadSessions() {
|
|
158
|
-
const sessions = this.state.getSessions();
|
|
159
|
-
let pruned = 0;
|
|
160
|
-
for (const [id, record] of Object.entries(sessions)) {
|
|
161
|
-
// Remove stopped/completed/failed sessions older than 24h
|
|
162
|
-
if (record.status === "stopped" ||
|
|
163
|
-
record.status === "completed" ||
|
|
164
|
-
record.status === "failed") {
|
|
165
|
-
const stoppedAt = record.stoppedAt
|
|
166
|
-
? new Date(record.stoppedAt).getTime()
|
|
167
|
-
: new Date(record.startedAt).getTime();
|
|
168
|
-
const age = Date.now() - stoppedAt;
|
|
169
|
-
if (age > 24 * 60 * 60 * 1000) {
|
|
170
|
-
this.state.removeSession(id);
|
|
171
|
-
pruned++;
|
|
172
|
-
}
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
// Remove running/idle sessions whose PID is dead
|
|
176
|
-
if (record.status === "running" || record.status === "idle") {
|
|
177
|
-
if (record.pid && !this.isProcessAlive(record.pid)) {
|
|
178
|
-
this.state.removeSession(id);
|
|
179
|
-
pruned++;
|
|
180
|
-
}
|
|
181
|
-
else if (!record.pid) {
|
|
182
|
-
this.state.removeSession(id);
|
|
183
|
-
pruned++;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
44
|
+
stopLaunchCleanup() {
|
|
45
|
+
if (this.cleanupHandle) {
|
|
46
|
+
clearInterval(this.cleanupHandle);
|
|
47
|
+
this.cleanupHandle = null;
|
|
186
48
|
}
|
|
187
|
-
return pruned;
|
|
188
49
|
}
|
|
189
|
-
/**
|
|
190
|
-
* Remove stopped sessions from state that have been stopped for more than 7 days.
|
|
191
|
-
* This reduces overhead from accumulating hundreds of historical sessions.
|
|
192
|
-
*/
|
|
193
|
-
pruneOldSessions() {
|
|
194
|
-
const sessions = this.state.getSessions();
|
|
195
|
-
const now = Date.now();
|
|
196
|
-
let pruned = 0;
|
|
197
|
-
for (const [id, record] of Object.entries(sessions)) {
|
|
198
|
-
if (record.status !== "stopped" &&
|
|
199
|
-
record.status !== "completed" &&
|
|
200
|
-
record.status !== "failed") {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
const stoppedAt = record.stoppedAt
|
|
204
|
-
? new Date(record.stoppedAt).getTime()
|
|
205
|
-
: new Date(record.startedAt).getTime();
|
|
206
|
-
if (now - stoppedAt > STOPPED_SESSION_PRUNE_AGE_MS) {
|
|
207
|
-
this.state.removeSession(id);
|
|
208
|
-
pruned++;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
if (pruned > 0) {
|
|
212
|
-
console.error(`Pruned ${pruned} sessions stopped >7 days ago from state`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
/** Track a newly launched session */
|
|
50
|
+
/** Track a newly launched session (stores launch metadata in state) */
|
|
216
51
|
track(session, adapterName) {
|
|
217
52
|
const record = sessionToRecord(session, adapterName);
|
|
218
53
|
// Pending→UUID reconciliation: if this is a real session (not pending),
|
|
@@ -227,7 +62,7 @@ export class SessionTracker {
|
|
|
227
62
|
this.state.setSession(session.id, record);
|
|
228
63
|
return record;
|
|
229
64
|
}
|
|
230
|
-
/** Get session
|
|
65
|
+
/** Get session launch metadata by id (exact or prefix match) */
|
|
231
66
|
getSession(id) {
|
|
232
67
|
// Exact match
|
|
233
68
|
const exact = this.state.getSession(id);
|
|
@@ -240,48 +75,11 @@ export class SessionTracker {
|
|
|
240
75
|
return matches[0][1];
|
|
241
76
|
return undefined;
|
|
242
77
|
}
|
|
243
|
-
/**
|
|
244
|
-
listSessions(opts) {
|
|
245
|
-
const sessions = Object.values(this.state.getSessions());
|
|
246
|
-
// Liveness check: mark sessions with dead PIDs as stopped
|
|
247
|
-
for (const s of sessions) {
|
|
248
|
-
if ((s.status === "running" || s.status === "idle") && s.pid) {
|
|
249
|
-
if (!this.isProcessAlive(s.pid)) {
|
|
250
|
-
s.status = "stopped";
|
|
251
|
-
s.stoppedAt = new Date().toISOString();
|
|
252
|
-
this.state.setSession(s.id, s);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
let filtered = sessions;
|
|
257
|
-
if (opts?.adapter) {
|
|
258
|
-
filtered = filtered.filter((s) => s.adapter === opts.adapter);
|
|
259
|
-
}
|
|
260
|
-
if (opts?.status) {
|
|
261
|
-
filtered = filtered.filter((s) => s.status === opts.status);
|
|
262
|
-
}
|
|
263
|
-
else if (!opts?.all) {
|
|
264
|
-
filtered = filtered.filter((s) => s.status === "running" || s.status === "idle");
|
|
265
|
-
}
|
|
266
|
-
// Dedup: if a pending-* entry shares a PID with a resolved entry, show only the resolved one
|
|
267
|
-
filtered = deduplicatePendingSessions(filtered);
|
|
268
|
-
return filtered.sort((a, b) => {
|
|
269
|
-
// Running first, then by recency
|
|
270
|
-
if (a.status === "running" && b.status !== "running")
|
|
271
|
-
return -1;
|
|
272
|
-
if (b.status === "running" && a.status !== "running")
|
|
273
|
-
return 1;
|
|
274
|
-
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
activeCount() {
|
|
278
|
-
return Object.values(this.state.getSessions()).filter((s) => s.status === "running" || s.status === "idle").length;
|
|
279
|
-
}
|
|
280
|
-
/** Remove a session from state entirely (used for ghost cleanup) */
|
|
78
|
+
/** Remove a session from launch metadata */
|
|
281
79
|
removeSession(sessionId) {
|
|
282
80
|
this.state.removeSession(sessionId);
|
|
283
81
|
}
|
|
284
|
-
/** Called when a session stops —
|
|
82
|
+
/** Called when a session stops — marks it in launch metadata, returns the record */
|
|
285
83
|
onSessionExit(sessionId) {
|
|
286
84
|
const session = this.state.getSession(sessionId);
|
|
287
85
|
if (session) {
|
|
@@ -291,6 +89,91 @@ export class SessionTracker {
|
|
|
291
89
|
}
|
|
292
90
|
return session;
|
|
293
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Merge adapter-discovered sessions with daemon launch metadata.
|
|
94
|
+
*
|
|
95
|
+
* 1. Enrich discovered sessions with launch metadata (prompt, group, spec, etc.)
|
|
96
|
+
* 2. Reconcile: mark daemon-launched sessions as stopped if their adapter
|
|
97
|
+
* succeeded but didn't return them (and they're past the grace period).
|
|
98
|
+
* 3. Include recently-launched sessions that adapters haven't discovered yet.
|
|
99
|
+
*
|
|
100
|
+
* Returns the merged session list and IDs of sessions that were marked stopped
|
|
101
|
+
* (for lock cleanup by the caller).
|
|
102
|
+
*/
|
|
103
|
+
reconcileAndEnrich(discovered, succeededAdapters) {
|
|
104
|
+
// Build lookups for discovered sessions
|
|
105
|
+
const discoveredIds = new Set(discovered.map((d) => d.id));
|
|
106
|
+
const discoveredPids = new Map();
|
|
107
|
+
for (const d of discovered) {
|
|
108
|
+
if (d.pid)
|
|
109
|
+
discoveredPids.set(d.pid, d.id);
|
|
110
|
+
}
|
|
111
|
+
// 1. Convert discovered sessions to records, enriching with launch metadata
|
|
112
|
+
const sessions = discovered.map((disc) => enrichDiscovered(disc, this.state.getSession(disc.id)));
|
|
113
|
+
// 2. Reconcile daemon-launched sessions that disappeared from adapter results
|
|
114
|
+
const stoppedLaunchIds = [];
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
for (const [id, record] of Object.entries(this.state.getSessions())) {
|
|
117
|
+
if (record.status !== "running" &&
|
|
118
|
+
record.status !== "idle" &&
|
|
119
|
+
record.status !== "pending")
|
|
120
|
+
continue;
|
|
121
|
+
// If adapter for this session didn't succeed, include as-is from launch metadata
|
|
122
|
+
// (we can't verify status, so trust the last-known state)
|
|
123
|
+
if (!succeededAdapters.has(record.adapter)) {
|
|
124
|
+
sessions.push(record);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Skip if adapter returned this session (it's still active)
|
|
128
|
+
if (discoveredIds.has(id))
|
|
129
|
+
continue;
|
|
130
|
+
// Check if this session's PID was resolved to a different ID (pending→UUID)
|
|
131
|
+
if (record.pid && discoveredPids.has(record.pid)) {
|
|
132
|
+
// PID was resolved to a real session — remove stale launch entry
|
|
133
|
+
this.state.removeSession(id);
|
|
134
|
+
stoppedLaunchIds.push(id);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Grace period: don't mark recently-launched sessions as stopped
|
|
138
|
+
const launchAge = now - new Date(record.startedAt).getTime();
|
|
139
|
+
if (launchAge < LAUNCH_GRACE_PERIOD_MS) {
|
|
140
|
+
// Still within grace period — include as-is in results
|
|
141
|
+
sessions.push(record);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Session disappeared from adapter results — mark stopped
|
|
145
|
+
this.state.setSession(id, {
|
|
146
|
+
...record,
|
|
147
|
+
status: "stopped",
|
|
148
|
+
stoppedAt: new Date().toISOString(),
|
|
149
|
+
});
|
|
150
|
+
stoppedLaunchIds.push(id);
|
|
151
|
+
}
|
|
152
|
+
return { sessions, stoppedLaunchIds };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Check PID liveness for daemon-launched sessions.
|
|
156
|
+
* Returns IDs of sessions whose PIDs have died.
|
|
157
|
+
* This is a lightweight check (no adapter fan-out) for lock cleanup.
|
|
158
|
+
*/
|
|
159
|
+
cleanupDeadLaunches() {
|
|
160
|
+
const dead = [];
|
|
161
|
+
for (const [id, record] of Object.entries(this.state.getSessions())) {
|
|
162
|
+
if (record.status !== "running" &&
|
|
163
|
+
record.status !== "idle" &&
|
|
164
|
+
record.status !== "pending")
|
|
165
|
+
continue;
|
|
166
|
+
if (record.pid && !this.isProcessAlive(record.pid)) {
|
|
167
|
+
this.state.setSession(id, {
|
|
168
|
+
...record,
|
|
169
|
+
status: "stopped",
|
|
170
|
+
stoppedAt: new Date().toISOString(),
|
|
171
|
+
});
|
|
172
|
+
dead.push(id);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return dead;
|
|
176
|
+
}
|
|
294
177
|
}
|
|
295
178
|
/** Check if a process is alive via kill(pid, 0) signal check */
|
|
296
179
|
function defaultIsProcessAlive(pid) {
|
|
@@ -303,22 +186,25 @@ function defaultIsProcessAlive(pid) {
|
|
|
303
186
|
}
|
|
304
187
|
}
|
|
305
188
|
/**
|
|
306
|
-
*
|
|
307
|
-
* This is a safety net for list output — the poll() reaper handles cleanup in state.
|
|
189
|
+
* Convert a discovered session to a SessionRecord, enriching with launch metadata.
|
|
308
190
|
*/
|
|
309
|
-
function
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
191
|
+
function enrichDiscovered(disc, launchMeta) {
|
|
192
|
+
return {
|
|
193
|
+
id: disc.id,
|
|
194
|
+
adapter: disc.adapter,
|
|
195
|
+
status: disc.status,
|
|
196
|
+
startedAt: disc.startedAt?.toISOString() ?? new Date().toISOString(),
|
|
197
|
+
stoppedAt: disc.stoppedAt?.toISOString(),
|
|
198
|
+
cwd: disc.cwd ?? launchMeta?.cwd,
|
|
199
|
+
model: disc.model ?? launchMeta?.model,
|
|
200
|
+
prompt: disc.prompt ?? launchMeta?.prompt,
|
|
201
|
+
tokens: disc.tokens,
|
|
202
|
+
cost: disc.cost,
|
|
203
|
+
pid: disc.pid,
|
|
204
|
+
spec: launchMeta?.spec,
|
|
205
|
+
group: launchMeta?.group,
|
|
206
|
+
meta: disc.nativeMetadata ?? launchMeta?.meta ?? {},
|
|
207
|
+
};
|
|
322
208
|
}
|
|
323
209
|
function sessionToRecord(session, adapterName) {
|
|
324
210
|
return {
|
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
|
-
}
|