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