agent-office-cli 0.0.1
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/package.json +28 -0
- package/src/auth.js +178 -0
- package/src/core/config.js +13 -0
- package/src/core/index.js +36 -0
- package/src/core/providers/base.js +38 -0
- package/src/core/providers/claude-transcript.js +126 -0
- package/src/core/providers/claude.js +199 -0
- package/src/core/providers/codex-transcript.js +210 -0
- package/src/core/providers/codex.js +91 -0
- package/src/core/providers/generic.js +40 -0
- package/src/core/providers/index.js +17 -0
- package/src/core/session-contract.js +98 -0
- package/src/core/state.js +23 -0
- package/src/core/store/session-store.js +232 -0
- package/src/index.js +348 -0
- package/src/runtime/cli-helpers.js +90 -0
- package/src/runtime/ensure-node-pty.js +49 -0
- package/src/runtime/index.js +54 -0
- package/src/runtime/pty-manager.js +598 -0
- package/src/runtime/session-registry.js +74 -0
- package/src/runtime/tmux.js +152 -0
- package/src/server.js +208 -0
- package/src/tunnel.js +224 -0
- package/src/web/index.js +7 -0
- package/src/web/public/app.js +713 -0
- package/src/web/public/dashboard.html +245 -0
- package/src/web/public/index.html +84 -0
- package/src/web/public/login.css +833 -0
- package/src/web/public/login.html +28 -0
- package/src/web/public/office.html +22 -0
- package/src/web/public/register.html +316 -0
- package/src/web/public/styles.css +988 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const pty = require("node-pty");
|
|
4
|
+
const { getProvider } = require("../core");
|
|
5
|
+
const {
|
|
6
|
+
AGENTOFFICE_TMUX_PREFIX,
|
|
7
|
+
attachClient,
|
|
8
|
+
capturePane,
|
|
9
|
+
createTmuxSession,
|
|
10
|
+
describePane,
|
|
11
|
+
killSession,
|
|
12
|
+
localAttachCommand,
|
|
13
|
+
sessionExists
|
|
14
|
+
} = require("./tmux");
|
|
15
|
+
const { listSessionRecords, persistSessionRecord, removeSessionRecord } = require("./session-registry");
|
|
16
|
+
|
|
17
|
+
function nextSessionId() {
|
|
18
|
+
return `sess_${crypto.randomBytes(5).toString("hex")}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function defaultTransportForProvider(providerName) {
|
|
22
|
+
if (providerName === "generic") {
|
|
23
|
+
return "pty";
|
|
24
|
+
}
|
|
25
|
+
return "tmux";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function initialManagedState() {
|
|
29
|
+
return "idle";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createPtyManager({ store }) {
|
|
33
|
+
const sessions = new Map();
|
|
34
|
+
const eventsClients = new Set();
|
|
35
|
+
const terminalClients = new Map();
|
|
36
|
+
|
|
37
|
+
function broadcastEvent(payload) {
|
|
38
|
+
const message = JSON.stringify(payload);
|
|
39
|
+
for (const client of eventsClients) {
|
|
40
|
+
if (client.readyState === 1) {
|
|
41
|
+
client.send(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function broadcastTerminal(sessionId, payload) {
|
|
47
|
+
const clients = terminalClients.get(sessionId);
|
|
48
|
+
if (!clients) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const message = JSON.stringify(payload);
|
|
52
|
+
for (const client of clients) {
|
|
53
|
+
if (client.readyState === 1) {
|
|
54
|
+
client.send(message);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
store.emitter.on("session:update", (session) => {
|
|
60
|
+
if (!session) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const terminalBacked = session.transport === "tmux" && session.meta && session.meta.tmuxSession;
|
|
64
|
+
const terminalClosed = ["completed", "exited"].includes(session.status);
|
|
65
|
+
if (terminalBacked && !terminalClosed) {
|
|
66
|
+
persistSessionRecord(session);
|
|
67
|
+
}
|
|
68
|
+
if (terminalBacked && terminalClosed) {
|
|
69
|
+
removeSessionRecord(session.sessionId);
|
|
70
|
+
}
|
|
71
|
+
broadcastEvent({
|
|
72
|
+
type: "session:update",
|
|
73
|
+
session: store.getSessionSummary(session.sessionId)
|
|
74
|
+
});
|
|
75
|
+
broadcastTerminal(session.sessionId, { type: "session:update", session });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
store.emitter.on("session:remove", (payload) => {
|
|
79
|
+
if (!payload || !payload.sessionId) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
removeSessionRecord(payload.sessionId);
|
|
83
|
+
broadcastEvent({ type: "session:remove", sessionId: payload.sessionId });
|
|
84
|
+
broadcastTerminal(payload.sessionId, { type: "session:remove", sessionId: payload.sessionId });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function applyProviderReconcile(session, result) {
|
|
88
|
+
if (!result) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (result.session) {
|
|
93
|
+
store.upsertSession({
|
|
94
|
+
sessionId: session.sessionId,
|
|
95
|
+
...result.session
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const latest = store.getSession(session.sessionId);
|
|
100
|
+
if (!latest) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (result.state && result.state !== latest.displayState) {
|
|
105
|
+
store.setSessionState(session.sessionId, result.state, result.patch || {});
|
|
106
|
+
} else if (result.patch) {
|
|
107
|
+
store.upsertSession({ sessionId: session.sessionId, ...result.patch });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (result.eventName) {
|
|
111
|
+
store.addEvent(session.sessionId, result.eventName, { meta: result.meta || {} });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function markRuntimeExit(sessionId, { exitCode = 0, signal = 0, reason = null, patchOverride = null } = {}) {
|
|
116
|
+
const session = store.getSession(sessionId);
|
|
117
|
+
if (!session) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (["completed", "exited"].includes(session.status)) {
|
|
121
|
+
sessions.delete(sessionId);
|
|
122
|
+
store.removeSession(sessionId);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const provider = getProvider(session.provider);
|
|
126
|
+
const next = patchOverride || provider.onExit({ session, exitCode, signal });
|
|
127
|
+
store.markExit(sessionId, next);
|
|
128
|
+
store.addEvent(sessionId, "session_exited", {
|
|
129
|
+
meta: {
|
|
130
|
+
exitCode,
|
|
131
|
+
signal,
|
|
132
|
+
reason,
|
|
133
|
+
transport: session.transport
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
broadcastTerminal(sessionId, { type: "terminal:exit", exitCode, signal });
|
|
137
|
+
sessions.delete(sessionId);
|
|
138
|
+
const latest = store.getSession(sessionId);
|
|
139
|
+
if (latest && ["completed", "exited"].includes(latest.status)) {
|
|
140
|
+
store.removeSession(sessionId);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createPtyManagedSession({ sessionId, providerName, title, command, cwd }) {
|
|
145
|
+
const provider = getProvider(providerName);
|
|
146
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
147
|
+
const proc = pty.spawn(shell, ["-lc", command], {
|
|
148
|
+
name: "xterm-256color",
|
|
149
|
+
cwd,
|
|
150
|
+
env: process.env,
|
|
151
|
+
cols: 120,
|
|
152
|
+
rows: 32
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const session = store.upsertSession({
|
|
156
|
+
...provider.createSession({ provider: providerName, title, command, cwd, mode: "managed", transport: "pty" }),
|
|
157
|
+
sessionId,
|
|
158
|
+
provider: providerName,
|
|
159
|
+
title,
|
|
160
|
+
command,
|
|
161
|
+
cwd,
|
|
162
|
+
pid: proc.pid,
|
|
163
|
+
transport: "pty",
|
|
164
|
+
state: initialManagedState(providerName),
|
|
165
|
+
status: "running",
|
|
166
|
+
host: os.hostname()
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
sessions.set(session.sessionId, {
|
|
170
|
+
transport: "pty",
|
|
171
|
+
providerName,
|
|
172
|
+
provider,
|
|
173
|
+
pty: proc,
|
|
174
|
+
hasTerminal: true
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
store.addEvent(session.sessionId, "session_started", { meta: { managed: true, transport: "pty" } });
|
|
178
|
+
|
|
179
|
+
proc.onData((chunk) => {
|
|
180
|
+
store.appendOutput(session.sessionId, chunk);
|
|
181
|
+
const nextState = provider.classifyOutput(chunk, store.getSession(session.sessionId));
|
|
182
|
+
if (nextState) {
|
|
183
|
+
store.setSessionState(session.sessionId, nextState, { status: "running" });
|
|
184
|
+
}
|
|
185
|
+
broadcastTerminal(session.sessionId, { type: "terminal:data", data: chunk });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
proc.onExit(({ exitCode, signal }) => {
|
|
189
|
+
markRuntimeExit(session.sessionId, { exitCode, signal, reason: "pty_exit" });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return store.getSession(session.sessionId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function createTmuxManagedSession({ sessionId, providerName, title, command, cwd }) {
|
|
196
|
+
const provider = getProvider(providerName);
|
|
197
|
+
const shell = process.env.SHELL || "/bin/zsh";
|
|
198
|
+
const tmuxSession = `${AGENTOFFICE_TMUX_PREFIX}${sessionId}`;
|
|
199
|
+
|
|
200
|
+
createTmuxSession({
|
|
201
|
+
sessionName: tmuxSession,
|
|
202
|
+
cwd,
|
|
203
|
+
command,
|
|
204
|
+
shell
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const pane = describePane(tmuxSession);
|
|
208
|
+
const session = store.upsertSession({
|
|
209
|
+
...provider.createSession({
|
|
210
|
+
provider: providerName,
|
|
211
|
+
title,
|
|
212
|
+
command,
|
|
213
|
+
cwd,
|
|
214
|
+
mode: "managed",
|
|
215
|
+
transport: "tmux",
|
|
216
|
+
meta: {
|
|
217
|
+
tmuxSession,
|
|
218
|
+
localAttachCommand: localAttachCommand(tmuxSession)
|
|
219
|
+
}
|
|
220
|
+
}),
|
|
221
|
+
sessionId,
|
|
222
|
+
provider: providerName,
|
|
223
|
+
title,
|
|
224
|
+
command,
|
|
225
|
+
cwd,
|
|
226
|
+
pid: pane ? pane.pid : null,
|
|
227
|
+
transport: "tmux",
|
|
228
|
+
state: initialManagedState(providerName),
|
|
229
|
+
status: "running",
|
|
230
|
+
host: os.hostname(),
|
|
231
|
+
meta: {
|
|
232
|
+
tmuxSession,
|
|
233
|
+
localAttachCommand: localAttachCommand(tmuxSession)
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
sessions.set(session.sessionId, {
|
|
238
|
+
transport: "tmux",
|
|
239
|
+
providerName,
|
|
240
|
+
provider,
|
|
241
|
+
tmuxSession,
|
|
242
|
+
cwd,
|
|
243
|
+
hasTerminal: true
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
store.addEvent(session.sessionId, "session_started", {
|
|
247
|
+
meta: {
|
|
248
|
+
managed: true,
|
|
249
|
+
transport: "tmux",
|
|
250
|
+
tmuxSession
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return store.getSession(session.sessionId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function restoreManagedSessions() {
|
|
258
|
+
const restored = [];
|
|
259
|
+
for (const record of listSessionRecords()) {
|
|
260
|
+
if (!record || record.transport !== "tmux" || !record.meta || !record.meta.tmuxSession) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!sessionExists(record.meta.tmuxSession)) {
|
|
264
|
+
removeSessionRecord(record.sessionId);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const provider = getProvider(record.provider);
|
|
269
|
+
const pane = describePane(record.meta.tmuxSession);
|
|
270
|
+
const session = store.upsertSession({
|
|
271
|
+
...provider.createSession({
|
|
272
|
+
provider: record.provider,
|
|
273
|
+
title: record.title,
|
|
274
|
+
command: record.command,
|
|
275
|
+
cwd: record.cwd,
|
|
276
|
+
mode: record.mode || "managed",
|
|
277
|
+
transport: "tmux",
|
|
278
|
+
meta: {
|
|
279
|
+
...(record.meta || {}),
|
|
280
|
+
localAttachCommand: localAttachCommand(record.meta.tmuxSession)
|
|
281
|
+
}
|
|
282
|
+
}),
|
|
283
|
+
sessionId: record.sessionId,
|
|
284
|
+
provider: record.provider,
|
|
285
|
+
title: record.title,
|
|
286
|
+
command: record.command,
|
|
287
|
+
cwd: record.cwd,
|
|
288
|
+
mode: record.mode || "managed",
|
|
289
|
+
transport: "tmux",
|
|
290
|
+
state: record.state || "working",
|
|
291
|
+
status: "running",
|
|
292
|
+
createdAt: record.createdAt,
|
|
293
|
+
updatedAt: record.updatedAt,
|
|
294
|
+
pid: pane ? pane.pid : null,
|
|
295
|
+
host: record.host || os.hostname(),
|
|
296
|
+
meta: {
|
|
297
|
+
...(record.meta || {}),
|
|
298
|
+
localAttachCommand: localAttachCommand(record.meta.tmuxSession)
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
sessions.set(session.sessionId, {
|
|
303
|
+
transport: "tmux",
|
|
304
|
+
providerName: session.provider,
|
|
305
|
+
provider,
|
|
306
|
+
tmuxSession: session.meta.tmuxSession,
|
|
307
|
+
cwd: session.cwd,
|
|
308
|
+
hasTerminal: true
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
store.addEvent(session.sessionId, "session_restored", {
|
|
312
|
+
meta: {
|
|
313
|
+
transport: "tmux",
|
|
314
|
+
tmuxSession: session.meta.tmuxSession
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
restored.push(store.getSession(session.sessionId));
|
|
318
|
+
}
|
|
319
|
+
return restored;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
setInterval(async () => {
|
|
323
|
+
const currentSessions = store.listSessions();
|
|
324
|
+
for (const session of currentSessions) {
|
|
325
|
+
if (["completed", "exited"].includes(session.status)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const runtime = sessions.get(session.sessionId);
|
|
330
|
+
if (session.transport === "tmux" && session.meta && session.meta.tmuxSession && !sessionExists(session.meta.tmuxSession)) {
|
|
331
|
+
markRuntimeExit(session.sessionId, { exitCode: 0, signal: 0, reason: "tmux_session_missing" });
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (runtime && runtime.transport === "tmux") {
|
|
336
|
+
const pane = describePane(runtime.tmuxSession);
|
|
337
|
+
if (pane) {
|
|
338
|
+
if (pane.pid && pane.pid !== session.pid) {
|
|
339
|
+
store.upsertSession({ sessionId: session.sessionId, pid: pane.pid });
|
|
340
|
+
}
|
|
341
|
+
if (pane.dead) {
|
|
342
|
+
killSession(runtime.tmuxSession);
|
|
343
|
+
markRuntimeExit(session.sessionId, {
|
|
344
|
+
exitCode: pane.deadStatus == null ? 0 : pane.deadStatus,
|
|
345
|
+
signal: 0,
|
|
346
|
+
reason: "tmux_pane_dead"
|
|
347
|
+
});
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const screen = await capturePane(runtime.tmuxSession);
|
|
353
|
+
const nextState = runtime.provider.classifyOutput(screen, store.getSession(session.sessionId));
|
|
354
|
+
if (nextState && nextState !== session.displayState) {
|
|
355
|
+
store.setSessionState(session.sessionId, nextState, { status: "running" });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const provider = getProvider(session.provider);
|
|
360
|
+
applyProviderReconcile(session, provider.reconcileSession(session, { sessions: currentSessions }));
|
|
361
|
+
}
|
|
362
|
+
}, 1200);
|
|
363
|
+
|
|
364
|
+
function createManagedSession({ provider: providerName, title, command, cwd, transport }) {
|
|
365
|
+
const sessionId = nextSessionId();
|
|
366
|
+
const resolvedTransport = transport || defaultTransportForProvider(providerName);
|
|
367
|
+
if (resolvedTransport === "tmux") {
|
|
368
|
+
return createTmuxManagedSession({ sessionId, providerName, title, command, cwd });
|
|
369
|
+
}
|
|
370
|
+
return createPtyManagedSession({ sessionId, providerName, title, command, cwd });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function resolveClaudeSessionId(mappedSession) {
|
|
374
|
+
const existingHookSession = store.getSession(mappedSession.sessionId);
|
|
375
|
+
if (existingHookSession) {
|
|
376
|
+
return existingHookSession.sessionId;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const currentSessions = store.listSessions();
|
|
380
|
+
const matchedManaged = currentSessions
|
|
381
|
+
.filter((session) => session.provider === "claude")
|
|
382
|
+
.filter((session) => session.transport === "tmux")
|
|
383
|
+
.filter((session) => session.cwd === mappedSession.cwd)
|
|
384
|
+
.filter((session) => session.status === "running")
|
|
385
|
+
.find((session) => {
|
|
386
|
+
const meta = session.meta || {};
|
|
387
|
+
if (meta.hookSessionId === mappedSession.sessionId) {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
if (meta.hookSessionId) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
return !meta.transcriptPath;
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return matchedManaged ? matchedManaged.sessionId : mappedSession.sessionId;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isClaudePermissionDeny(meta = {}) {
|
|
400
|
+
const text = [meta.reason, meta.message, meta.error]
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.join(" ")
|
|
403
|
+
.toLowerCase();
|
|
404
|
+
return text.includes("permission") && (text.includes("deny") || text.includes("denied") || text.includes("reject") || text.includes("declin"));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function ingestClaudeHook(payload) {
|
|
408
|
+
const provider = getProvider("claude");
|
|
409
|
+
const mapped = provider.mapHookPayload(payload);
|
|
410
|
+
const hookTimestamp = new Date().toISOString();
|
|
411
|
+
const targetSessionId = resolveClaudeSessionId(mapped.session);
|
|
412
|
+
const isManagedTarget = targetSessionId !== mapped.session.sessionId;
|
|
413
|
+
|
|
414
|
+
// Only update sessions started via `ato claude` (managed tmux sessions).
|
|
415
|
+
// Ignore hooks from external Claude processes not launched by AgentOffice.
|
|
416
|
+
if (!isManagedTarget) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const previousSession = store.getSession(targetSessionId);
|
|
421
|
+
const session = store.upsertSession({
|
|
422
|
+
...mapped.session,
|
|
423
|
+
sessionId: targetSessionId,
|
|
424
|
+
mode: "managed",
|
|
425
|
+
transport: "tmux",
|
|
426
|
+
title: undefined,
|
|
427
|
+
command: undefined,
|
|
428
|
+
meta: {
|
|
429
|
+
...(mapped.session.meta || {}),
|
|
430
|
+
hookSessionId: mapped.session.sessionId,
|
|
431
|
+
lastHookAt: hookTimestamp,
|
|
432
|
+
approvalRequestedAt: mapped.state === "approval" ? hookTimestamp : undefined
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
let effectiveState = mapped.state;
|
|
436
|
+
if (!effectiveState && previousSession && previousSession.displayState === "approval") {
|
|
437
|
+
if (mapped.eventName === "posttooluse") {
|
|
438
|
+
effectiveState = "working";
|
|
439
|
+
} else if (mapped.eventName === "tool_failure" && isClaudePermissionDeny(mapped.meta)) {
|
|
440
|
+
effectiveState = "idle";
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (effectiveState) {
|
|
444
|
+
store.setSessionState(session.sessionId, effectiveState, { status: mapped.session.status || session.status });
|
|
445
|
+
}
|
|
446
|
+
if (mapped.eventName) {
|
|
447
|
+
store.addEvent(session.sessionId, mapped.eventName, { meta: mapped.meta });
|
|
448
|
+
}
|
|
449
|
+
if (mapped.session.status === "exited") {
|
|
450
|
+
const runtime = sessions.get(session.sessionId);
|
|
451
|
+
if (runtime && runtime.transport === "tmux") {
|
|
452
|
+
killSession(runtime.tmuxSession);
|
|
453
|
+
markRuntimeExit(session.sessionId, {
|
|
454
|
+
exitCode: 0,
|
|
455
|
+
signal: 0,
|
|
456
|
+
reason: "hook_session_end",
|
|
457
|
+
patchOverride: { state: "idle", status: "exited" }
|
|
458
|
+
});
|
|
459
|
+
return store.getSession(session.sessionId);
|
|
460
|
+
}
|
|
461
|
+
store.markExit(session.sessionId, { status: "exited", state: "idle", displayState: "idle", displayZone: "idle-zone" });
|
|
462
|
+
}
|
|
463
|
+
return store.getSession(session.sessionId);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function registerEventsSocket(ws) {
|
|
467
|
+
eventsClients.add(ws);
|
|
468
|
+
ws.send(JSON.stringify({
|
|
469
|
+
type: "sessions:snapshot",
|
|
470
|
+
sessions: store.listSessionSummaries()
|
|
471
|
+
}));
|
|
472
|
+
ws.on("close", () => {
|
|
473
|
+
eventsClients.delete(ws);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function registerTerminalSocket(sessionId, ws) {
|
|
478
|
+
const set = terminalClients.get(sessionId) || new Set();
|
|
479
|
+
set.add(ws);
|
|
480
|
+
terminalClients.set(sessionId, set);
|
|
481
|
+
ws.send(JSON.stringify({ type: "session:update", session: store.getSession(sessionId) }));
|
|
482
|
+
|
|
483
|
+
const entry = sessions.get(sessionId);
|
|
484
|
+
if (!entry) {
|
|
485
|
+
const session = store.getSession(sessionId);
|
|
486
|
+
const reason = session && session.transport === "hook"
|
|
487
|
+
? "This Claude worker came from hooks only. It updates state in the office but does not own a shared terminal. Launch Claude with `ato claude` if you want terminal control."
|
|
488
|
+
: "No managed terminal transport is attached to this session.";
|
|
489
|
+
ws.send(JSON.stringify({ type: "terminal:unavailable", reason }));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let attachedClient = null;
|
|
493
|
+
let tmuxStreamStarted = false;
|
|
494
|
+
|
|
495
|
+
async function startTmuxStream(cols, rows) {
|
|
496
|
+
if (tmuxStreamStarted || !entry || entry.transport !== "tmux") {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
tmuxStreamStarted = true;
|
|
500
|
+
try {
|
|
501
|
+
const snapshot = await capturePane(entry.tmuxSession);
|
|
502
|
+
if (snapshot && ws.readyState === 1) {
|
|
503
|
+
ws.send(JSON.stringify({ type: "terminal:data", data: `${snapshot}\r\n` }));
|
|
504
|
+
}
|
|
505
|
+
attachedClient = attachClient(entry.tmuxSession, { cwd: entry.cwd, cols, rows });
|
|
506
|
+
attachedClient.onData((chunk) => {
|
|
507
|
+
if (ws.readyState === 1) {
|
|
508
|
+
ws.send(JSON.stringify({ type: "terminal:data", data: chunk }));
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
attachedClient.onExit(({ exitCode, signal }) => {
|
|
512
|
+
if (ws.readyState === 1) {
|
|
513
|
+
ws.send(JSON.stringify({ type: "terminal:exit", exitCode, signal }));
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
} catch (error) {
|
|
517
|
+
ws.send(JSON.stringify({ type: "terminal:error", message: error.message }));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// For non-tmux transports that had immediate setup, keep original behavior
|
|
522
|
+
if (entry && entry.transport === "pty") {
|
|
523
|
+
// PTY sessions stream via broadcastTerminal, no per-client attach needed
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
ws.on("message", async (raw) => {
|
|
527
|
+
try {
|
|
528
|
+
const message = JSON.parse(String(raw));
|
|
529
|
+
const runtime = sessions.get(sessionId);
|
|
530
|
+
if (!runtime) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (message.type === "input") {
|
|
534
|
+
if (runtime.transport === "pty") {
|
|
535
|
+
runtime.pty.write(message.data || "");
|
|
536
|
+
} else if (attachedClient) {
|
|
537
|
+
attachedClient.write(message.data || "");
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (message.type === "resize") {
|
|
542
|
+
const cols = Number(message.cols || 120);
|
|
543
|
+
const rows = Number(message.rows || 32);
|
|
544
|
+
if (runtime.transport === "pty") {
|
|
545
|
+
runtime.pty.resize(cols, rows);
|
|
546
|
+
} else if (!tmuxStreamStarted) {
|
|
547
|
+
// First resize from client — start tmux stream at the correct size
|
|
548
|
+
await startTmuxStream(cols, rows);
|
|
549
|
+
} else if (attachedClient) {
|
|
550
|
+
attachedClient.resize(cols, rows);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch (error) {
|
|
554
|
+
ws.send(JSON.stringify({ type: "terminal:error", message: error.message }));
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
ws.on("close", () => {
|
|
559
|
+
if (attachedClient) {
|
|
560
|
+
// Kill the linked web-view tmux session first, then the PTY process
|
|
561
|
+
if (attachedClient.webTmuxSession) {
|
|
562
|
+
try {
|
|
563
|
+
killSession(attachedClient.webTmuxSession);
|
|
564
|
+
} catch {
|
|
565
|
+
// Linked session may already be gone
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
attachedClient.kill();
|
|
570
|
+
} catch {
|
|
571
|
+
// Ignore already-closed terminal clients.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const clients = terminalClients.get(sessionId);
|
|
575
|
+
if (!clients) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
clients.delete(ws);
|
|
579
|
+
if (clients.size === 0) {
|
|
580
|
+
terminalClients.delete(sessionId);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
createManagedSession,
|
|
587
|
+
defaultTransportForProvider,
|
|
588
|
+
ingestClaudeHook,
|
|
589
|
+
restoreManagedSessions,
|
|
590
|
+
registerEventsSocket,
|
|
591
|
+
registerTerminalSocket
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
module.exports = {
|
|
596
|
+
createPtyManager,
|
|
597
|
+
defaultTransportForProvider
|
|
598
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const REGISTRY_DIR = path.join(os.homedir(), ".agentoffice", "sessions");
|
|
6
|
+
|
|
7
|
+
function ensureRegistryDir() {
|
|
8
|
+
fs.mkdirSync(REGISTRY_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function recordPath(sessionId) {
|
|
12
|
+
return path.join(REGISTRY_DIR, `${sessionId}.json`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function persistSessionRecord(session) {
|
|
16
|
+
if (!session || session.transport !== "tmux" || !session.meta || !session.meta.tmuxSession) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
ensureRegistryDir();
|
|
20
|
+
const record = {
|
|
21
|
+
sessionId: session.sessionId,
|
|
22
|
+
provider: session.provider,
|
|
23
|
+
title: session.title,
|
|
24
|
+
command: session.command,
|
|
25
|
+
cwd: session.cwd,
|
|
26
|
+
mode: session.mode,
|
|
27
|
+
transport: session.transport,
|
|
28
|
+
state: session.state,
|
|
29
|
+
status: session.status,
|
|
30
|
+
createdAt: session.createdAt,
|
|
31
|
+
updatedAt: session.updatedAt,
|
|
32
|
+
host: session.host,
|
|
33
|
+
meta: session.meta
|
|
34
|
+
};
|
|
35
|
+
const filePath = recordPath(session.sessionId);
|
|
36
|
+
fs.writeFileSync(filePath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
37
|
+
return filePath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function removeSessionRecord(sessionId) {
|
|
41
|
+
try {
|
|
42
|
+
fs.unlinkSync(recordPath(sessionId));
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function listSessionRecords() {
|
|
50
|
+
try {
|
|
51
|
+
ensureRegistryDir();
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return fs.readdirSync(REGISTRY_DIR)
|
|
57
|
+
.filter((name) => name.endsWith(".json"))
|
|
58
|
+
.map((name) => path.join(REGISTRY_DIR, name))
|
|
59
|
+
.map((filePath) => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
REGISTRY_DIR,
|
|
71
|
+
listSessionRecords,
|
|
72
|
+
persistSessionRecord,
|
|
73
|
+
removeSessionRecord
|
|
74
|
+
};
|