agent-office-cli 0.1.0 → 0.1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Run and manage AI agent sessions locally, with optional relay to agentoffice.top",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -1,6 +1,32 @@
1
1
  const { GenericProvider } = require("./generic");
2
2
  const { findManagedCodexSessionFile, summarizeCodexSession } = require("./codex-transcript");
3
3
 
4
+ function activeOverlayPatch(session, nextLifecycleState) {
5
+ if (!session || !["approval", "attention"].includes(session.displayState)) {
6
+ return null;
7
+ }
8
+
9
+ if (nextLifecycleState === "idle") {
10
+ return {
11
+ displayState: "idle",
12
+ displayZone: "idle-zone",
13
+ meta: {
14
+ overlayState: null,
15
+ overlayUpdatedAt: null
16
+ }
17
+ };
18
+ }
19
+
20
+ return {
21
+ displayState: session.displayState,
22
+ displayZone: session.displayZone,
23
+ meta: {
24
+ overlayState: session.displayState,
25
+ overlayUpdatedAt: session.updatedAt || null
26
+ }
27
+ };
28
+ }
29
+
4
30
  class CodexProvider extends GenericProvider {
5
31
  constructor() {
6
32
  super("codex");
@@ -25,7 +51,14 @@ class CodexProvider extends GenericProvider {
25
51
  if (text.includes("approval") || text.includes("press enter") || text.includes("confirm")) {
26
52
  return "approval";
27
53
  }
28
- if (text.includes("error") || text.includes("failed") || text.includes("panic")) {
54
+ if (
55
+ text.includes("network error") ||
56
+ text.includes("connection timeout") ||
57
+ text.includes("timed out") ||
58
+ text.includes("error") ||
59
+ text.includes("failed") ||
60
+ text.includes("panic")
61
+ ) {
29
62
  return "attention";
30
63
  }
31
64
  return null;
@@ -50,7 +83,8 @@ class CodexProvider extends GenericProvider {
50
83
  };
51
84
 
52
85
  const previousPath = session.meta && session.meta.codexSessionPath;
53
- const previousState = session.displayState;
86
+ const overlay = activeOverlayPatch(session, summary.state);
87
+ const previousState = session.state;
54
88
  const previousCursor = session.meta && session.meta.codexTranscriptCursor;
55
89
  const previousLifecycle = session.meta && session.meta.codexLastLifecycle;
56
90
  const lifecycleAdvanced = Boolean(summary.lastTimestamp && summary.lastTimestamp !== previousCursor);
@@ -68,20 +102,32 @@ class CodexProvider extends GenericProvider {
68
102
  return {
69
103
  session: metaChanged
70
104
  ? {
71
- meta: nextMeta
105
+ meta: nextMeta
72
106
  }
73
107
  : null,
74
108
  state: summary.state,
75
- patch: summary.state ? { status: "running" } : null,
76
- eventName: lifecycleAdvanced && summary.lastLifecycle ? `codex_${summary.lastLifecycle}` : null,
77
- meta: lifecycleAdvanced
109
+ patch: summary.state
78
110
  ? {
79
- codexSessionPath: matched.path,
80
- codexSessionId: matched.sessionMeta && matched.sessionMeta.id,
81
- turnId: summary.lastTurnId || null,
82
- lastAgentMessage: summary.lastAgentMessage || null
111
+ status: "running",
112
+ ...(overlay
113
+ ? {
114
+ displayState: overlay.displayState,
115
+ displayZone: overlay.displayZone
116
+ }
117
+ : {})
83
118
  }
84
- : null
119
+ : null,
120
+ eventName: lifecycleAdvanced && summary.lastLifecycle ? `codex_${summary.lastLifecycle}` : null,
121
+ meta:
122
+ lifecycleAdvanced || overlay
123
+ ? {
124
+ codexSessionPath: matched.path,
125
+ codexSessionId: matched.sessionMeta && matched.sessionMeta.id,
126
+ turnId: summary.lastTurnId || null,
127
+ lastAgentMessage: summary.lastAgentMessage || null,
128
+ ...(overlay ? overlay.meta : {})
129
+ }
130
+ : null
85
131
  };
86
132
  }
87
133
  }
@@ -0,0 +1,74 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+ const assert = require("node:assert/strict");
6
+
7
+ const { CodexProvider } = require("./codex");
8
+
9
+ function writeTranscriptFile(entries) {
10
+ const dirPath = fs.mkdtempSync(path.join(os.tmpdir(), "codex-transcript-"));
11
+ const filePath = path.join(dirPath, "session.jsonl");
12
+ fs.writeFileSync(
13
+ filePath,
14
+ `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
15
+ "utf8"
16
+ );
17
+ return filePath;
18
+ }
19
+
20
+ test("reconcileSession keeps approval display overlay when transcript advances to working", () => {
21
+ const transcriptPath = writeTranscriptFile([
22
+ {
23
+ type: "session_meta",
24
+ payload: {
25
+ id: "codex-session-1",
26
+ cwd: process.cwd(),
27
+ timestamp: "2026-03-18T10:00:00.000Z"
28
+ }
29
+ },
30
+ {
31
+ timestamp: "2026-03-18T10:00:01.000Z",
32
+ type: "event_msg",
33
+ payload: {
34
+ type: "task_started",
35
+ turn_id: "turn-1"
36
+ }
37
+ }
38
+ ]);
39
+
40
+ const provider = new CodexProvider();
41
+ const session = provider.createSession({
42
+ cwd: process.cwd(),
43
+ title: "Codex",
44
+ command: "codex",
45
+ meta: {
46
+ codexSessionPath: transcriptPath,
47
+ codexTranscriptCursor: null,
48
+ codexLastLifecycle: null
49
+ }
50
+ });
51
+ session.status = "running";
52
+ session.state = "working";
53
+ session.displayState = "approval";
54
+ session.displayZone = "approval-zone";
55
+ session.updatedAt = "2026-03-18T10:00:02.000Z";
56
+
57
+ const result = provider.reconcileSession(session, { sessions: [session] });
58
+
59
+ assert.ok(result);
60
+ assert.equal(result.state, "working");
61
+ assert.equal(result.patch.displayState, "approval");
62
+ assert.equal(result.patch.displayZone, "approval-zone");
63
+ });
64
+
65
+ test("classifyOutput can raise attention for transcript-backed sessions", () => {
66
+ const provider = new CodexProvider();
67
+ const nextState = provider.classifyOutput("network error: connection timed out", {
68
+ meta: {
69
+ codexSessionPath: "/tmp/mock-codex.jsonl"
70
+ }
71
+ });
72
+
73
+ assert.equal(nextState, "attention");
74
+ });
@@ -1,9 +1,11 @@
1
+ const { displayZoneFor } = require("./state");
2
+
1
3
  const CONTRACT_VERSION = 1;
2
4
 
3
5
  function sessionLifecycle(session) {
4
6
  const status = session.status || "registered";
5
7
  const displayState = session.displayState || session.state || "idle";
6
- const displayZone = session.displayZone || "working-zone";
8
+ const displayZone = session.displayZone || displayZoneFor(displayState);
7
9
 
8
10
  return {
9
11
  status,
@@ -0,0 +1,27 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const { toPublicSession } = require("./session-contract");
5
+
6
+ test("toPublicSession derives displayZone from displayState when zone is missing", () => {
7
+ const session = toPublicSession({
8
+ sessionId: "sess_3",
9
+ provider: "codex",
10
+ title: "Codex",
11
+ command: "codex",
12
+ cwd: process.cwd(),
13
+ mode: "managed",
14
+ transport: "tmux",
15
+ state: "working",
16
+ displayState: "approval",
17
+ status: "running",
18
+ createdAt: "2026-03-18T00:00:00.000Z",
19
+ updatedAt: "2026-03-18T00:00:01.000Z",
20
+ meta: {}
21
+ });
22
+
23
+ assert.equal(session.state, "working");
24
+ assert.equal(session.displayState, "approval");
25
+ assert.equal(session.displayZone, "approval-zone");
26
+ assert.equal(session.lifecycle.displayZone, "approval-zone");
27
+ });
@@ -13,10 +13,14 @@ function cloneSession(session) {
13
13
  return toPublicSession(session);
14
14
  }
15
15
 
16
+ function applyDisplay(session, nextDisplayState, nextDisplayZone = null) {
17
+ session.displayState = nextDisplayState;
18
+ session.displayZone = nextDisplayZone || displayZoneFor(nextDisplayState);
19
+ }
20
+
16
21
  function applyState(session, nextState) {
17
22
  session.state = nextState;
18
- session.displayState = nextState;
19
- session.displayZone = displayZoneFor(nextState);
23
+ applyDisplay(session, nextState);
20
24
  }
21
25
 
22
26
  function createSessionStore() {
@@ -26,6 +30,7 @@ function createSessionStore() {
26
30
  function buildSession(payload) {
27
31
  const sessionId = payload.sessionId || `sess_${crypto.randomBytes(5).toString("hex")}`;
28
32
  const state = payload.state || "idle";
33
+ const displayState = payload.displayState || state;
29
34
  const createdAt = payload.createdAt || isoNow();
30
35
  const updatedAt = payload.updatedAt || createdAt;
31
36
 
@@ -38,8 +43,8 @@ function createSessionStore() {
38
43
  mode: payload.mode || "managed",
39
44
  transport: payload.transport || "pty",
40
45
  state,
41
- displayState: payload.displayState || state,
42
- displayZone: payload.displayZone || displayZoneFor(state),
46
+ displayState,
47
+ displayZone: payload.displayZone || displayZoneFor(displayState),
43
48
  status: payload.status || "registered",
44
49
  createdAt,
45
50
  updatedAt,
@@ -86,10 +91,10 @@ function createSessionStore() {
86
91
  if (payload.state) {
87
92
  applyState(existing, payload.state);
88
93
  }
89
- if (payload.displayState && !payload.state) {
90
- existing.displayState = payload.displayState;
94
+ if (payload.displayState) {
95
+ applyDisplay(existing, payload.displayState, payload.displayZone);
91
96
  }
92
- if (payload.displayZone && !payload.state) {
97
+ if (payload.displayZone && !payload.displayState) {
93
98
  existing.displayZone = payload.displayZone;
94
99
  }
95
100
  if (payload.status) {
@@ -110,9 +115,9 @@ function createSessionStore() {
110
115
  session.updatedAt = isoNow();
111
116
  Object.assign(session, patch);
112
117
  if (patch.displayState) {
113
- session.displayState = patch.displayState;
118
+ applyDisplay(session, patch.displayState, patch.displayZone);
114
119
  }
115
- if (patch.displayZone) {
120
+ if (patch.displayZone && !patch.displayState) {
116
121
  session.displayZone = patch.displayZone;
117
122
  }
118
123
  emitUpdate(sessionId);
@@ -164,13 +169,11 @@ function createSessionStore() {
164
169
  Object.assign(session, patch);
165
170
  if (patch.state) {
166
171
  applyState(session, patch.state);
167
- } else {
168
- if (patch.displayState) {
169
- session.displayState = patch.displayState;
170
- }
171
- if (patch.displayZone) {
172
- session.displayZone = patch.displayZone;
173
- }
172
+ }
173
+ if (patch.displayState) {
174
+ applyDisplay(session, patch.displayState, patch.displayZone);
175
+ } else if (patch.displayZone) {
176
+ session.displayZone = patch.displayZone;
174
177
  }
175
178
  emitUpdate(sessionId);
176
179
  return session;
@@ -0,0 +1,50 @@
1
+ const test = require("node:test");
2
+ const assert = require("node:assert/strict");
3
+
4
+ const { createSessionStore } = require("./session-store");
5
+
6
+ test("setSessionState keeps lifecycle state and applies display override", () => {
7
+ const store = createSessionStore();
8
+ store.upsertSession({
9
+ sessionId: "sess_1",
10
+ provider: "codex",
11
+ title: "Codex",
12
+ command: "codex",
13
+ cwd: process.cwd(),
14
+ state: "idle",
15
+ status: "running"
16
+ });
17
+
18
+ const next = store.setSessionState("sess_1", "working", {
19
+ status: "running",
20
+ displayState: "approval",
21
+ displayZone: "approval-zone"
22
+ });
23
+
24
+ assert.equal(next.state, "working");
25
+ assert.equal(next.displayState, "approval");
26
+ assert.equal(next.displayZone, "approval-zone");
27
+ assert.equal(next.status, "running");
28
+ });
29
+
30
+ test("setSessionState derives displayZone from displayState override when zone is omitted", () => {
31
+ const store = createSessionStore();
32
+ store.upsertSession({
33
+ sessionId: "sess_2",
34
+ provider: "codex",
35
+ title: "Codex",
36
+ command: "codex",
37
+ cwd: process.cwd(),
38
+ state: "idle",
39
+ status: "running"
40
+ });
41
+
42
+ const next = store.setSessionState("sess_2", "working", {
43
+ status: "running",
44
+ displayState: "attention"
45
+ });
46
+
47
+ assert.equal(next.state, "working");
48
+ assert.equal(next.displayState, "attention");
49
+ assert.equal(next.displayZone, "attention-zone");
50
+ });
@@ -0,0 +1,26 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+
6
+ test("root postinstall points at the live ensure-node-pty module", () => {
7
+ const rootDir = path.resolve(__dirname, "../../../../");
8
+ const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
9
+ const postinstall = packageJson.scripts && packageJson.scripts.postinstall;
10
+
11
+ assert.match(postinstall, /packages\/cli\/src\/runtime\/ensure-node-pty/);
12
+ });
13
+
14
+ test("service packages no longer depend on @agent-office/core", () => {
15
+ const rootDir = path.resolve(__dirname, "../../../../");
16
+ const apiPackageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "services/api/package.json"), "utf8"));
17
+ const relayPackageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "services/relay/package.json"), "utf8"));
18
+
19
+ assert.ok(!("@agent-office/core" in (apiPackageJson.dependencies || {})));
20
+ assert.ok(!("@agent-office/core" in (relayPackageJson.dependencies || {})));
21
+ });
22
+
23
+ test("legacy packages/core package has been removed", () => {
24
+ const rootDir = path.resolve(__dirname, "../../../../");
25
+ assert.equal(fs.existsSync(path.join(rootDir, "packages/core")), false);
26
+ });
@@ -1,7 +1,7 @@
1
1
  const crypto = require("node:crypto");
2
2
  const os = require("node:os");
3
3
  const pty = require("node-pty");
4
- const { getProvider } = require("../core");
4
+ const { displayZoneFor, getProvider } = require("../core");
5
5
  const {
6
6
  AGENTOFFICE_TMUX_PREFIX,
7
7
  attachClient,
@@ -350,20 +350,21 @@ function createPtyManager({ store }) {
350
350
  }
351
351
 
352
352
  const provider = getProvider(session.provider);
353
- const reconcileResult = provider.reconcileSession(session, { sessions: currentSessions });
354
- // Only run screen-based classifyOutput when the session has no transcript-driven state.
355
- // For providers like Codex that use a transcript (codexSessionPath set), classifyOutput
356
- // on the raw terminal text is unreliable and fights the transcript state machine, causing
357
- // the session to flicker between "attention" and "idle" every polling tick.
358
- const hasTranscriptState = Boolean(session.meta && session.meta.codexSessionPath);
359
- if (!hasTranscriptState && (!reconcileResult || !reconcileResult.state)) {
360
- const screen = await capturePane(runtime.tmuxSession);
361
- const nextState = runtime.provider.classifyOutput(screen, store.getSession(session.sessionId));
362
- if (nextState && nextState !== session.displayState) {
363
- store.setSessionState(session.sessionId, nextState, { status: "running" });
364
- }
353
+ const screen = await capturePane(runtime.tmuxSession);
354
+ const latestSession = store.getSession(session.sessionId) || session;
355
+ const overlayState = runtime.provider.classifyOutput(screen, latestSession);
356
+
357
+ if (overlayState && overlayState !== latestSession.displayState) {
358
+ store.setSessionState(session.sessionId, latestSession.state || "working", {
359
+ status: "running",
360
+ displayState: overlayState,
361
+ displayZone: displayZoneFor(overlayState)
362
+ });
365
363
  }
366
- applyProviderReconcile(session, reconcileResult);
364
+
365
+ const reconciledSession = store.getSession(session.sessionId) || session;
366
+ const reconcileResult = provider.reconcileSession(reconciledSession, { sessions: currentSessions });
367
+ applyProviderReconcile(reconciledSession, reconcileResult);
367
368
  continue;
368
369
  }
369
370
 
package/src/server.js CHANGED
@@ -1,4 +1,7 @@
1
+ const fs = require("node:fs");
1
2
  const http = require("node:http");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
2
5
  const express = require("express");
3
6
  const { WebSocketServer } = require("ws");
4
7
  const auth = require("./auth");
@@ -112,6 +115,20 @@ function createAppServer({ host, port, store, ptyManager }) {
112
115
  res.json({ ok: true });
113
116
  });
114
117
 
118
+ // Returns home dir + subdirectories of a given path (for working directory picker)
119
+ app.get("/api/dirs", (req, res) => {
120
+ const home = os.homedir();
121
+ const target = String(req.query.path || home);
122
+ let entries = [];
123
+ try {
124
+ entries = fs.readdirSync(target, { withFileTypes: true })
125
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
126
+ .map((e) => path.join(target, e.name))
127
+ .slice(0, 50);
128
+ } catch { /* unreadable dir */ }
129
+ res.json({ home, path: target, dirs: entries });
130
+ });
131
+
115
132
  app.get("/api/sessions", (_req, res) => {
116
133
  res.json({ sessions: store.listSessionSummaries() });
117
134
  });