@orgloop/agentctl 1.0.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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/adapters/claude-code.d.ts +83 -0
- package/dist/adapters/claude-code.js +783 -0
- package/dist/adapters/openclaw.d.ts +88 -0
- package/dist/adapters/openclaw.js +297 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +808 -0
- package/dist/client/daemon-client.d.ts +6 -0
- package/dist/client/daemon-client.js +81 -0
- package/dist/compat-shim.d.ts +2 -0
- package/dist/compat-shim.js +15 -0
- package/dist/core/types.d.ts +68 -0
- package/dist/core/types.js +2 -0
- package/dist/daemon/fuse-engine.d.ts +30 -0
- package/dist/daemon/fuse-engine.js +118 -0
- package/dist/daemon/launchagent.d.ts +7 -0
- package/dist/daemon/launchagent.js +49 -0
- package/dist/daemon/lock-manager.d.ts +16 -0
- package/dist/daemon/lock-manager.js +71 -0
- package/dist/daemon/metrics.d.ts +20 -0
- package/dist/daemon/metrics.js +72 -0
- package/dist/daemon/server.d.ts +33 -0
- package/dist/daemon/server.js +283 -0
- package/dist/daemon/session-tracker.d.ts +28 -0
- package/dist/daemon/session-tracker.js +121 -0
- package/dist/daemon/state.d.ts +61 -0
- package/dist/daemon/state.js +126 -0
- package/dist/daemon/supervisor.d.ts +24 -0
- package/dist/daemon/supervisor.js +79 -0
- package/dist/hooks.d.ts +19 -0
- package/dist/hooks.js +39 -0
- package/dist/merge.d.ts +24 -0
- package/dist/merge.js +65 -0
- package/dist/migration/migrate-locks.d.ts +5 -0
- package/dist/migration/migrate-locks.js +41 -0
- package/dist/worktree.d.ts +24 -0
- package/dist/worktree.js +65 -0
- package/package.json +60 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AgentAdapter, AgentSession, LaunchOpts, LifecycleEvent, ListOpts, PeekOpts, StopOpts } from "../core/types.js";
|
|
2
|
+
export interface OpenClawAdapterOpts {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
authToken?: string;
|
|
5
|
+
/** Override for testing — replaces the real WebSocket RPC call */
|
|
6
|
+
rpcCall?: RpcCallFn;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Shape of a single RPC exchange: send method+params, get back the payload.
|
|
10
|
+
* Injected in tests to avoid a real WebSocket connection.
|
|
11
|
+
*/
|
|
12
|
+
export type RpcCallFn = (method: string, params: Record<string, unknown>) => Promise<unknown>;
|
|
13
|
+
/** Row returned by the gateway's `sessions.list` method */
|
|
14
|
+
export interface GatewaySessionRow {
|
|
15
|
+
key: string;
|
|
16
|
+
kind: "direct" | "group" | "global" | "unknown";
|
|
17
|
+
label?: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
derivedTitle?: string;
|
|
20
|
+
lastMessagePreview?: string;
|
|
21
|
+
channel?: string;
|
|
22
|
+
updatedAt: number | null;
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
inputTokens?: number;
|
|
25
|
+
outputTokens?: number;
|
|
26
|
+
totalTokens?: number;
|
|
27
|
+
model?: string;
|
|
28
|
+
modelProvider?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Result envelope from `sessions.list` */
|
|
31
|
+
export interface SessionsListResult {
|
|
32
|
+
ts: number;
|
|
33
|
+
path: string;
|
|
34
|
+
count: number;
|
|
35
|
+
defaults: {
|
|
36
|
+
modelProvider: string | null;
|
|
37
|
+
model: string | null;
|
|
38
|
+
contextTokens: number | null;
|
|
39
|
+
};
|
|
40
|
+
sessions: GatewaySessionRow[];
|
|
41
|
+
}
|
|
42
|
+
/** Single preview entry from `sessions.preview` */
|
|
43
|
+
export interface SessionsPreviewEntry {
|
|
44
|
+
key: string;
|
|
45
|
+
status: "ok" | "empty" | "missing" | "error";
|
|
46
|
+
items: Array<{
|
|
47
|
+
role: string;
|
|
48
|
+
text: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
/** Result envelope from `sessions.preview` */
|
|
52
|
+
export interface SessionsPreviewResult {
|
|
53
|
+
ts: number;
|
|
54
|
+
previews: SessionsPreviewEntry[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* OpenClaw adapter — reads session data from the OpenClaw gateway via
|
|
58
|
+
* its WebSocket RPC protocol. Falls back gracefully when the gateway
|
|
59
|
+
* is unreachable.
|
|
60
|
+
*/
|
|
61
|
+
export declare class OpenClawAdapter implements AgentAdapter {
|
|
62
|
+
readonly id = "openclaw";
|
|
63
|
+
private readonly baseUrl;
|
|
64
|
+
private readonly authToken;
|
|
65
|
+
private readonly rpcCall;
|
|
66
|
+
constructor(opts?: OpenClawAdapterOpts);
|
|
67
|
+
list(opts?: ListOpts): Promise<AgentSession[]>;
|
|
68
|
+
peek(sessionId: string, opts?: PeekOpts): Promise<string>;
|
|
69
|
+
status(sessionId: string): Promise<AgentSession>;
|
|
70
|
+
launch(_opts: LaunchOpts): Promise<AgentSession>;
|
|
71
|
+
stop(_sessionId: string, _opts?: StopOpts): Promise<void>;
|
|
72
|
+
resume(sessionId: string, _message: string): Promise<void>;
|
|
73
|
+
events(): AsyncIterable<LifecycleEvent>;
|
|
74
|
+
/**
|
|
75
|
+
* Map a gateway session row to the standard AgentSession interface.
|
|
76
|
+
* OpenClaw sessions with a recent updatedAt are considered "running".
|
|
77
|
+
*/
|
|
78
|
+
private mapRowToSession;
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a sessionId (or prefix) to a gateway session key.
|
|
81
|
+
*/
|
|
82
|
+
private resolveKey;
|
|
83
|
+
/**
|
|
84
|
+
* Real WebSocket RPC call — connects, performs handshake, sends one
|
|
85
|
+
* request, reads the response, then disconnects.
|
|
86
|
+
*/
|
|
87
|
+
private defaultRpcCall;
|
|
88
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:18789";
|
|
3
|
+
/**
|
|
4
|
+
* OpenClaw adapter — reads session data from the OpenClaw gateway via
|
|
5
|
+
* its WebSocket RPC protocol. Falls back gracefully when the gateway
|
|
6
|
+
* is unreachable.
|
|
7
|
+
*/
|
|
8
|
+
export class OpenClawAdapter {
|
|
9
|
+
id = "openclaw";
|
|
10
|
+
baseUrl;
|
|
11
|
+
authToken;
|
|
12
|
+
rpcCall;
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
this.baseUrl = opts?.baseUrl || DEFAULT_BASE_URL;
|
|
15
|
+
this.authToken =
|
|
16
|
+
opts?.authToken || process.env.OPENCLAW_WEBHOOK_TOKEN || "";
|
|
17
|
+
this.rpcCall = opts?.rpcCall || this.defaultRpcCall.bind(this);
|
|
18
|
+
}
|
|
19
|
+
async list(opts) {
|
|
20
|
+
let result;
|
|
21
|
+
try {
|
|
22
|
+
result = (await this.rpcCall("sessions.list", {
|
|
23
|
+
includeDerivedTitles: true,
|
|
24
|
+
includeLastMessage: true,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Gateway unreachable — return empty
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
let sessions = result.sessions.map((row) => this.mapRowToSession(row, result.defaults));
|
|
32
|
+
if (opts?.status) {
|
|
33
|
+
sessions = sessions.filter((s) => s.status === opts.status);
|
|
34
|
+
}
|
|
35
|
+
if (!opts?.all && !opts?.status) {
|
|
36
|
+
sessions = sessions.filter((s) => s.status === "running" || s.status === "idle");
|
|
37
|
+
}
|
|
38
|
+
return sessions;
|
|
39
|
+
}
|
|
40
|
+
async peek(sessionId, opts) {
|
|
41
|
+
const key = await this.resolveKey(sessionId);
|
|
42
|
+
if (!key)
|
|
43
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
44
|
+
const limit = opts?.lines ?? 20;
|
|
45
|
+
let result;
|
|
46
|
+
try {
|
|
47
|
+
result = (await this.rpcCall("sessions.preview", {
|
|
48
|
+
keys: [key],
|
|
49
|
+
limit,
|
|
50
|
+
maxChars: 4000,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new Error(`Failed to peek session ${sessionId}: ${err.message}`);
|
|
55
|
+
}
|
|
56
|
+
const preview = result.previews?.[0];
|
|
57
|
+
if (!preview || preview.status === "missing") {
|
|
58
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
59
|
+
}
|
|
60
|
+
if (preview.items.length === 0)
|
|
61
|
+
return "(no messages)";
|
|
62
|
+
const assistantMessages = preview.items
|
|
63
|
+
.filter((item) => item.role === "assistant")
|
|
64
|
+
.map((item) => item.text);
|
|
65
|
+
if (assistantMessages.length === 0)
|
|
66
|
+
return "(no assistant messages)";
|
|
67
|
+
return assistantMessages.slice(-limit).join("\n---\n");
|
|
68
|
+
}
|
|
69
|
+
async status(sessionId) {
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = (await this.rpcCall("sessions.list", {
|
|
73
|
+
includeDerivedTitles: true,
|
|
74
|
+
search: sessionId,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw new Error(`Failed to get status for ${sessionId}: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
const row = result.sessions.find((s) => s.sessionId === sessionId ||
|
|
81
|
+
s.key === sessionId ||
|
|
82
|
+
s.sessionId?.startsWith(sessionId) ||
|
|
83
|
+
s.key.startsWith(sessionId));
|
|
84
|
+
if (!row)
|
|
85
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
86
|
+
return this.mapRowToSession(row, result.defaults);
|
|
87
|
+
}
|
|
88
|
+
async launch(_opts) {
|
|
89
|
+
throw new Error("OpenClaw sessions cannot be launched via agentctl");
|
|
90
|
+
}
|
|
91
|
+
async stop(_sessionId, _opts) {
|
|
92
|
+
throw new Error("OpenClaw sessions cannot be stopped via agentctl");
|
|
93
|
+
}
|
|
94
|
+
async resume(sessionId, _message) {
|
|
95
|
+
// OpenClaw sessions receive messages through their configured channels,
|
|
96
|
+
// not through a direct CLI interface.
|
|
97
|
+
throw new Error(`Cannot resume OpenClaw session ${sessionId} — use the gateway UI or configured channel`);
|
|
98
|
+
}
|
|
99
|
+
async *events() {
|
|
100
|
+
// Poll-based diffing (same pattern as claude-code)
|
|
101
|
+
let knownSessions = new Map();
|
|
102
|
+
// Initial snapshot
|
|
103
|
+
const initial = await this.list({ all: true });
|
|
104
|
+
for (const s of initial) {
|
|
105
|
+
knownSessions.set(s.id, s);
|
|
106
|
+
}
|
|
107
|
+
while (true) {
|
|
108
|
+
await sleep(5000);
|
|
109
|
+
let current;
|
|
110
|
+
try {
|
|
111
|
+
current = await this.list({ all: true });
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const currentMap = new Map(current.map((s) => [s.id, s]));
|
|
117
|
+
for (const [id, session] of currentMap) {
|
|
118
|
+
const prev = knownSessions.get(id);
|
|
119
|
+
if (!prev) {
|
|
120
|
+
yield {
|
|
121
|
+
type: "session.started",
|
|
122
|
+
adapter: this.id,
|
|
123
|
+
sessionId: id,
|
|
124
|
+
session,
|
|
125
|
+
timestamp: new Date(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
else if (prev.status === "running" && session.status === "stopped") {
|
|
129
|
+
yield {
|
|
130
|
+
type: "session.stopped",
|
|
131
|
+
adapter: this.id,
|
|
132
|
+
sessionId: id,
|
|
133
|
+
session,
|
|
134
|
+
timestamp: new Date(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
else if (prev.status === "running" && session.status === "idle") {
|
|
138
|
+
yield {
|
|
139
|
+
type: "session.idle",
|
|
140
|
+
adapter: this.id,
|
|
141
|
+
sessionId: id,
|
|
142
|
+
session,
|
|
143
|
+
timestamp: new Date(),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
knownSessions = currentMap;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// --- Private helpers ---
|
|
151
|
+
/**
|
|
152
|
+
* Map a gateway session row to the standard AgentSession interface.
|
|
153
|
+
* OpenClaw sessions with a recent updatedAt are considered "running".
|
|
154
|
+
*/
|
|
155
|
+
mapRowToSession(row, defaults) {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
const updatedAt = row.updatedAt ?? 0;
|
|
158
|
+
const ageMs = now - updatedAt;
|
|
159
|
+
// Consider "running" if updated in the last 5 minutes
|
|
160
|
+
const isActive = updatedAt > 0 && ageMs < 5 * 60 * 1000;
|
|
161
|
+
const model = row.model || defaults.model || undefined;
|
|
162
|
+
const input = row.inputTokens ?? 0;
|
|
163
|
+
const output = row.outputTokens ?? 0;
|
|
164
|
+
return {
|
|
165
|
+
id: row.sessionId || row.key,
|
|
166
|
+
adapter: this.id,
|
|
167
|
+
status: isActive ? "running" : "idle",
|
|
168
|
+
startedAt: updatedAt > 0 ? new Date(updatedAt) : new Date(),
|
|
169
|
+
cwd: undefined,
|
|
170
|
+
model,
|
|
171
|
+
prompt: row.derivedTitle || row.displayName || row.label,
|
|
172
|
+
tokens: input || output ? { in: input, out: output } : undefined,
|
|
173
|
+
meta: {
|
|
174
|
+
key: row.key,
|
|
175
|
+
kind: row.kind,
|
|
176
|
+
channel: row.channel,
|
|
177
|
+
displayName: row.displayName,
|
|
178
|
+
modelProvider: row.modelProvider || defaults.modelProvider,
|
|
179
|
+
lastMessagePreview: row.lastMessagePreview,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a sessionId (or prefix) to a gateway session key.
|
|
185
|
+
*/
|
|
186
|
+
async resolveKey(sessionId) {
|
|
187
|
+
let result;
|
|
188
|
+
try {
|
|
189
|
+
result = (await this.rpcCall("sessions.list", {
|
|
190
|
+
search: sessionId,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const row = result.sessions.find((s) => s.sessionId === sessionId ||
|
|
197
|
+
s.key === sessionId ||
|
|
198
|
+
s.sessionId?.startsWith(sessionId) ||
|
|
199
|
+
s.key.startsWith(sessionId));
|
|
200
|
+
return row?.key ?? null;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Real WebSocket RPC call — connects, performs handshake, sends one
|
|
204
|
+
* request, reads the response, then disconnects.
|
|
205
|
+
*/
|
|
206
|
+
async defaultRpcCall(method, params) {
|
|
207
|
+
// Dynamic import so tests can inject a mock without loading ws
|
|
208
|
+
const { WebSocket } = await import("ws").catch(() => {
|
|
209
|
+
// Fall back to globalThis.WebSocket (available in Node 22+)
|
|
210
|
+
return { WebSocket: globalThis.WebSocket };
|
|
211
|
+
});
|
|
212
|
+
const wsUrl = this.baseUrl.replace(/^http/, "ws");
|
|
213
|
+
const ws = new WebSocket(wsUrl);
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
const timeout = setTimeout(() => {
|
|
216
|
+
ws.close();
|
|
217
|
+
reject(new Error("OpenClaw gateway connection timed out"));
|
|
218
|
+
}, 10_000);
|
|
219
|
+
const reqId = randomUUID();
|
|
220
|
+
let connected = false;
|
|
221
|
+
ws.onopen = () => {
|
|
222
|
+
// Wait for challenge event, then send connect
|
|
223
|
+
};
|
|
224
|
+
ws.onmessage = (event) => {
|
|
225
|
+
try {
|
|
226
|
+
const raw = typeof event.data === "string" ? event.data : String(event.data);
|
|
227
|
+
const frame = JSON.parse(raw);
|
|
228
|
+
// Step 1: Receive challenge, send connect
|
|
229
|
+
if (frame.type === "event" && frame.event === "connect.challenge") {
|
|
230
|
+
ws.send(JSON.stringify({
|
|
231
|
+
type: "req",
|
|
232
|
+
id: randomUUID(),
|
|
233
|
+
method: "connect",
|
|
234
|
+
params: {
|
|
235
|
+
minProtocol: 1,
|
|
236
|
+
maxProtocol: 1,
|
|
237
|
+
client: {
|
|
238
|
+
id: "agentctl",
|
|
239
|
+
version: "0.1.0",
|
|
240
|
+
platform: process.platform,
|
|
241
|
+
mode: "cli",
|
|
242
|
+
},
|
|
243
|
+
role: "operator",
|
|
244
|
+
scopes: ["operator.read"],
|
|
245
|
+
auth: { token: this.authToken || null },
|
|
246
|
+
},
|
|
247
|
+
}));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// Step 2: Receive hello-ok, send actual RPC
|
|
251
|
+
if (frame.type === "res" && frame.ok && !connected) {
|
|
252
|
+
connected = true;
|
|
253
|
+
ws.send(JSON.stringify({
|
|
254
|
+
type: "req",
|
|
255
|
+
id: reqId,
|
|
256
|
+
method,
|
|
257
|
+
params,
|
|
258
|
+
}));
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Step 3: Receive RPC response
|
|
262
|
+
if (frame.type === "res" && frame.id === reqId) {
|
|
263
|
+
clearTimeout(timeout);
|
|
264
|
+
ws.close();
|
|
265
|
+
if (frame.ok) {
|
|
266
|
+
resolve(frame.payload);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
reject(new Error(frame.error?.message || `RPC error: ${method}`));
|
|
270
|
+
}
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Auth failure
|
|
274
|
+
if (frame.type === "res" && !frame.ok && !connected) {
|
|
275
|
+
clearTimeout(timeout);
|
|
276
|
+
ws.close();
|
|
277
|
+
reject(new Error(frame.error?.message || "OpenClaw gateway auth failed"));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Ignore malformed frames
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
ws.onerror = (err) => {
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
reject(new Error(`OpenClaw gateway error: ${err?.message || "connection failed"}`));
|
|
287
|
+
};
|
|
288
|
+
ws.onclose = () => {
|
|
289
|
+
clearTimeout(timeout);
|
|
290
|
+
// Only reject if we haven't resolved yet
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function sleep(ms) {
|
|
296
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
297
|
+
}
|
package/dist/cli.d.ts
ADDED