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 ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "agent-office-cli",
3
+ "version": "0.0.1",
4
+ "description": "Run and manage AI agent sessions locally, with optional relay to agentoffice.top",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": ">=18"
8
+ },
9
+ "bin": {
10
+ "ato": "src/index.js"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "keywords": [
16
+ "ai",
17
+ "agent",
18
+ "claude",
19
+ "codex",
20
+ "tmux",
21
+ "cli"
22
+ ],
23
+ "dependencies": {
24
+ "express": "^4.21.2",
25
+ "node-pty": "^1.0.0",
26
+ "ws": "^8.18.0"
27
+ }
28
+ }
package/src/auth.js ADDED
@@ -0,0 +1,178 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+ const os = require("node:os");
5
+
6
+ const TOKEN_DIR = path.join(os.homedir(), ".agentoffice");
7
+ const TOKEN_PATH = path.join(TOKEN_DIR, "token");
8
+ const TOKEN_BYTES = 32;
9
+
10
+ let cachedToken = null;
11
+
12
+ function ensureTokenDir() {
13
+ if (!fs.existsSync(TOKEN_DIR)) {
14
+ fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
15
+ }
16
+ }
17
+
18
+ function generateToken() {
19
+ return crypto.randomBytes(TOKEN_BYTES).toString("hex");
20
+ }
21
+
22
+ function loadOrCreateToken() {
23
+ ensureTokenDir();
24
+ if (fs.existsSync(TOKEN_PATH)) {
25
+ cachedToken = fs.readFileSync(TOKEN_PATH, "utf8").trim();
26
+ if (cachedToken.length > 0) {
27
+ return cachedToken;
28
+ }
29
+ }
30
+ cachedToken = generateToken();
31
+ fs.writeFileSync(TOKEN_PATH, cachedToken + "\n", { mode: 0o600 });
32
+ return cachedToken;
33
+ }
34
+
35
+ function resetToken() {
36
+ ensureTokenDir();
37
+ cachedToken = generateToken();
38
+ fs.writeFileSync(TOKEN_PATH, cachedToken + "\n", { mode: 0o600 });
39
+ return cachedToken;
40
+ }
41
+
42
+ function setToken(token) {
43
+ ensureTokenDir();
44
+ cachedToken = token;
45
+ fs.writeFileSync(TOKEN_PATH, cachedToken + "\n", { mode: 0o600 });
46
+ return cachedToken;
47
+ }
48
+
49
+ function getToken() {
50
+ if (!cachedToken) {
51
+ loadOrCreateToken();
52
+ }
53
+ return cachedToken;
54
+ }
55
+
56
+ function verifyToken(input) {
57
+ const expected = getToken();
58
+ if (typeof input !== "string" || input.length === 0) {
59
+ return false;
60
+ }
61
+ const inputBuf = Buffer.from(input);
62
+ const expectedBuf = Buffer.from(expected);
63
+ if (inputBuf.length !== expectedBuf.length) {
64
+ return false;
65
+ }
66
+ return crypto.timingSafeEqual(inputBuf, expectedBuf);
67
+ }
68
+
69
+ // --- Rate limiter ---
70
+
71
+ const loginAttempts = new Map();
72
+ const RATE_WINDOW_MS = 60 * 1000;
73
+ const MAX_ATTEMPTS_PER_WINDOW = 5;
74
+ const LOCKOUT_THRESHOLD = 10;
75
+ const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
76
+
77
+ function getAttemptRecord(ip) {
78
+ const now = Date.now();
79
+ let record = loginAttempts.get(ip);
80
+ if (!record) {
81
+ record = { attempts: [], failures: 0, lockedUntil: 0 };
82
+ loginAttempts.set(ip, record);
83
+ }
84
+ record.attempts = record.attempts.filter((t) => now - t < RATE_WINDOW_MS);
85
+ return record;
86
+ }
87
+
88
+ function checkRateLimit(ip) {
89
+ const now = Date.now();
90
+ const record = getAttemptRecord(ip);
91
+ if (record.lockedUntil > now) {
92
+ const remainingSeconds = Math.ceil((record.lockedUntil - now) / 1000);
93
+ return { allowed: false, locked: true, remainingSeconds, remaining: 0 };
94
+ }
95
+ if (record.attempts.length >= MAX_ATTEMPTS_PER_WINDOW) {
96
+ return { allowed: false, locked: false, remainingSeconds: 0, remaining: 0 };
97
+ }
98
+ return { allowed: true, locked: false, remainingSeconds: 0, remaining: MAX_ATTEMPTS_PER_WINDOW - record.attempts.length };
99
+ }
100
+
101
+ function recordAttempt(ip, success) {
102
+ const now = Date.now();
103
+ const record = getAttemptRecord(ip);
104
+ record.attempts.push(now);
105
+ if (success) {
106
+ record.failures = 0;
107
+ record.lockedUntil = 0;
108
+ } else {
109
+ record.failures += 1;
110
+ if (record.failures >= LOCKOUT_THRESHOLD) {
111
+ record.lockedUntil = now + LOCKOUT_DURATION_MS;
112
+ }
113
+ }
114
+ }
115
+
116
+ // --- Cookie helpers ---
117
+
118
+ const COOKIE_NAME = "agentoffice_token";
119
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60;
120
+
121
+ function parseCookies(header) {
122
+ const cookies = {};
123
+ if (!header) return cookies;
124
+ header.split(";").forEach((pair) => {
125
+ const [name, ...rest] = pair.trim().split("=");
126
+ if (name) {
127
+ cookies[name.trim()] = decodeURIComponent(rest.join("=").trim());
128
+ }
129
+ });
130
+ return cookies;
131
+ }
132
+
133
+ function getTokenFromCookie(req) {
134
+ const header = req.headers?.cookie || "";
135
+ const cookies = parseCookies(header);
136
+ return cookies[COOKIE_NAME] || null;
137
+ }
138
+
139
+ function setAuthCookie(res, token, secure) {
140
+ const parts = [
141
+ `${COOKIE_NAME}=${encodeURIComponent(token)}`,
142
+ `Path=/`,
143
+ `HttpOnly`,
144
+ `SameSite=Strict`,
145
+ `Max-Age=${COOKIE_MAX_AGE}`
146
+ ];
147
+ if (secure) {
148
+ parts.push("Secure");
149
+ }
150
+ res.setHeader("Set-Cookie", parts.join("; "));
151
+ }
152
+
153
+ function clearAuthCookie(res) {
154
+ const parts = [
155
+ `${COOKIE_NAME}=`,
156
+ `Path=/`,
157
+ `HttpOnly`,
158
+ `SameSite=Strict`,
159
+ `Max-Age=0`
160
+ ];
161
+ res.setHeader("Set-Cookie", parts.join("; "));
162
+ }
163
+
164
+ module.exports = {
165
+ TOKEN_PATH,
166
+ loadOrCreateToken,
167
+ resetToken,
168
+ setToken,
169
+ getToken,
170
+ verifyToken,
171
+ checkRateLimit,
172
+ recordAttempt,
173
+ getTokenFromCookie,
174
+ setAuthCookie,
175
+ clearAuthCookie,
176
+ parseCookies,
177
+ COOKIE_NAME
178
+ };
@@ -0,0 +1,13 @@
1
+ const DEFAULT_HOST = "127.0.0.1";
2
+ const DEFAULT_LAN_HOST = "0.0.0.0";
3
+ const DEFAULT_PORT = 8765;
4
+ const DEFAULT_SERVER_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`;
5
+ const LOG_LIMIT = 1000;
6
+
7
+ module.exports = {
8
+ DEFAULT_HOST,
9
+ DEFAULT_LAN_HOST,
10
+ DEFAULT_PORT,
11
+ DEFAULT_SERVER_URL,
12
+ LOG_LIMIT
13
+ };
@@ -0,0 +1,36 @@
1
+ const { createSessionStore } = require("./store/session-store");
2
+ const { getProvider } = require("./providers");
3
+ const { ClaudeProvider, printClaudeHooksConfig } = require("./providers/claude");
4
+ const { CodexProvider } = require("./providers/codex");
5
+ const { GenericProvider } = require("./providers/generic");
6
+ const { BaseProvider } = require("./providers/base");
7
+ const { DISPLAY_STATES, DISPLAY_ZONES, displayZoneFor } = require("./state");
8
+ const { CONTRACT_VERSION, toPublicSession, toSessionSummary } = require("./session-contract");
9
+ const {
10
+ DEFAULT_HOST,
11
+ DEFAULT_LAN_HOST,
12
+ DEFAULT_PORT,
13
+ DEFAULT_SERVER_URL,
14
+ LOG_LIMIT
15
+ } = require("./config");
16
+
17
+ module.exports = {
18
+ createSessionStore,
19
+ getProvider,
20
+ ClaudeProvider,
21
+ printClaudeHooksConfig,
22
+ CodexProvider,
23
+ GenericProvider,
24
+ BaseProvider,
25
+ DISPLAY_STATES,
26
+ DISPLAY_ZONES,
27
+ displayZoneFor,
28
+ CONTRACT_VERSION,
29
+ toPublicSession,
30
+ toSessionSummary,
31
+ DEFAULT_HOST,
32
+ DEFAULT_LAN_HOST,
33
+ DEFAULT_PORT,
34
+ DEFAULT_SERVER_URL,
35
+ LOG_LIMIT
36
+ };
@@ -0,0 +1,38 @@
1
+ class BaseProvider {
2
+ constructor(name) {
3
+ this.name = name;
4
+ }
5
+
6
+ createSession(payload) {
7
+ return {
8
+ provider: this.name,
9
+ title: payload.title,
10
+ command: payload.command,
11
+ cwd: payload.cwd,
12
+ mode: payload.mode || "managed",
13
+ transport: payload.transport || "pty",
14
+ state: "idle",
15
+ status: "registered",
16
+ meta: payload.meta || {}
17
+ };
18
+ }
19
+
20
+ classifyOutput() {
21
+ return null;
22
+ }
23
+
24
+ reconcileSession() {
25
+ return null;
26
+ }
27
+
28
+ onExit({ exitCode }) {
29
+ if (exitCode === 0) {
30
+ return { state: "idle", status: "completed" };
31
+ }
32
+ return { state: "attention", status: "attention" };
33
+ }
34
+ }
35
+
36
+ module.exports = {
37
+ BaseProvider
38
+ };
@@ -0,0 +1,126 @@
1
+ const fs = require("node:fs");
2
+
3
+ const INTERRUPT_MARKERS = [
4
+ "[Request interrupted by user]",
5
+ "[Request interrupted by user for tool use]"
6
+ ];
7
+
8
+ function readTranscriptTail(filePath, bytes = 65536) {
9
+ try {
10
+ const stats = fs.statSync(filePath);
11
+ const start = Math.max(0, stats.size - bytes);
12
+ const length = stats.size - start;
13
+ const fd = fs.openSync(filePath, "r");
14
+ const buffer = Buffer.alloc(length);
15
+ fs.readSync(fd, buffer, 0, length, start);
16
+ fs.closeSync(fd);
17
+ return buffer.toString("utf8");
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ function extractRecentEntries(filePath) {
24
+ const text = readTranscriptTail(filePath);
25
+ if (!text) {
26
+ return [];
27
+ }
28
+ return text
29
+ .split("\n")
30
+ .filter(Boolean)
31
+ .slice(-30)
32
+ .map((line) => {
33
+ try {
34
+ return JSON.parse(line);
35
+ } catch {
36
+ return null;
37
+ }
38
+ })
39
+ .filter(Boolean);
40
+ }
41
+
42
+ function isAfter(entry, sinceIso) {
43
+ if (!sinceIso || !entry.timestamp) {
44
+ return true;
45
+ }
46
+ return entry.timestamp > sinceIso;
47
+ }
48
+
49
+ function hasMarkerContent(content) {
50
+ if (typeof content === "string") {
51
+ return INTERRUPT_MARKERS.some((marker) => content.includes(marker));
52
+ }
53
+ if (Array.isArray(content)) {
54
+ return content.some((item) => item && typeof item.text === "string" && INTERRUPT_MARKERS.includes(item.text));
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function isInterruptEntry(entry) {
60
+ return Boolean(entry && entry.type === "user" && entry.message && hasMarkerContent(entry.message.content));
61
+ }
62
+
63
+ function isRejectedToolUseEntry(entry) {
64
+ if (!entry) {
65
+ return false;
66
+ }
67
+ if (entry.toolUseResult === "User rejected tool use") {
68
+ return true;
69
+ }
70
+ const content = entry.message && entry.message.content;
71
+ if (!Array.isArray(content)) {
72
+ return false;
73
+ }
74
+ return content.some((item) => {
75
+ if (!item || item.type !== "tool_result" || !item.is_error || typeof item.content !== "string") {
76
+ return false;
77
+ }
78
+ return item.content.toLowerCase().includes("tool use was rejected");
79
+ });
80
+ }
81
+
82
+ function hasInterruptMarker(filePath) {
83
+ const entries = extractRecentEntries(filePath);
84
+ const lastEntry = entries[entries.length - 1];
85
+ return isInterruptEntry(lastEntry);
86
+ }
87
+
88
+ function detectPermissionResolution(filePath, sinceIso) {
89
+ const entries = extractRecentEntries(filePath);
90
+ const relevantEntries = sinceIso
91
+ ? entries.filter((entry) => isAfter(entry, sinceIso))
92
+ : entries.slice(-3);
93
+
94
+ const rejectedEntry = [...relevantEntries].reverse().find((entry) => isRejectedToolUseEntry(entry));
95
+ if (rejectedEntry) {
96
+ return {
97
+ eventName: "transcript_permission_denied",
98
+ state: "idle",
99
+ meta: {
100
+ transcriptPath: filePath,
101
+ timestamp: rejectedEntry.timestamp || null,
102
+ reason: "Claude transcript recorded a denied tool-use approval without a follow-up hook state change."
103
+ }
104
+ };
105
+ }
106
+
107
+ const interruptEntry = [...relevantEntries].reverse().find((entry) => isInterruptEntry(entry));
108
+ if (interruptEntry) {
109
+ return {
110
+ eventName: "transcript_interrupt",
111
+ state: "idle",
112
+ meta: {
113
+ transcriptPath: filePath,
114
+ timestamp: interruptEntry.timestamp || null,
115
+ reason: "Claude transcript recorded a user interrupt without a follow-up hook state change."
116
+ }
117
+ };
118
+ }
119
+
120
+ return null;
121
+ }
122
+
123
+ module.exports = {
124
+ detectPermissionResolution,
125
+ hasInterruptMarker
126
+ };
@@ -0,0 +1,199 @@
1
+ const path = require("node:path");
2
+ const { BaseProvider } = require("./base");
3
+ const { detectPermissionResolution, hasInterruptMarker } = require("./claude-transcript");
4
+
5
+ function approvalTimestampFor(session) {
6
+ const events = [...(session.events || [])].reverse();
7
+ const approvalEvent = events.find((event) => {
8
+ if (event.name === "permission_request") {
9
+ return true;
10
+ }
11
+ return event.name === "notification" && ["permission_prompt", "elicitation_dialog"].includes(event.meta && event.meta.notificationType);
12
+ });
13
+ if (session.meta && session.meta.approvalRequestedAt) {
14
+ return session.meta.approvalRequestedAt;
15
+ }
16
+ return approvalEvent ? approvalEvent.timestamp : null;
17
+ }
18
+
19
+ class ClaudeProvider extends BaseProvider {
20
+ constructor() {
21
+ super("claude");
22
+ }
23
+
24
+ createSession(payload) {
25
+ return {
26
+ ...super.createSession(payload),
27
+ mode: payload.mode || "hooked",
28
+ transport: payload.transport || "hook",
29
+ meta: {
30
+ transcriptPath: payload.meta && payload.meta.transcriptPath,
31
+ model: payload.meta && payload.meta.model,
32
+ permissionMode: payload.meta && payload.meta.permissionMode,
33
+ agentType: payload.meta && payload.meta.agentType,
34
+ hookEventName: payload.meta && payload.meta.hookEventName
35
+ }
36
+ };
37
+ }
38
+
39
+ classifyOutput() {
40
+ return null;
41
+ }
42
+
43
+ reconcileSession(session) {
44
+ if (session.status === "exited") {
45
+ return null;
46
+ }
47
+
48
+ const transcriptPath = session.meta && session.meta.transcriptPath;
49
+ if (!transcriptPath) {
50
+ return null;
51
+ }
52
+
53
+ if (session.displayState === "approval") {
54
+ const resolution = detectPermissionResolution(transcriptPath, approvalTimestampFor(session));
55
+ if (resolution) {
56
+ return {
57
+ state: resolution.state,
58
+ patch: { status: "running" },
59
+ eventName: resolution.eventName,
60
+ meta: resolution.meta
61
+ };
62
+ }
63
+ return null;
64
+ }
65
+
66
+ if (session.displayState !== "working" || !hasInterruptMarker(transcriptPath)) {
67
+ return null;
68
+ }
69
+
70
+ return {
71
+ state: "idle",
72
+ patch: { status: "running" },
73
+ eventName: "transcript_interrupt",
74
+ meta: {
75
+ transcriptPath,
76
+ reason: "Claude transcript recorded a user interrupt without a follow-up hook state change."
77
+ }
78
+ };
79
+ }
80
+
81
+ mapHookPayload(payload) {
82
+ const hookEventName = payload.hook_event_name;
83
+ const baseMeta = {
84
+ hookEventName,
85
+ toolName: payload.tool_name || null,
86
+ notificationType: payload.notification_type || null,
87
+ reason: payload.reason || null,
88
+ error: payload.error || null,
89
+ message: payload.message || null,
90
+ isInterrupt: payload.is_interrupt || false,
91
+ transcriptPath: payload.transcript_path || null,
92
+ model: payload.model || null,
93
+ permissionMode: payload.permission_mode || null
94
+ };
95
+
96
+ const result = {
97
+ session: {
98
+ sessionId: payload.session_id,
99
+ provider: "claude",
100
+ title: payload.agent_type ? `Claude ${payload.agent_type}` : `Claude · ${path.basename(payload.cwd || process.cwd())}`,
101
+ command: "claude",
102
+ cwd: payload.cwd || process.cwd(),
103
+ mode: "hooked",
104
+ transport: "hook",
105
+ meta: baseMeta,
106
+ status: "running"
107
+ },
108
+ eventName: null,
109
+ state: null,
110
+ meta: baseMeta
111
+ };
112
+
113
+ switch (hookEventName) {
114
+ case "SessionStart":
115
+ result.eventName = "session_started";
116
+ result.state = "idle";
117
+ break;
118
+ case "UserPromptSubmit":
119
+ result.eventName = "prompt_submitted";
120
+ result.state = "working";
121
+ result.meta.prompt = payload.prompt || null;
122
+ break;
123
+ case "PreToolUse":
124
+ case "SubagentStart":
125
+ result.eventName = hookEventName.toLowerCase();
126
+ result.state = "working";
127
+ result.meta.toolInput = payload.tool_input || null;
128
+ break;
129
+ case "PostToolUse":
130
+ case "SubagentStop":
131
+ result.eventName = hookEventName.toLowerCase();
132
+ result.state = null;
133
+ result.meta.toolInput = payload.tool_input || null;
134
+ break;
135
+ case "PermissionRequest":
136
+ result.eventName = "permission_request";
137
+ result.state = "approval";
138
+ result.meta.toolInput = payload.tool_input || null;
139
+ break;
140
+ case "Notification":
141
+ result.eventName = "notification";
142
+ if (["permission_prompt", "elicitation_dialog"].includes(payload.notification_type)) {
143
+ result.state = "approval";
144
+ } else if (payload.notification_type === "idle_prompt") {
145
+ result.state = "idle";
146
+ } else {
147
+ result.state = null;
148
+ }
149
+ break;
150
+ case "PostToolUseFailure":
151
+ result.eventName = "tool_failure";
152
+ result.state = payload.is_interrupt ? "idle" : "attention";
153
+ result.meta.toolInput = payload.tool_input || null;
154
+ break;
155
+ case "Stop":
156
+ result.eventName = "stop";
157
+ result.state = "idle";
158
+ break;
159
+ case "TaskCompleted":
160
+ result.eventName = "task_completed";
161
+ result.state = "idle";
162
+ result.session.status = "completed";
163
+ break;
164
+ case "SessionEnd":
165
+ result.eventName = "session_ended";
166
+ result.state = "idle";
167
+ result.session.status = "exited";
168
+ break;
169
+ default:
170
+ break;
171
+ }
172
+
173
+ return result;
174
+ }
175
+ }
176
+
177
+ function printClaudeHooksConfig({ handlerPath, serverUrl }) {
178
+ const command = `node ${JSON.stringify(handlerPath)} claude-hook --server ${JSON.stringify(serverUrl)}`;
179
+ const hook = { type: "command", command };
180
+ return {
181
+ hooks: {
182
+ SessionStart: [{ matcher: "*", hooks: [hook] }],
183
+ UserPromptSubmit: [{ hooks: [hook] }],
184
+ PreToolUse: [{ matcher: "*", hooks: [hook] }],
185
+ PermissionRequest: [{ matcher: "*", hooks: [hook] }],
186
+ PostToolUse: [{ matcher: "*", hooks: [hook] }],
187
+ PostToolUseFailure: [{ matcher: "*", hooks: [hook] }],
188
+ Notification: [{ matcher: "*", hooks: [hook] }],
189
+ Stop: [{ hooks: [hook] }],
190
+ TaskCompleted: [{ hooks: [hook] }],
191
+ SessionEnd: [{ matcher: "*", hooks: [hook] }]
192
+ }
193
+ };
194
+ }
195
+
196
+ module.exports = {
197
+ ClaudeProvider,
198
+ printClaudeHooksConfig
199
+ };