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.
@@ -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
+ };