agent-office-cli 0.1.7 → 0.1.9

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/README.md ADDED
@@ -0,0 +1,168 @@
1
+ # agent-office-cli
2
+
3
+ `agent-office-cli` is the local runtime for AgentOffice.
4
+
5
+ It starts the local session manager, restores tmux-backed AI workers, connects your machine to the hosted relay, and lets you launch or attach to Claude Code / Codex sessions from the terminal.
6
+
7
+ ## What This Package Does
8
+
9
+ - Starts the local AgentOffice service with `ato start`
10
+ - Restores existing tmux-backed workers after restart
11
+ - Connects your machine to `agentoffice.top` or a custom relay
12
+ - Launches Claude Code and Codex workers with one command
13
+ - Exposes local worker terminals to the AgentOffice web or mobile UI
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm i -g agent-office-cli
19
+ ```
20
+
21
+ ## Requirements
22
+
23
+ - Node.js 18+
24
+ - `tmux`
25
+ - Claude Code and/or Codex CLI if you want to launch those providers
26
+
27
+ Example on macOS:
28
+
29
+ ```bash
30
+ brew install tmux
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ 1. Create an API key on `https://agentoffice.top`
36
+ 2. Start the local runtime
37
+ 3. Launch workers from another terminal
38
+
39
+ Using an environment variable:
40
+
41
+ ```bash
42
+ export AGENTOFFICE_API_KEY=sk_your_api_key
43
+ ato start
44
+ ```
45
+
46
+ Or pass the key directly:
47
+
48
+ ```bash
49
+ ato start --key sk_your_api_key
50
+ ```
51
+
52
+ Launch workers:
53
+
54
+ ```bash
55
+ ato claude
56
+ ato codex
57
+ ```
58
+
59
+ Launch with a custom title:
60
+
61
+ ```bash
62
+ ato claude -t "Review PR #42"
63
+ ato codex -t "Fix login bug"
64
+ ```
65
+
66
+ Attach your local terminal to an existing worker:
67
+
68
+ ```bash
69
+ ato attach <sessionId>
70
+ ```
71
+
72
+ ## Common Commands
73
+
74
+ ### `ato start`
75
+
76
+ Starts the local AgentOffice runtime, restores managed sessions, and connects to the hosted relay when an API key is present.
77
+
78
+ Examples:
79
+
80
+ ```bash
81
+ ato start
82
+ ato start --key sk_your_api_key
83
+ ato start --key sk_your_api_key --relay https://your-relay.example.com
84
+ ```
85
+
86
+ Notes:
87
+
88
+ - On macOS, `ato start` keeps the machine awake while the tunnel is active
89
+ - Hosted tunnel logs are written to `~/.agentoffice/logs/tunnel.log`
90
+
91
+ ### `ato claude`
92
+
93
+ Launches a Claude Code worker in tmux so it can be supervised from AgentOffice.
94
+
95
+ ```bash
96
+ ato claude
97
+ ato claude -t "Investigate flaky test"
98
+ ```
99
+
100
+ ### `ato codex`
101
+
102
+ Launches a Codex worker in tmux.
103
+
104
+ ```bash
105
+ ato codex
106
+ ato codex -t "Refactor websocket retry logic"
107
+ ```
108
+
109
+ ### `ato attach`
110
+
111
+ Attaches your local shell directly to a worker's tmux session.
112
+
113
+ ```bash
114
+ ato attach <sessionId>
115
+ ```
116
+
117
+ ## Hosted Mode
118
+
119
+ When you start the runtime with an API key, the CLI opens a secure tunnel from your local machine to the AgentOffice relay.
120
+
121
+ That tunnel is used to:
122
+
123
+ - show your workers in the Office UI
124
+ - open remote terminals from web or mobile
125
+ - launch workers from the AgentOffice interface
126
+ - proxy requests into the local runtime
127
+
128
+ If the connection drops, the tunnel automatically retries and records reconnect details in the local tunnel log.
129
+
130
+ ## Troubleshooting
131
+
132
+ ### `tmux is required`
133
+
134
+ Install `tmux` first, then run `ato start` again.
135
+
136
+ ### Worker launch commands are missing
137
+
138
+ Make sure the provider CLI is installed and available on `PATH`:
139
+
140
+ - `claude`
141
+ - `codex`
142
+
143
+ ### Remote Office shows offline
144
+
145
+ Check the local tunnel log:
146
+
147
+ ```bash
148
+ tail -n 100 ~/.agentoffice/logs/tunnel.log
149
+ ```
150
+
151
+ Look for:
152
+
153
+ - websocket connection errors
154
+ - reconnect attempts
155
+ - auth failures
156
+ - relay disconnect reasons
157
+
158
+ ## Package Scope
159
+
160
+ This package is the CLI/runtime portion of AgentOffice only.
161
+
162
+ It does not include the full web app source or product docs. Those live in the main repository:
163
+
164
+ - GitHub: `https://github.com/fakeou/agent-office`
165
+
166
+ ## License
167
+
168
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-office-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Run and manage AI agent sessions locally, with optional relay to agentoffice.top",
5
5
  "license": "MIT",
6
6
  "engines": {
package/src/tunnel.js CHANGED
@@ -4,6 +4,9 @@ const { createTunnelLogger, describeWebSocketClose } = require("./runtime/tunnel
4
4
 
5
5
  const RECONNECT_BASE_MS = 1000;
6
6
  const RECONNECT_MAX_MS = 30000;
7
+ const AUTH_RESPONSE_TIMEOUT_MS = 15000;
8
+ const STALE_UPSTREAM_TIMEOUT_MS = 70000;
9
+ const WATCHDOG_INTERVAL_MS = 5000;
7
10
  const LOCAL_PROXY_STRIP_HEADERS = new Set([
8
11
  "accept-encoding",
9
12
  "connection",
@@ -41,12 +44,49 @@ function buildLocalRequestHeaders(headers, localServerUrl) {
41
44
  return nextHeaders;
42
45
  }
43
46
 
44
- function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunnelLogger() }) {
47
+ const TERMINAL_AUTH_REASONS = new Set([
48
+ "invalid_key",
49
+ "key_revoked"
50
+ ]);
51
+
52
+ function isTerminalAuthFailure({ code, reason, error }) {
53
+ if (error) {
54
+ return TERMINAL_AUTH_REASONS.has(error);
55
+ }
56
+ if (code !== 4401) {
57
+ return false;
58
+ }
59
+ return TERMINAL_AUTH_REASONS.has(reason);
60
+ }
61
+
62
+ function closeSocket(socket) {
63
+ if (!socket) {
64
+ return;
65
+ }
66
+ if (typeof socket.terminate === "function") {
67
+ socket.terminate();
68
+ return;
69
+ }
70
+ socket.close();
71
+ }
72
+
73
+ function createTunnelClient({
74
+ key,
75
+ relayUrl,
76
+ localServerUrl,
77
+ logger = createTunnelLogger(),
78
+ reconnectBaseMs = RECONNECT_BASE_MS,
79
+ reconnectMaxMs = RECONNECT_MAX_MS,
80
+ authResponseTimeoutMs = AUTH_RESPONSE_TIMEOUT_MS,
81
+ staleUpstreamTimeoutMs = STALE_UPSTREAM_TIMEOUT_MS,
82
+ watchdogIntervalMs = WATCHDOG_INTERVAL_MS
83
+ }) {
45
84
  let ws = null;
46
- let reconnectDelay = RECONNECT_BASE_MS;
85
+ let reconnectDelay = reconnectBaseMs;
47
86
  let stopped = false;
48
87
  let authenticated = false;
49
88
  let pendingStatusSummary = [];
89
+ let reconnectTimer = null;
50
90
 
51
91
  function flushStatusSummary() {
52
92
  if (!authenticated || !ws || ws.readyState !== WebSocket.OPEN) {
@@ -58,6 +98,20 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
58
98
  }));
59
99
  }
60
100
 
101
+ function scheduleReconnect() {
102
+ if (stopped || reconnectTimer) {
103
+ return;
104
+ }
105
+
106
+ const delay = reconnectDelay;
107
+ reconnectTimer = setTimeout(() => {
108
+ reconnectTimer = null;
109
+ reconnectDelay = Math.min(reconnectDelay * 2, reconnectMaxMs);
110
+ connect();
111
+ }, delay);
112
+ reconnectTimer.unref?.();
113
+ }
114
+
61
115
  function connect() {
62
116
  if (stopped) {
63
117
  return;
@@ -65,16 +119,56 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
65
119
 
66
120
  authenticated = false;
67
121
  const url = `${relayUrl.replace(/^http/, "ws")}/upstream`;
68
- ws = new WebSocket(url);
122
+ const socket = new WebSocket(url);
123
+ let socketAuthenticated = false;
124
+ let lastActivityAt = Date.now();
69
125
 
70
- ws.on("open", () => {
71
- reconnectDelay = RECONNECT_BASE_MS;
126
+ function markActivity() {
127
+ lastActivityAt = Date.now();
128
+ }
129
+
130
+ const watchdogTimer = setInterval(() => {
131
+ if (stopped || ws !== socket) {
132
+ return;
133
+ }
134
+
135
+ const idleForMs = Date.now() - lastActivityAt;
136
+ if (!socketAuthenticated) {
137
+ if (idleForMs < authResponseTimeoutMs) {
138
+ return;
139
+ }
140
+ logger.error(`[tunnel] auth response timeout after ${idleForMs}ms. Terminating socket and retrying.`);
141
+ closeSocket(socket);
142
+ return;
143
+ }
144
+
145
+ if (idleForMs < staleUpstreamTimeoutMs) {
146
+ return;
147
+ }
148
+
149
+ logger.error(`[tunnel] upstream stale for ${idleForMs}ms. Terminating socket and reconnecting.`);
150
+ closeSocket(socket);
151
+ }, watchdogIntervalMs);
152
+ watchdogTimer.unref?.();
153
+
154
+ ws = socket;
155
+
156
+ socket.on("open", () => {
157
+ markActivity();
72
158
  logger.info("[tunnel] connected to relay, authenticating...");
73
- ws.send(JSON.stringify({ type: "auth", key }));
159
+ socket.send(JSON.stringify({ type: "auth", key }));
74
160
  });
75
161
 
76
- ws.on("message", async (raw) => {
162
+ socket.on("ping", markActivity);
163
+ socket.on("pong", markActivity);
164
+
165
+ socket.on("message", async (raw) => {
77
166
  try {
167
+ if (ws !== socket) {
168
+ return;
169
+ }
170
+
171
+ markActivity();
78
172
  const str = String(raw);
79
173
 
80
174
  // Fast path: WS data forwarding uses "W:${connId}:${data}" prefix (no JSON parse)
@@ -91,15 +185,19 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
91
185
  const msg = JSON.parse(str);
92
186
 
93
187
  if (msg.type === "auth:ok") {
188
+ socketAuthenticated = true;
94
189
  authenticated = true;
190
+ reconnectDelay = reconnectBaseMs;
95
191
  logger.info(`[tunnel] authenticated with relay: ${relayUrl} (userId=${msg.userId})`);
96
192
  flushStatusSummary();
97
193
  return;
98
194
  }
99
195
  if (msg.type === "auth:error") {
100
196
  logger.error(`[tunnel] authentication failed: ${msg.error || "invalid key"}`);
101
- stopped = true;
102
- ws.close();
197
+ if (isTerminalAuthFailure({ error: msg.error })) {
198
+ stopped = true;
199
+ }
200
+ socket.close();
103
201
  return;
104
202
  }
105
203
 
@@ -113,26 +211,27 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
113
211
  }
114
212
  });
115
213
 
116
- ws.on("close", (code, reasonBuffer) => {
214
+ socket.on("close", (code, reasonBuffer) => {
215
+ clearInterval(watchdogTimer);
216
+ if (ws === socket) {
217
+ ws = null;
218
+ }
117
219
  const reason = Buffer.isBuffer(reasonBuffer) ? reasonBuffer.toString("utf8") : String(reasonBuffer || "");
118
220
  const closeDetails = describeWebSocketClose({ code, reason });
119
221
  if (stopped) {
120
222
  logger.info(`[tunnel] stopped with close ${closeDetails}`);
121
223
  return;
122
224
  }
123
- if (code === 4401) {
225
+ if (isTerminalAuthFailure({ code, reason })) {
124
226
  logger.error(`[tunnel] authentication rejected by relay (${closeDetails}). Not reconnecting.`);
125
227
  stopped = true;
126
228
  return;
127
229
  }
128
230
  logger.info(`[tunnel] disconnected (${closeDetails}). Reconnecting in ${reconnectDelay}ms...`);
129
- setTimeout(() => {
130
- reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
131
- connect();
132
- }, reconnectDelay);
231
+ scheduleReconnect();
133
232
  });
134
233
 
135
- ws.on("error", (err) => {
234
+ socket.on("error", (err) => {
136
235
  logger.error(`[tunnel] ws error: ${err.message}`);
137
236
  });
138
237
  }
@@ -242,6 +341,10 @@ function createTunnelClient({ key, relayUrl, localServerUrl, logger = createTunn
242
341
 
243
342
  function stop() {
244
343
  stopped = true;
344
+ if (reconnectTimer) {
345
+ clearTimeout(reconnectTimer);
346
+ reconnectTimer = null;
347
+ }
245
348
  for (const localWs of localWsConnections.values()) {
246
349
  localWs.close();
247
350
  }
@@ -1,7 +1,10 @@
1
1
  const test = require("node:test");
2
2
  const assert = require("node:assert/strict");
3
+ const { once } = require("node:events");
4
+ const { createServer } = require("node:http");
5
+ const { WebSocketServer } = require("ws");
3
6
 
4
- const { buildLocalRequestHeaders } = require("./tunnel");
7
+ const { buildLocalRequestHeaders, createTunnelClient } = require("./tunnel");
5
8
 
6
9
  test("buildLocalRequestHeaders strips browser-only proxy headers and rewrites host", () => {
7
10
  const next = buildLocalRequestHeaders(
@@ -29,3 +32,167 @@ test("buildLocalRequestHeaders strips browser-only proxy headers and rewrites ho
29
32
  host: "127.0.0.1:8765"
30
33
  });
31
34
  });
35
+
36
+ async function withRelaySocketServer(run) {
37
+ const server = createServer();
38
+ const wss = new WebSocketServer({ server });
39
+
40
+ server.listen(0, "127.0.0.1");
41
+ await once(server, "listening");
42
+
43
+ const address = server.address();
44
+ const relayUrl = `http://127.0.0.1:${address.port}`;
45
+
46
+ try {
47
+ await run({ relayUrl, wss });
48
+ } finally {
49
+ await new Promise((resolve) => wss.close(resolve));
50
+ await new Promise((resolve, reject) => {
51
+ server.close((error) => {
52
+ if (error) {
53
+ reject(error);
54
+ return;
55
+ }
56
+ resolve();
57
+ });
58
+ });
59
+ }
60
+ }
61
+
62
+ function createNoopLogger() {
63
+ return {
64
+ info() {},
65
+ error() {}
66
+ };
67
+ }
68
+
69
+ async function waitFor(predicate, timeoutMs = 500) {
70
+ const startedAt = Date.now();
71
+ while (!predicate()) {
72
+ if (Date.now() - startedAt > timeoutMs) {
73
+ return false;
74
+ }
75
+ await new Promise((resolve) => setTimeout(resolve, 10));
76
+ }
77
+ return true;
78
+ }
79
+
80
+ test("createTunnelClient reconnects when auth never completes", async () => {
81
+ await withRelaySocketServer(async ({ relayUrl, wss }) => {
82
+ const connections = [];
83
+ wss.on("connection", (socket) => {
84
+ connections.push(socket);
85
+ socket.on("message", () => {
86
+ // Intentionally ignore auth messages to simulate a stalled handshake.
87
+ });
88
+ });
89
+
90
+ const tunnel = createTunnelClient({
91
+ key: "test-key",
92
+ relayUrl,
93
+ localServerUrl: "http://127.0.0.1:8765",
94
+ logger: createNoopLogger(),
95
+ reconnectBaseMs: 20,
96
+ reconnectMaxMs: 40,
97
+ authResponseTimeoutMs: 50,
98
+ watchdogIntervalMs: 10
99
+ });
100
+
101
+ try {
102
+ const reconnected = await waitFor(() => connections.length >= 2);
103
+ assert.ok(reconnected, `expected reconnect after stalled auth, saw ${connections.length} connection(s)`);
104
+ } finally {
105
+ tunnel.stop();
106
+ }
107
+ });
108
+ });
109
+
110
+ test("createTunnelClient reconnects when an authenticated tunnel goes silent", async () => {
111
+ await withRelaySocketServer(async ({ relayUrl, wss }) => {
112
+ const connections = [];
113
+ wss.on("connection", (socket) => {
114
+ connections.push(socket);
115
+ socket.once("message", () => {
116
+ socket.send(JSON.stringify({ type: "auth:ok", userId: "user_test" }));
117
+ // Intentionally stay silent after auth: no ping, no messages.
118
+ });
119
+ });
120
+
121
+ const tunnel = createTunnelClient({
122
+ key: "test-key",
123
+ relayUrl,
124
+ localServerUrl: "http://127.0.0.1:8765",
125
+ logger: createNoopLogger(),
126
+ reconnectBaseMs: 20,
127
+ reconnectMaxMs: 40,
128
+ staleUpstreamTimeoutMs: 60,
129
+ watchdogIntervalMs: 10
130
+ });
131
+
132
+ try {
133
+ const reconnected = await waitFor(() => connections.length >= 2);
134
+ assert.ok(reconnected, `expected reconnect after stale upstream, saw ${connections.length} connection(s)`);
135
+ } finally {
136
+ tunnel.stop();
137
+ }
138
+ });
139
+ });
140
+
141
+ test("createTunnelClient retries after relay auth timeout closes the socket", async () => {
142
+ await withRelaySocketServer(async ({ relayUrl, wss }) => {
143
+ const connections = [];
144
+ wss.on("connection", (socket) => {
145
+ connections.push(socket);
146
+ socket.once("message", () => {
147
+ socket.close(4401, "auth_timeout");
148
+ });
149
+ });
150
+
151
+ const tunnel = createTunnelClient({
152
+ key: "test-key",
153
+ relayUrl,
154
+ localServerUrl: "http://127.0.0.1:8765",
155
+ logger: createNoopLogger(),
156
+ reconnectBaseMs: 20,
157
+ reconnectMaxMs: 40,
158
+ watchdogIntervalMs: 10
159
+ });
160
+
161
+ try {
162
+ const reconnected = await waitFor(() => connections.length >= 2);
163
+ assert.ok(reconnected, `expected reconnect after auth_timeout, saw ${connections.length} connection(s)`);
164
+ } finally {
165
+ tunnel.stop();
166
+ }
167
+ });
168
+ });
169
+
170
+ test("createTunnelClient stops retrying after invalid key is rejected", async () => {
171
+ await withRelaySocketServer(async ({ relayUrl, wss }) => {
172
+ const connections = [];
173
+ wss.on("connection", (socket) => {
174
+ connections.push(socket);
175
+ socket.once("message", () => {
176
+ socket.send(JSON.stringify({ type: "auth:error", error: "invalid_key" }));
177
+ socket.close(4401, "invalid_key");
178
+ });
179
+ });
180
+
181
+ const tunnel = createTunnelClient({
182
+ key: "test-key",
183
+ relayUrl,
184
+ localServerUrl: "http://127.0.0.1:8765",
185
+ logger: createNoopLogger(),
186
+ reconnectBaseMs: 20,
187
+ reconnectMaxMs: 40,
188
+ watchdogIntervalMs: 10
189
+ });
190
+
191
+ try {
192
+ await new Promise((resolve) => setTimeout(resolve, 120));
193
+ assert.equal(connections.length, 1);
194
+ } finally {
195
+ tunnel.stop();
196
+ }
197
+ });
198
+ });