@scotthuang/agent-knock-knock 0.2.46 → 0.2.47
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/CHANGELOG.md +12 -0
- package/README.md +25 -10
- package/dist/src/approval-policy.d.ts +1 -0
- package/dist/src/approval-policy.js +6 -0
- package/dist/src/approval-policy.js.map +1 -1
- package/dist/src/claude-hook-installer.d.ts +65 -0
- package/dist/src/claude-hook-installer.js +410 -0
- package/dist/src/claude-hook-installer.js.map +1 -0
- package/dist/src/claude-hook-protocol.d.ts +89 -0
- package/dist/src/claude-hook-protocol.js +243 -0
- package/dist/src/claude-hook-protocol.js.map +1 -0
- package/dist/src/claude-hook-store.d.ts +247 -0
- package/dist/src/claude-hook-store.js +1064 -0
- package/dist/src/claude-hook-store.js.map +1 -0
- package/dist/src/claude-terminal-agent-adapter.d.ts +47 -0
- package/dist/src/claude-terminal-agent-adapter.js +920 -0
- package/dist/src/claude-terminal-agent-adapter.js.map +1 -0
- package/dist/src/cli.js +987 -103
- package/dist/src/cli.js.map +1 -1
- package/dist/src/openclaw-plugin.js +6 -6
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/terminal-agent-adapter.d.ts +34 -0
- package/dist/src/terminal-agent-adapter.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +19 -2
- package/dist/src/terminal-agent-bridge.js +140 -15
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-agent-registry.js +3 -1
- package/dist/src/terminal-agent-registry.js.map +1 -1
- package/openclaw.plugin.json +3 -3
- package/package.json +1 -1
- package/templates/openclaw-skills/agent-knock-knock/SKILL.md +16 -13
|
@@ -0,0 +1,1064 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { canonicalClaudePermissionFingerprint, claudePermissionHookOutput, parseClaudeHookInput } from "./claude-hook-protocol.js";
|
|
6
|
+
const STORE_SCHEMA_VERSION = 1;
|
|
7
|
+
const DEFAULT_PERMISSION_LEASE_MS = 5 * 60 * 1000;
|
|
8
|
+
const DEFAULT_MANAGED_LEASE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
10
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
11
|
+
const STALE_LOCK_MS = 30_000;
|
|
12
|
+
export class ClaudeHookStoreError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "ClaudeHookStoreError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function defaultClaudeHookStoreDir() {
|
|
21
|
+
return path.join(os.homedir(), ".agent-knock-knock", "claude-hooks");
|
|
22
|
+
}
|
|
23
|
+
export class ClaudeHookStore {
|
|
24
|
+
rootDir;
|
|
25
|
+
now;
|
|
26
|
+
sleep;
|
|
27
|
+
permissionLeaseMs;
|
|
28
|
+
randomId;
|
|
29
|
+
constructor(options = {}) {
|
|
30
|
+
this.rootDir = options.rootDir ?? defaultClaudeHookStoreDir();
|
|
31
|
+
this.now = options.now ?? (() => new Date());
|
|
32
|
+
this.sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
33
|
+
this.permissionLeaseMs = positiveMilliseconds(options.permissionLeaseMs ?? DEFAULT_PERMISSION_LEASE_MS, "permissionLeaseMs");
|
|
34
|
+
this.randomId = options.randomId ?? randomUUID;
|
|
35
|
+
}
|
|
36
|
+
activateLease(options) {
|
|
37
|
+
validateActivateLeaseOptions(options);
|
|
38
|
+
return this.withLeaseLock(() => {
|
|
39
|
+
const leases = this.readLeases();
|
|
40
|
+
const now = this.nowIso();
|
|
41
|
+
const expiresAt = options.expiresAt === undefined
|
|
42
|
+
? new Date(Date.parse(now) + DEFAULT_MANAGED_LEASE_MS).toISOString()
|
|
43
|
+
: new Date(timestampMs(options.expiresAt, "expiresAt")).toISOString();
|
|
44
|
+
if (Date.parse(expiresAt) <= Date.parse(now)) {
|
|
45
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "managed Claude lease expiresAt must be in the future");
|
|
46
|
+
}
|
|
47
|
+
const existing = leases.find((lease) => !lease.releasedAt &&
|
|
48
|
+
lease.conversationId === options.conversationId &&
|
|
49
|
+
lease.messageId === options.messageId);
|
|
50
|
+
const conflicts = leases.filter((lease) => !lease.releasedAt &&
|
|
51
|
+
Date.parse(lease.expiresAt) > Date.parse(now) &&
|
|
52
|
+
lease.id !== existing?.id &&
|
|
53
|
+
((options.sessionId !== undefined && lease.sessionId === options.sessionId) ||
|
|
54
|
+
(options.pid !== undefined && lease.pid === options.pid)));
|
|
55
|
+
if (conflicts.length > 0) {
|
|
56
|
+
throw new ClaudeHookStoreError("AMBIGUOUS_SESSION", "an active managed Claude lease already owns the exact session or pid");
|
|
57
|
+
}
|
|
58
|
+
const lease = existing
|
|
59
|
+
? Object.assign(existing, {
|
|
60
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
61
|
+
...(options.pid === undefined ? {} : { pid: options.pid }),
|
|
62
|
+
cwd: options.cwd,
|
|
63
|
+
terminalTarget: options.terminalTarget,
|
|
64
|
+
updatedAt: now,
|
|
65
|
+
expiresAt
|
|
66
|
+
})
|
|
67
|
+
: {
|
|
68
|
+
id: uniqueManagedLeaseId(leases, this.randomId),
|
|
69
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
70
|
+
...(options.pid === undefined ? {} : { pid: options.pid }),
|
|
71
|
+
cwd: options.cwd,
|
|
72
|
+
conversationId: options.conversationId,
|
|
73
|
+
messageId: options.messageId,
|
|
74
|
+
terminalTarget: options.terminalTarget,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
expiresAt
|
|
78
|
+
};
|
|
79
|
+
if (!existing) {
|
|
80
|
+
leases.push(lease);
|
|
81
|
+
}
|
|
82
|
+
this.writeLeases(leases);
|
|
83
|
+
return clone(lease);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
resolveLease(inputOrIdentity) {
|
|
87
|
+
const identity = hookInputIdentity(inputOrIdentity);
|
|
88
|
+
validateIdentity(identity);
|
|
89
|
+
return cloneOptional(this.resolveLeaseFrom(this.readLeases(), identity));
|
|
90
|
+
}
|
|
91
|
+
releaseLease(options) {
|
|
92
|
+
validateReleaseLeaseOptions(options);
|
|
93
|
+
return this.withLeaseLock(() => {
|
|
94
|
+
const leases = this.readLeases();
|
|
95
|
+
const matches = leases.filter((lease) => options.leaseId !== undefined
|
|
96
|
+
? lease.id === options.leaseId
|
|
97
|
+
: lease.conversationId === options.conversationId && lease.messageId === options.messageId);
|
|
98
|
+
const activeMatches = matches.filter((lease) => !lease.releasedAt);
|
|
99
|
+
if (activeMatches.length > 1) {
|
|
100
|
+
throw new ClaudeHookStoreError("AMBIGUOUS_SESSION", "multiple managed Claude leases match release identity");
|
|
101
|
+
}
|
|
102
|
+
const lease = activeMatches[0] ?? (matches.length === 1 ? matches[0] : undefined);
|
|
103
|
+
if (!lease) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
if (!lease.releasedAt) {
|
|
107
|
+
const now = this.nowIso();
|
|
108
|
+
lease.releasedAt = now;
|
|
109
|
+
lease.updatedAt = now;
|
|
110
|
+
this.writeLeases(leases);
|
|
111
|
+
}
|
|
112
|
+
return clone(lease);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
record(inputValue, options = {}) {
|
|
116
|
+
const input = parseClaudeHookInput(inputValue);
|
|
117
|
+
const claudePid = optionalPid(options.claudePid);
|
|
118
|
+
const leaseMs = positiveMilliseconds(options.permissionLeaseMs ?? this.permissionLeaseMs, "permissionLeaseMs");
|
|
119
|
+
return this.withSessionLock(input.session_id, () => this.withLeaseLock(() => {
|
|
120
|
+
const receivedAt = this.nowIso();
|
|
121
|
+
const session = this.readSessionById(input.session_id) ?? newSession(input, receivedAt);
|
|
122
|
+
const eventId = this.uniqueEventId(session);
|
|
123
|
+
const lease = this.resolveLeaseFrom(this.readLeases(), {
|
|
124
|
+
sessionId: input.session_id,
|
|
125
|
+
...(claudePid === undefined ? {} : { pid: claudePid }),
|
|
126
|
+
cwd: input.cwd
|
|
127
|
+
}, { ambiguousCwdAsUnmanaged: true });
|
|
128
|
+
if (input.hook_event_name === "SessionStart" &&
|
|
129
|
+
(input.source === "startup" || input.source === "clear" || input.source === "fork")) {
|
|
130
|
+
session.active_turn_id = undefined;
|
|
131
|
+
}
|
|
132
|
+
const turnId = input.hook_event_name === "UserPromptSubmit"
|
|
133
|
+
? input.prompt_id ?? eventId
|
|
134
|
+
: input.prompt_id ?? session.active_turn_id;
|
|
135
|
+
const event = {
|
|
136
|
+
id: eventId,
|
|
137
|
+
received_at: receivedAt,
|
|
138
|
+
...(claudePid === undefined ? {} : { claude_pid: claudePid }),
|
|
139
|
+
...(turnId === undefined ? {} : { turn_id: turnId }),
|
|
140
|
+
...(lease === undefined ? {} : {
|
|
141
|
+
lease_id: lease.lease.id,
|
|
142
|
+
conversation_id: lease.lease.conversationId,
|
|
143
|
+
message_id: lease.lease.messageId
|
|
144
|
+
}),
|
|
145
|
+
input
|
|
146
|
+
};
|
|
147
|
+
session.cwd = input.cwd;
|
|
148
|
+
session.transcript_path = input.transcript_path;
|
|
149
|
+
session.updated_at = receivedAt;
|
|
150
|
+
if (claudePid !== undefined) {
|
|
151
|
+
session.claude_pid = claudePid;
|
|
152
|
+
}
|
|
153
|
+
if (input.hook_event_name === "UserPromptSubmit") {
|
|
154
|
+
session.active_turn_id = turnId;
|
|
155
|
+
}
|
|
156
|
+
session.events.push(event);
|
|
157
|
+
const permission = input.hook_event_name === "PermissionRequest" && lease?.authorizationEligible
|
|
158
|
+
? this.createPendingPermission(session, event, input, leaseMs, lease)
|
|
159
|
+
: undefined;
|
|
160
|
+
if (permission) {
|
|
161
|
+
session.permissions.push(permission);
|
|
162
|
+
}
|
|
163
|
+
this.writeSession(session);
|
|
164
|
+
return clone({
|
|
165
|
+
event,
|
|
166
|
+
session,
|
|
167
|
+
managed: input.hook_event_name === "PermissionRequest"
|
|
168
|
+
? Boolean(permission)
|
|
169
|
+
: lease !== undefined,
|
|
170
|
+
...(permission ? { permission } : {}),
|
|
171
|
+
...(lease ? { lease } : {})
|
|
172
|
+
});
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
resolveSession(identity) {
|
|
176
|
+
validateIdentity(identity);
|
|
177
|
+
if (identity.sessionId) {
|
|
178
|
+
const session = this.readSessionById(identity.sessionId);
|
|
179
|
+
if (!session) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (identity.pid !== undefined && session.claude_pid !== identity.pid) {
|
|
183
|
+
throw new ClaudeHookStoreError("SESSION_IDENTITY_MISMATCH", `Claude session ${identity.sessionId} does not belong to pid ${identity.pid}`);
|
|
184
|
+
}
|
|
185
|
+
if (identity.cwd !== undefined && session.cwd !== identity.cwd) {
|
|
186
|
+
throw new ClaudeHookStoreError("SESSION_IDENTITY_MISMATCH", `Claude session ${identity.sessionId} does not belong to cwd ${identity.cwd}`);
|
|
187
|
+
}
|
|
188
|
+
return clone(session);
|
|
189
|
+
}
|
|
190
|
+
const sessions = this.readAllSessions();
|
|
191
|
+
const matches = identity.pid !== undefined
|
|
192
|
+
? sessions.filter((session) => session.claude_pid === identity.pid)
|
|
193
|
+
: sessions.filter((session) => session.cwd === identity.cwd);
|
|
194
|
+
if (matches.length === 0) {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
if (matches.length > 1 && identity.requireUnique !== false) {
|
|
198
|
+
const label = identity.pid !== undefined ? `pid ${identity.pid}` : `cwd ${identity.cwd}`;
|
|
199
|
+
throw new ClaudeHookStoreError("AMBIGUOUS_SESSION", `multiple Claude sessions match ${label}; use the exact session id`);
|
|
200
|
+
}
|
|
201
|
+
matches.sort((left, right) => Date.parse(right.updated_at) - Date.parse(left.updated_at));
|
|
202
|
+
return clone(matches[0]);
|
|
203
|
+
}
|
|
204
|
+
inspectPermission(identity) {
|
|
205
|
+
const session = this.resolveSession(identity);
|
|
206
|
+
if (!session) {
|
|
207
|
+
return { blocked: false, approvable: false, reason: "no matching Claude hook session" };
|
|
208
|
+
}
|
|
209
|
+
const nowMs = this.nowDate().getTime();
|
|
210
|
+
const managedLeases = new Map(this.readLeases()
|
|
211
|
+
.filter((lease) => lease.releasedAt === undefined && Date.parse(lease.expiresAt) > nowMs)
|
|
212
|
+
.map((lease) => [lease.id, lease]));
|
|
213
|
+
const active = session.permissions.filter((permission) => permission.consumedAt === undefined &&
|
|
214
|
+
Date.parse(permission.expiresAt) > nowMs &&
|
|
215
|
+
managedLeases.get(permission.leaseId)?.conversationId === permission.conversationId &&
|
|
216
|
+
managedLeases.get(permission.leaseId)?.messageId === permission.messageId);
|
|
217
|
+
if (active.length === 0) {
|
|
218
|
+
return { blocked: false, approvable: false, reason: "no unexpired Claude permission request" };
|
|
219
|
+
}
|
|
220
|
+
if (active.length > 1) {
|
|
221
|
+
return {
|
|
222
|
+
blocked: true,
|
|
223
|
+
approvable: false,
|
|
224
|
+
reason: "multiple Claude permission requests are pending; an exact request id is required"
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const permission = active[0];
|
|
228
|
+
if (permission.decision) {
|
|
229
|
+
return {
|
|
230
|
+
blocked: true,
|
|
231
|
+
approvable: false,
|
|
232
|
+
reason: `Claude permission decision ${permission.decision.behavior} is waiting for hook consumption`,
|
|
233
|
+
requestId: permission.requestId,
|
|
234
|
+
fingerprint: permission.fingerprint,
|
|
235
|
+
expiresAt: permission.expiresAt,
|
|
236
|
+
cwd: permission.cwd,
|
|
237
|
+
sessionId: permission.sessionId,
|
|
238
|
+
conversationId: permission.conversationId,
|
|
239
|
+
messageId: permission.messageId
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const command = permission.toolName === "Bash"
|
|
243
|
+
? stringProperty(permission.toolInput, "command")
|
|
244
|
+
: undefined;
|
|
245
|
+
const kind = permission.toolName === "Bash" ? "run_command" : "tool_permission";
|
|
246
|
+
return {
|
|
247
|
+
blocked: true,
|
|
248
|
+
approvable: true,
|
|
249
|
+
reason: `Claude requested permission for ${permission.toolName}`,
|
|
250
|
+
requestId: permission.requestId,
|
|
251
|
+
fingerprint: permission.fingerprint,
|
|
252
|
+
expiresAt: permission.expiresAt,
|
|
253
|
+
eventId: permission.eventId,
|
|
254
|
+
sessionId: permission.sessionId,
|
|
255
|
+
leaseId: permission.leaseId,
|
|
256
|
+
conversationId: permission.conversationId,
|
|
257
|
+
messageId: permission.messageId,
|
|
258
|
+
cwd: permission.cwd,
|
|
259
|
+
toolName: permission.toolName,
|
|
260
|
+
toolInput: clone(permission.toolInput),
|
|
261
|
+
kind,
|
|
262
|
+
promptKind: kind,
|
|
263
|
+
...(command ? { command } : {})
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
decidePermission(options) {
|
|
267
|
+
validateDecisionOptions(options);
|
|
268
|
+
return this.withSessionLock(options.sessionId, () => this.withLeaseLock(() => {
|
|
269
|
+
const session = this.requireSession(options.sessionId);
|
|
270
|
+
const permission = requirePermission(session, options.requestId);
|
|
271
|
+
this.validatePermissionLease(permission, options, this.readLeases());
|
|
272
|
+
if (permission.decision) {
|
|
273
|
+
throw new ClaudeHookStoreError("PERMISSION_ALREADY_DECIDED", `Claude permission request ${options.requestId} already has a decision`);
|
|
274
|
+
}
|
|
275
|
+
const decidedAt = this.nowIso();
|
|
276
|
+
const decision = {
|
|
277
|
+
behavior: options.decision,
|
|
278
|
+
...(options.decision === "deny" && options.interrupt !== undefined
|
|
279
|
+
? { interrupt: options.interrupt }
|
|
280
|
+
: {}),
|
|
281
|
+
decided_at: decidedAt
|
|
282
|
+
};
|
|
283
|
+
permission.decision = decision;
|
|
284
|
+
session.updated_at = decidedAt;
|
|
285
|
+
this.writeSession(session);
|
|
286
|
+
return clone(decision);
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
consumePermissionDecision(options) {
|
|
290
|
+
validateConsumeOptions(options);
|
|
291
|
+
return this.withSessionLock(options.sessionId, () => this.withLeaseLock(() => {
|
|
292
|
+
const session = this.requireSession(options.sessionId);
|
|
293
|
+
const permission = requirePermission(session, options.requestId);
|
|
294
|
+
this.validatePermissionLease(permission, options, this.readLeases(), {
|
|
295
|
+
allowDecidedAfterRelease: true
|
|
296
|
+
});
|
|
297
|
+
if (!permission.decision) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
const consumedAt = this.nowIso();
|
|
301
|
+
permission.consumedAt = consumedAt;
|
|
302
|
+
session.updated_at = consumedAt;
|
|
303
|
+
this.writeSession(session);
|
|
304
|
+
const decision = permission.decision;
|
|
305
|
+
return {
|
|
306
|
+
requestId: permission.requestId,
|
|
307
|
+
fingerprint: permission.fingerprint,
|
|
308
|
+
behavior: decision.behavior,
|
|
309
|
+
...(decision.interrupt === undefined ? {} : { interrupt: decision.interrupt }),
|
|
310
|
+
decidedAt: decision.decided_at,
|
|
311
|
+
consumedAt,
|
|
312
|
+
hookOutput: claudePermissionHookOutput(decision)
|
|
313
|
+
};
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
async waitForPermissionDecision(options) {
|
|
317
|
+
const timeoutMs = options.timeoutMs === undefined
|
|
318
|
+
? this.permissionLeaseRemaining(options)
|
|
319
|
+
: nonNegativeMilliseconds(options.timeoutMs, "timeoutMs");
|
|
320
|
+
const pollIntervalMs = positiveMilliseconds(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, "pollIntervalMs");
|
|
321
|
+
const deadline = this.nowDate().getTime() + timeoutMs;
|
|
322
|
+
while (true) {
|
|
323
|
+
const result = this.consumePermissionDecision(options);
|
|
324
|
+
if (result) {
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
const remaining = deadline - this.nowDate().getTime();
|
|
328
|
+
if (remaining <= 0) {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
await this.sleep(Math.min(pollIntervalMs, remaining));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
detectCompletion(options) {
|
|
335
|
+
const startedAtMs = timestampMs(options.startedAt, "startedAt");
|
|
336
|
+
const hasConversationId = options.conversationId !== undefined;
|
|
337
|
+
const hasMessageId = options.messageId !== undefined;
|
|
338
|
+
if (hasConversationId !== hasMessageId ||
|
|
339
|
+
(hasConversationId && (!options.conversationId || !options.messageId))) {
|
|
340
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "conversationId and messageId must be provided together for Claude completion detection");
|
|
341
|
+
}
|
|
342
|
+
const session = this.resolveSession(options);
|
|
343
|
+
if (!session) {
|
|
344
|
+
return { status: "unknown", reason: "no matching Claude hook session" };
|
|
345
|
+
}
|
|
346
|
+
const completionLease = hasConversationId
|
|
347
|
+
? this.resolveLeaseFrom(this.readLeases(), options)
|
|
348
|
+
: undefined;
|
|
349
|
+
if (hasConversationId && (!completionLease || !completionLease.authorizationEligible)) {
|
|
350
|
+
return {
|
|
351
|
+
status: "unknown",
|
|
352
|
+
reason: "no active strongly-bound managed Claude lease matches the completion identity",
|
|
353
|
+
sessionId: session.session_id
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (completionLease &&
|
|
357
|
+
(completionLease.lease.conversationId !== options.conversationId ||
|
|
358
|
+
completionLease.lease.messageId !== options.messageId)) {
|
|
359
|
+
return {
|
|
360
|
+
status: "unknown",
|
|
361
|
+
reason: "the active managed Claude lease belongs to a different conversation message",
|
|
362
|
+
sessionId: session.session_id
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
const matchesCompletionLease = (event) => completionLease === undefined || (event.lease_id === completionLease.lease.id &&
|
|
366
|
+
event.conversation_id === completionLease.lease.conversationId &&
|
|
367
|
+
event.message_id === completionLease.lease.messageId);
|
|
368
|
+
const prompts = session.events.filter((event) => event.input.hook_event_name === "UserPromptSubmit" &&
|
|
369
|
+
event.turn_id !== undefined &&
|
|
370
|
+
Date.parse(event.received_at) >= startedAtMs &&
|
|
371
|
+
matchesCompletionLease(event) &&
|
|
372
|
+
(options.promptId === undefined ||
|
|
373
|
+
event.turn_id === options.promptId ||
|
|
374
|
+
event.id === options.promptId ||
|
|
375
|
+
event.input.prompt_id === options.promptId));
|
|
376
|
+
if (prompts.length === 0) {
|
|
377
|
+
return {
|
|
378
|
+
status: "unknown",
|
|
379
|
+
reason: options.promptId
|
|
380
|
+
? `no UserPromptSubmit hook event matches prompt ${options.promptId} after startedAt`
|
|
381
|
+
: "no UserPromptSubmit hook event was observed after startedAt",
|
|
382
|
+
sessionId: session.session_id
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
prompts.sort((left, right) => Date.parse(left.received_at) - Date.parse(right.received_at));
|
|
386
|
+
const prompt = prompts[0];
|
|
387
|
+
const promptId = prompt.turn_id;
|
|
388
|
+
const terminalEvents = session.events.filter((event) => event.turn_id === promptId &&
|
|
389
|
+
Date.parse(event.received_at) >= Date.parse(prompt.received_at) &&
|
|
390
|
+
sameManagedMessageBinding(prompt, event) &&
|
|
391
|
+
matchesCompletionLease(event) &&
|
|
392
|
+
(event.input.hook_event_name === "Stop" || event.input.hook_event_name === "StopFailure"));
|
|
393
|
+
if (terminalEvents.length === 0) {
|
|
394
|
+
return {
|
|
395
|
+
status: "pending",
|
|
396
|
+
reason: "the correlated Claude turn has not emitted Stop or StopFailure",
|
|
397
|
+
sessionId: session.session_id,
|
|
398
|
+
promptId
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
terminalEvents.sort((left, right) => Date.parse(left.received_at) - Date.parse(right.received_at));
|
|
402
|
+
const terminalEvent = terminalEvents.at(-1);
|
|
403
|
+
if (terminalEvent.input.hook_event_name === "StopFailure") {
|
|
404
|
+
const failure = {
|
|
405
|
+
code: terminalEvent.input.error,
|
|
406
|
+
...(terminalEvent.input.error_details === undefined
|
|
407
|
+
? {}
|
|
408
|
+
: { details: clone(terminalEvent.input.error_details) }),
|
|
409
|
+
...(terminalEvent.input.last_assistant_message === undefined
|
|
410
|
+
? {}
|
|
411
|
+
: { message: terminalEvent.input.last_assistant_message }),
|
|
412
|
+
eventId: terminalEvent.id,
|
|
413
|
+
receivedAt: terminalEvent.received_at,
|
|
414
|
+
promptId
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
status: "failed",
|
|
418
|
+
reason: `Claude turn failed with ${failure.code}`,
|
|
419
|
+
sessionId: session.session_id,
|
|
420
|
+
promptId,
|
|
421
|
+
failure
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (terminalEvent.input.hook_event_name !== "Stop") {
|
|
425
|
+
return {
|
|
426
|
+
status: "unknown",
|
|
427
|
+
reason: "correlated Claude terminal hook event has an unsupported type",
|
|
428
|
+
sessionId: session.session_id,
|
|
429
|
+
promptId
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
return completionFromStop(session, promptId, terminalEvent, terminalEvent.input);
|
|
433
|
+
}
|
|
434
|
+
createPendingPermission(session, event, input, leaseMs, leaseResolution) {
|
|
435
|
+
const lease = leaseResolution.lease;
|
|
436
|
+
if (!leaseResolution.authorizationEligible || leaseResolution.matchedBy === "cwd") {
|
|
437
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "cwd-only managed leases cannot authorize Claude");
|
|
438
|
+
}
|
|
439
|
+
const fingerprint = canonicalClaudePermissionFingerprint(input);
|
|
440
|
+
const nonce = this.uniqueNonce(session);
|
|
441
|
+
return {
|
|
442
|
+
requestId: `permission:${fingerprint.slice(0, 16)}:${nonce}`,
|
|
443
|
+
nonce,
|
|
444
|
+
fingerprint,
|
|
445
|
+
eventId: event.id,
|
|
446
|
+
...(event.turn_id === undefined ? {} : { turnId: event.turn_id }),
|
|
447
|
+
sessionId: session.session_id,
|
|
448
|
+
leaseId: lease.id,
|
|
449
|
+
leaseMatchedBy: leaseResolution.matchedBy,
|
|
450
|
+
...(event.claude_pid === undefined ? {} : { claudePid: event.claude_pid }),
|
|
451
|
+
terminalTarget: lease.terminalTarget,
|
|
452
|
+
conversationId: lease.conversationId,
|
|
453
|
+
messageId: lease.messageId,
|
|
454
|
+
cwd: input.cwd,
|
|
455
|
+
toolName: input.tool_name,
|
|
456
|
+
toolInput: clone(input.tool_input),
|
|
457
|
+
createdAt: event.received_at,
|
|
458
|
+
expiresAt: new Date(Math.min(Date.parse(event.received_at) + leaseMs, Date.parse(lease.expiresAt))).toISOString()
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
resolveLeaseFrom(leases, identity, options = {}) {
|
|
462
|
+
const nowMs = this.nowDate().getTime();
|
|
463
|
+
const active = leases.filter((lease) => lease.releasedAt === undefined && Date.parse(lease.expiresAt) > nowMs);
|
|
464
|
+
const isConsistent = (lease) => (lease.sessionId === undefined || identity.sessionId === lease.sessionId) &&
|
|
465
|
+
(lease.pid === undefined || identity.pid === lease.pid) &&
|
|
466
|
+
(identity.cwd === undefined || lease.cwd === identity.cwd);
|
|
467
|
+
const exactSession = identity.sessionId === undefined
|
|
468
|
+
? []
|
|
469
|
+
: active.filter((lease) => lease.sessionId === identity.sessionId);
|
|
470
|
+
const exactPid = identity.pid === undefined
|
|
471
|
+
? []
|
|
472
|
+
: active.filter((lease) => lease.pid === identity.pid);
|
|
473
|
+
if (exactSession.length > 0 && exactPid.length > 0) {
|
|
474
|
+
const exactPidIds = new Set(exactPid.map((lease) => lease.id));
|
|
475
|
+
return uniqueLeaseResolution(exactSession.filter((lease) => exactPidIds.has(lease.id) && isConsistent(lease)), "session");
|
|
476
|
+
}
|
|
477
|
+
if (exactSession.length > 0) {
|
|
478
|
+
return uniqueLeaseResolution(exactSession.filter(isConsistent), "session");
|
|
479
|
+
}
|
|
480
|
+
if (exactPid.length > 0) {
|
|
481
|
+
return uniqueLeaseResolution(exactPid.filter(isConsistent), "pid");
|
|
482
|
+
}
|
|
483
|
+
const exactCwd = identity.cwd === undefined
|
|
484
|
+
? []
|
|
485
|
+
: active.filter((lease) => lease.cwd === identity.cwd && isConsistent(lease));
|
|
486
|
+
if (exactCwd.length > 1 && options.ambiguousCwdAsUnmanaged) {
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
return uniqueLeaseResolution(exactCwd, "cwd");
|
|
490
|
+
}
|
|
491
|
+
readLeases() {
|
|
492
|
+
const filePath = path.join(this.rootDir, "leases.json");
|
|
493
|
+
if (!fs.existsSync(filePath)) {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
rejectSymlink(filePath);
|
|
497
|
+
let value;
|
|
498
|
+
try {
|
|
499
|
+
value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
throw corruptStore(filePath, error);
|
|
503
|
+
}
|
|
504
|
+
if (!isObject(value) || value.schema_version !== STORE_SCHEMA_VERSION || !Array.isArray(value.leases)) {
|
|
505
|
+
throw corruptStore(filePath, "invalid managed lease file");
|
|
506
|
+
}
|
|
507
|
+
if (!value.leases.every(isManagedLease)) {
|
|
508
|
+
throw corruptStore(filePath, "invalid managed lease entry");
|
|
509
|
+
}
|
|
510
|
+
return value.leases;
|
|
511
|
+
}
|
|
512
|
+
writeLeases(leases) {
|
|
513
|
+
ensureDirectory(this.rootDir);
|
|
514
|
+
const filePath = path.join(this.rootDir, "leases.json");
|
|
515
|
+
const temporaryPath = `${filePath}.${process.pid}.${this.randomId()}.tmp`;
|
|
516
|
+
atomicWriteJson(filePath, temporaryPath, {
|
|
517
|
+
schema_version: STORE_SCHEMA_VERSION,
|
|
518
|
+
leases
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
withLeaseLock(action) {
|
|
522
|
+
return withFileLock(path.join(this.rootDir, "leases.lock"), this.randomId, action);
|
|
523
|
+
}
|
|
524
|
+
validatePermissionLease(permission, identity, leases, options = {}) {
|
|
525
|
+
if (permission.fingerprint !== identity.fingerprint) {
|
|
526
|
+
throw new ClaudeHookStoreError("PERMISSION_FINGERPRINT_MISMATCH", `Claude permission fingerprint changed for request ${permission.requestId}`);
|
|
527
|
+
}
|
|
528
|
+
if (permission.conversationId !== identity.conversationId || permission.messageId !== identity.messageId) {
|
|
529
|
+
throw new ClaudeHookStoreError("SESSION_IDENTITY_MISMATCH", `Claude permission request ${permission.requestId} belongs to a different conversation message`);
|
|
530
|
+
}
|
|
531
|
+
const managedLease = leases.find((lease) => lease.id === permission.leaseId);
|
|
532
|
+
const decidedBeforeRelease = Boolean(options.allowDecidedAfterRelease &&
|
|
533
|
+
permission.decision &&
|
|
534
|
+
(!managedLease?.releasedAt ||
|
|
535
|
+
Date.parse(permission.decision.decided_at) <= Date.parse(managedLease.releasedAt)) &&
|
|
536
|
+
Date.parse(permission.decision.decided_at) <= Date.parse(permission.expiresAt));
|
|
537
|
+
if (!managedLease || (!decidedBeforeRelease && (managedLease.releasedAt !== undefined ||
|
|
538
|
+
Date.parse(managedLease.expiresAt) <= this.nowDate().getTime())) ||
|
|
539
|
+
managedLease.conversationId !== permission.conversationId ||
|
|
540
|
+
managedLease.messageId !== permission.messageId ||
|
|
541
|
+
managedLease.terminalTarget !== permission.terminalTarget ||
|
|
542
|
+
(permission.leaseMatchedBy === "session" && managedLease.sessionId !== permission.sessionId) ||
|
|
543
|
+
(permission.leaseMatchedBy === "pid" &&
|
|
544
|
+
(permission.claudePid === undefined || managedLease.pid !== permission.claudePid))) {
|
|
545
|
+
throw new ClaudeHookStoreError("PERMISSION_EXPIRED", `managed Claude lease is no longer active for request ${permission.requestId}`);
|
|
546
|
+
}
|
|
547
|
+
if (permission.consumedAt) {
|
|
548
|
+
throw new ClaudeHookStoreError("PERMISSION_CONSUMED", `Claude permission request ${permission.requestId} was already consumed`);
|
|
549
|
+
}
|
|
550
|
+
if (!decidedBeforeRelease && Date.parse(permission.expiresAt) <= this.nowDate().getTime()) {
|
|
551
|
+
throw new ClaudeHookStoreError("PERMISSION_EXPIRED", `Claude permission request ${permission.requestId} expired at ${permission.expiresAt}`);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
permissionLeaseRemaining(options) {
|
|
555
|
+
const session = this.requireSession(options.sessionId);
|
|
556
|
+
const permission = requirePermission(session, options.requestId);
|
|
557
|
+
if (permission.fingerprint !== options.fingerprint) {
|
|
558
|
+
throw new ClaudeHookStoreError("PERMISSION_FINGERPRINT_MISMATCH", `Claude permission fingerprint changed for request ${permission.requestId}`);
|
|
559
|
+
}
|
|
560
|
+
if (permission.conversationId !== options.conversationId || permission.messageId !== options.messageId) {
|
|
561
|
+
throw new ClaudeHookStoreError("SESSION_IDENTITY_MISMATCH", `Claude permission request ${permission.requestId} belongs to a different conversation message`);
|
|
562
|
+
}
|
|
563
|
+
return Math.max(0, Date.parse(permission.expiresAt) - this.nowDate().getTime());
|
|
564
|
+
}
|
|
565
|
+
requireSession(sessionId) {
|
|
566
|
+
const session = this.readSessionById(sessionId);
|
|
567
|
+
if (!session) {
|
|
568
|
+
throw new ClaudeHookStoreError("SESSION_NOT_FOUND", `Claude hook session not found: ${sessionId}`);
|
|
569
|
+
}
|
|
570
|
+
return session;
|
|
571
|
+
}
|
|
572
|
+
readSessionById(sessionId) {
|
|
573
|
+
const filePath = this.sessionPath(sessionId);
|
|
574
|
+
if (!fs.existsSync(filePath)) {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
rejectSymlink(filePath);
|
|
578
|
+
return parseStoredSession(fs.readFileSync(filePath, "utf8"), filePath, sessionId);
|
|
579
|
+
}
|
|
580
|
+
readAllSessions() {
|
|
581
|
+
if (!fs.existsSync(this.rootDir)) {
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
return fs.readdirSync(this.rootDir, { withFileTypes: true })
|
|
585
|
+
.filter((entry) => entry.isFile() && /^[a-f0-9]{64}\.json$/u.test(entry.name))
|
|
586
|
+
.map((entry) => {
|
|
587
|
+
const filePath = path.join(this.rootDir, entry.name);
|
|
588
|
+
rejectSymlink(filePath);
|
|
589
|
+
return parseStoredSession(fs.readFileSync(filePath, "utf8"), filePath);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
writeSession(session) {
|
|
593
|
+
ensureDirectory(this.rootDir);
|
|
594
|
+
const filePath = this.sessionPath(session.session_id);
|
|
595
|
+
const temporaryPath = `${filePath}.${process.pid}.${this.randomId()}.tmp`;
|
|
596
|
+
let fileDescriptor;
|
|
597
|
+
try {
|
|
598
|
+
fileDescriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
599
|
+
fs.writeFileSync(fileDescriptor, `${JSON.stringify(session, null, 2)}\n`, "utf8");
|
|
600
|
+
fs.fsyncSync(fileDescriptor);
|
|
601
|
+
fs.closeSync(fileDescriptor);
|
|
602
|
+
fileDescriptor = undefined;
|
|
603
|
+
fs.renameSync(temporaryPath, filePath);
|
|
604
|
+
fs.chmodSync(filePath, 0o600);
|
|
605
|
+
fsyncDirectory(this.rootDir);
|
|
606
|
+
}
|
|
607
|
+
finally {
|
|
608
|
+
if (fileDescriptor !== undefined) {
|
|
609
|
+
fs.closeSync(fileDescriptor);
|
|
610
|
+
}
|
|
611
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
withSessionLock(sessionId, action) {
|
|
615
|
+
ensureDirectory(this.rootDir);
|
|
616
|
+
const lockPath = `${this.sessionPath(sessionId)}.lock`;
|
|
617
|
+
const owner = `${process.pid}:${this.randomId()}`;
|
|
618
|
+
const startedAt = Date.now();
|
|
619
|
+
let descriptor;
|
|
620
|
+
while (descriptor === undefined) {
|
|
621
|
+
try {
|
|
622
|
+
descriptor = fs.openSync(lockPath, "wx", 0o600);
|
|
623
|
+
fs.writeFileSync(descriptor, owner, "utf8");
|
|
624
|
+
fs.fsyncSync(descriptor);
|
|
625
|
+
}
|
|
626
|
+
catch (error) {
|
|
627
|
+
if (!isErrno(error, "EEXIST")) {
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
removeStaleLock(lockPath);
|
|
631
|
+
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
632
|
+
throw new ClaudeHookStoreError("LOCK_TIMEOUT", `timed out waiting for Claude hook lock: ${lockPath}`);
|
|
633
|
+
}
|
|
634
|
+
synchronousSleep(20);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
return action();
|
|
639
|
+
}
|
|
640
|
+
finally {
|
|
641
|
+
fs.closeSync(descriptor);
|
|
642
|
+
try {
|
|
643
|
+
if (fs.readFileSync(lockPath, "utf8") === owner) {
|
|
644
|
+
fs.rmSync(lockPath, { force: true });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
if (!isErrno(error, "ENOENT")) {
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
sessionPath(sessionId) {
|
|
655
|
+
const digest = createHash("sha256").update(sessionId).digest("hex");
|
|
656
|
+
return path.join(this.rootDir, `${digest}.json`);
|
|
657
|
+
}
|
|
658
|
+
uniqueEventId(session) {
|
|
659
|
+
const existing = new Set(session.events.map((event) => event.id));
|
|
660
|
+
return this.uniqueValue("event", existing);
|
|
661
|
+
}
|
|
662
|
+
uniqueNonce(session) {
|
|
663
|
+
const existing = new Set(session.permissions.map((permission) => permission.nonce));
|
|
664
|
+
return this.uniqueValue("nonce", existing);
|
|
665
|
+
}
|
|
666
|
+
uniqueValue(label, existing) {
|
|
667
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
668
|
+
const candidate = this.randomId();
|
|
669
|
+
if (candidate && !existing.has(candidate)) {
|
|
670
|
+
return candidate;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", `could not generate a unique Claude hook ${label}`);
|
|
674
|
+
}
|
|
675
|
+
nowDate() {
|
|
676
|
+
const value = this.now();
|
|
677
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
678
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "Claude hook store clock returned an invalid date");
|
|
679
|
+
}
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
nowIso() {
|
|
683
|
+
return this.nowDate().toISOString();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function sameManagedMessageBinding(prompt, terminalEvent) {
|
|
687
|
+
const promptIsBound = prompt.lease_id !== undefined ||
|
|
688
|
+
prompt.conversation_id !== undefined ||
|
|
689
|
+
prompt.message_id !== undefined;
|
|
690
|
+
const terminalIsBound = terminalEvent.lease_id !== undefined ||
|
|
691
|
+
terminalEvent.conversation_id !== undefined ||
|
|
692
|
+
terminalEvent.message_id !== undefined;
|
|
693
|
+
if (!promptIsBound && !terminalIsBound) {
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
return prompt.lease_id !== undefined &&
|
|
697
|
+
prompt.conversation_id !== undefined &&
|
|
698
|
+
prompt.message_id !== undefined &&
|
|
699
|
+
prompt.lease_id === terminalEvent.lease_id &&
|
|
700
|
+
prompt.conversation_id === terminalEvent.conversation_id &&
|
|
701
|
+
prompt.message_id === terminalEvent.message_id;
|
|
702
|
+
}
|
|
703
|
+
function completionFromStop(session, promptId, event, input) {
|
|
704
|
+
if (!Array.isArray(input.background_tasks) || !Array.isArray(input.session_crons)) {
|
|
705
|
+
return {
|
|
706
|
+
status: "unknown",
|
|
707
|
+
reason: "Stop hook did not include both background_tasks and session_crons; completion is unverified",
|
|
708
|
+
sessionId: session.session_id,
|
|
709
|
+
promptId
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
if (input.background_tasks.length > 0 || input.session_crons.length > 0) {
|
|
713
|
+
return {
|
|
714
|
+
status: "pending",
|
|
715
|
+
reason: `Claude still reports ${input.background_tasks.length} background task(s) and ${input.session_crons.length} session cron(s)`,
|
|
716
|
+
sessionId: session.session_id,
|
|
717
|
+
promptId
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
const text = input.last_assistant_message?.trim();
|
|
721
|
+
if (!text) {
|
|
722
|
+
return {
|
|
723
|
+
status: "unknown",
|
|
724
|
+
reason: "Stop hook did not include a non-empty last_assistant_message",
|
|
725
|
+
sessionId: session.session_id,
|
|
726
|
+
promptId
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
status: "done",
|
|
731
|
+
reason: "correlated Stop hook reported no background tasks or session crons",
|
|
732
|
+
sessionId: session.session_id,
|
|
733
|
+
promptId,
|
|
734
|
+
eventId: event.id,
|
|
735
|
+
timestamp: event.received_at,
|
|
736
|
+
text,
|
|
737
|
+
cwd: event.input.cwd
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
function newSession(input, now) {
|
|
741
|
+
return {
|
|
742
|
+
schema_version: STORE_SCHEMA_VERSION,
|
|
743
|
+
session_id: input.session_id,
|
|
744
|
+
cwd: input.cwd,
|
|
745
|
+
transcript_path: input.transcript_path,
|
|
746
|
+
created_at: now,
|
|
747
|
+
updated_at: now,
|
|
748
|
+
events: [],
|
|
749
|
+
permissions: []
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function parseStoredSession(source, filePath, expectedSessionId) {
|
|
753
|
+
let value;
|
|
754
|
+
try {
|
|
755
|
+
value = JSON.parse(source);
|
|
756
|
+
}
|
|
757
|
+
catch (error) {
|
|
758
|
+
throw corruptStore(filePath, error);
|
|
759
|
+
}
|
|
760
|
+
if (!isObject(value) || value.schema_version !== STORE_SCHEMA_VERSION) {
|
|
761
|
+
throw corruptStore(filePath, "unsupported or missing schema_version");
|
|
762
|
+
}
|
|
763
|
+
if (typeof value.session_id !== "string" || !value.session_id ||
|
|
764
|
+
typeof value.cwd !== "string" || typeof value.transcript_path !== "string" ||
|
|
765
|
+
typeof value.created_at !== "string" || typeof value.updated_at !== "string" ||
|
|
766
|
+
!Array.isArray(value.events) || !Array.isArray(value.permissions)) {
|
|
767
|
+
throw corruptStore(filePath, "missing required session fields");
|
|
768
|
+
}
|
|
769
|
+
if (expectedSessionId !== undefined && value.session_id !== expectedSessionId) {
|
|
770
|
+
throw corruptStore(filePath, `stored session id does not match ${expectedSessionId}`);
|
|
771
|
+
}
|
|
772
|
+
for (const event of value.events) {
|
|
773
|
+
if (!isObject(event) || typeof event.id !== "string" || typeof event.received_at !== "string") {
|
|
774
|
+
throw corruptStore(filePath, "invalid stored event");
|
|
775
|
+
}
|
|
776
|
+
try {
|
|
777
|
+
parseClaudeHookInput(event.input);
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
throw corruptStore(filePath, error);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
for (const permission of value.permissions) {
|
|
784
|
+
if (!isStoredPermission(permission)) {
|
|
785
|
+
throw corruptStore(filePath, "invalid stored permission request");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return value;
|
|
789
|
+
}
|
|
790
|
+
function isStoredPermission(value) {
|
|
791
|
+
if (!isObject(value)) {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
return typeof value.requestId === "string" &&
|
|
795
|
+
typeof value.nonce === "string" &&
|
|
796
|
+
typeof value.fingerprint === "string" &&
|
|
797
|
+
typeof value.eventId === "string" &&
|
|
798
|
+
typeof value.sessionId === "string" &&
|
|
799
|
+
typeof value.leaseId === "string" &&
|
|
800
|
+
(value.leaseMatchedBy === "session" || value.leaseMatchedBy === "pid") &&
|
|
801
|
+
(value.claudePid === undefined || (Number.isSafeInteger(value.claudePid) && value.claudePid > 0)) &&
|
|
802
|
+
typeof value.terminalTarget === "string" &&
|
|
803
|
+
typeof value.conversationId === "string" &&
|
|
804
|
+
typeof value.messageId === "string" &&
|
|
805
|
+
typeof value.cwd === "string" &&
|
|
806
|
+
typeof value.toolName === "string" &&
|
|
807
|
+
typeof value.createdAt === "string" &&
|
|
808
|
+
typeof value.expiresAt === "string" &&
|
|
809
|
+
(value.decision === undefined || (isObject(value.decision) &&
|
|
810
|
+
(value.decision.behavior === "allow" || value.decision.behavior === "deny") &&
|
|
811
|
+
typeof value.decision.decided_at === "string"));
|
|
812
|
+
}
|
|
813
|
+
function requirePermission(session, requestId) {
|
|
814
|
+
const matches = session.permissions.filter((permission) => permission.requestId === requestId);
|
|
815
|
+
if (matches.length === 0) {
|
|
816
|
+
throw new ClaudeHookStoreError("PERMISSION_NOT_FOUND", `Claude permission request not found: ${requestId}`);
|
|
817
|
+
}
|
|
818
|
+
if (matches.length > 1) {
|
|
819
|
+
throw new ClaudeHookStoreError("AMBIGUOUS_PERMISSION", `duplicate Claude permission request id: ${requestId}`);
|
|
820
|
+
}
|
|
821
|
+
return matches[0];
|
|
822
|
+
}
|
|
823
|
+
function validateIdentity(identity) {
|
|
824
|
+
if (!identity || typeof identity !== "object") {
|
|
825
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "Claude session identity is required");
|
|
826
|
+
}
|
|
827
|
+
if (identity.sessionId !== undefined && (!identity.sessionId || typeof identity.sessionId !== "string")) {
|
|
828
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "sessionId must be a non-empty string");
|
|
829
|
+
}
|
|
830
|
+
if (identity.pid !== undefined) {
|
|
831
|
+
optionalPid(identity.pid);
|
|
832
|
+
}
|
|
833
|
+
if (identity.cwd !== undefined && (!identity.cwd || typeof identity.cwd !== "string")) {
|
|
834
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "cwd must be a non-empty string");
|
|
835
|
+
}
|
|
836
|
+
if (!identity.sessionId && identity.pid === undefined && identity.cwd === undefined) {
|
|
837
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "sessionId, pid, or cwd is required");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
function validateActivateLeaseOptions(options) {
|
|
841
|
+
validateIdentity({
|
|
842
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
843
|
+
...(options.pid === undefined ? {} : { pid: options.pid }),
|
|
844
|
+
cwd: options.cwd
|
|
845
|
+
});
|
|
846
|
+
if (!options.conversationId || !options.messageId || !options.terminalTarget) {
|
|
847
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "conversationId, messageId, and terminalTarget are required for a managed Claude lease");
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
function validateReleaseLeaseOptions(options) {
|
|
851
|
+
if (!options.leaseId && (!options.conversationId || !options.messageId)) {
|
|
852
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "leaseId or conversationId plus messageId is required to release a managed Claude lease");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function validateDecisionOptions(options) {
|
|
856
|
+
validateConsumeOptions(options);
|
|
857
|
+
if (options.decision !== "allow" && options.decision !== "deny") {
|
|
858
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "decision must be allow or deny");
|
|
859
|
+
}
|
|
860
|
+
if (options.decision === "allow" && options.interrupt !== undefined) {
|
|
861
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "interrupt is only valid for a deny decision");
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function validateConsumeOptions(options) {
|
|
865
|
+
if (!options.sessionId || !options.requestId || !options.fingerprint ||
|
|
866
|
+
!options.conversationId || !options.messageId) {
|
|
867
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "sessionId, requestId, fingerprint, conversationId, and messageId are required for a Claude permission decision");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
function hookInputIdentity(inputOrIdentity) {
|
|
871
|
+
if ("hook_event_name" in inputOrIdentity) {
|
|
872
|
+
return {
|
|
873
|
+
sessionId: inputOrIdentity.session_id,
|
|
874
|
+
cwd: inputOrIdentity.cwd
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
return inputOrIdentity;
|
|
878
|
+
}
|
|
879
|
+
function uniqueLeaseResolution(leases, matchedBy) {
|
|
880
|
+
if (leases.length === 0) {
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
if (leases.length > 1) {
|
|
884
|
+
throw new ClaudeHookStoreError("AMBIGUOUS_SESSION", `multiple active managed Claude leases match exact ${matchedBy}`);
|
|
885
|
+
}
|
|
886
|
+
return {
|
|
887
|
+
lease: leases[0],
|
|
888
|
+
matchedBy,
|
|
889
|
+
authorizationEligible: matchedBy === "session" || matchedBy === "pid"
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function uniqueManagedLeaseId(leases, randomId) {
|
|
893
|
+
const existing = new Set(leases.map((lease) => lease.id));
|
|
894
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
895
|
+
const candidate = `lease:${randomId()}`;
|
|
896
|
+
if (candidate !== "lease:" && !existing.has(candidate)) {
|
|
897
|
+
return candidate;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "could not generate a unique managed Claude lease id");
|
|
901
|
+
}
|
|
902
|
+
function isManagedLease(value) {
|
|
903
|
+
return isObject(value) &&
|
|
904
|
+
typeof value.id === "string" &&
|
|
905
|
+
(value.sessionId === undefined || typeof value.sessionId === "string") &&
|
|
906
|
+
(value.pid === undefined || (Number.isSafeInteger(value.pid) && value.pid > 0)) &&
|
|
907
|
+
typeof value.cwd === "string" &&
|
|
908
|
+
typeof value.conversationId === "string" &&
|
|
909
|
+
typeof value.messageId === "string" &&
|
|
910
|
+
typeof value.terminalTarget === "string" &&
|
|
911
|
+
typeof value.createdAt === "string" &&
|
|
912
|
+
typeof value.updatedAt === "string" &&
|
|
913
|
+
typeof value.expiresAt === "string" &&
|
|
914
|
+
(value.releasedAt === undefined || typeof value.releasedAt === "string");
|
|
915
|
+
}
|
|
916
|
+
function optionalPid(value) {
|
|
917
|
+
if (value === undefined) {
|
|
918
|
+
return undefined;
|
|
919
|
+
}
|
|
920
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
921
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", "claudePid must be a positive integer");
|
|
922
|
+
}
|
|
923
|
+
return value;
|
|
924
|
+
}
|
|
925
|
+
function positiveMilliseconds(value, field) {
|
|
926
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
927
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", `${field} must be greater than zero`);
|
|
928
|
+
}
|
|
929
|
+
return value;
|
|
930
|
+
}
|
|
931
|
+
function nonNegativeMilliseconds(value, field) {
|
|
932
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
933
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", `${field} must not be negative`);
|
|
934
|
+
}
|
|
935
|
+
return value;
|
|
936
|
+
}
|
|
937
|
+
function timestampMs(value, field) {
|
|
938
|
+
const result = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
939
|
+
if (!Number.isFinite(result)) {
|
|
940
|
+
throw new ClaudeHookStoreError("INVALID_INPUT", `${field} must be a valid timestamp`);
|
|
941
|
+
}
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
function stringProperty(value, key) {
|
|
945
|
+
if (!isObject(value)) {
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
return typeof value[key] === "string" && value[key].trim() ? value[key] : undefined;
|
|
949
|
+
}
|
|
950
|
+
function ensureDirectory(directory) {
|
|
951
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
952
|
+
}
|
|
953
|
+
function atomicWriteJson(filePath, temporaryPath, value) {
|
|
954
|
+
let descriptor;
|
|
955
|
+
try {
|
|
956
|
+
descriptor = fs.openSync(temporaryPath, "wx", 0o600);
|
|
957
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
958
|
+
fs.fsyncSync(descriptor);
|
|
959
|
+
fs.closeSync(descriptor);
|
|
960
|
+
descriptor = undefined;
|
|
961
|
+
fs.renameSync(temporaryPath, filePath);
|
|
962
|
+
fs.chmodSync(filePath, 0o600);
|
|
963
|
+
fsyncDirectory(path.dirname(filePath));
|
|
964
|
+
}
|
|
965
|
+
finally {
|
|
966
|
+
if (descriptor !== undefined) {
|
|
967
|
+
fs.closeSync(descriptor);
|
|
968
|
+
}
|
|
969
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function withFileLock(lockPath, randomId, action) {
|
|
973
|
+
ensureDirectory(path.dirname(lockPath));
|
|
974
|
+
const owner = `${process.pid}:${randomId()}`;
|
|
975
|
+
const startedAt = Date.now();
|
|
976
|
+
let descriptor;
|
|
977
|
+
while (descriptor === undefined) {
|
|
978
|
+
try {
|
|
979
|
+
descriptor = fs.openSync(lockPath, "wx", 0o600);
|
|
980
|
+
fs.writeFileSync(descriptor, owner, "utf8");
|
|
981
|
+
fs.fsyncSync(descriptor);
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
if (!isErrno(error, "EEXIST")) {
|
|
985
|
+
throw error;
|
|
986
|
+
}
|
|
987
|
+
removeStaleLock(lockPath);
|
|
988
|
+
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
989
|
+
throw new ClaudeHookStoreError("LOCK_TIMEOUT", `timed out waiting for Claude hook lock: ${lockPath}`);
|
|
990
|
+
}
|
|
991
|
+
synchronousSleep(20);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
const acquiredDescriptor = descriptor;
|
|
995
|
+
try {
|
|
996
|
+
return action();
|
|
997
|
+
}
|
|
998
|
+
finally {
|
|
999
|
+
fs.closeSync(acquiredDescriptor);
|
|
1000
|
+
try {
|
|
1001
|
+
if (fs.readFileSync(lockPath, "utf8") === owner) {
|
|
1002
|
+
fs.rmSync(lockPath, { force: true });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch (error) {
|
|
1006
|
+
if (!isErrno(error, "ENOENT")) {
|
|
1007
|
+
throw error;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
function rejectSymlink(filePath) {
|
|
1013
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) {
|
|
1014
|
+
throw new ClaudeHookStoreError("CORRUPT_STORE", `refusing Claude hook store symlink: ${filePath}`);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
function removeStaleLock(lockPath) {
|
|
1018
|
+
try {
|
|
1019
|
+
if (Date.now() - fs.statSync(lockPath).mtimeMs > STALE_LOCK_MS) {
|
|
1020
|
+
fs.rmSync(lockPath, { force: true });
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
catch (error) {
|
|
1024
|
+
if (!isErrno(error, "ENOENT")) {
|
|
1025
|
+
throw error;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function synchronousSleep(milliseconds) {
|
|
1030
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
1031
|
+
Atomics.wait(signal, 0, 0, milliseconds);
|
|
1032
|
+
}
|
|
1033
|
+
function fsyncDirectory(directory) {
|
|
1034
|
+
let descriptor;
|
|
1035
|
+
try {
|
|
1036
|
+
descriptor = fs.openSync(directory, "r");
|
|
1037
|
+
fs.fsyncSync(descriptor);
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
// Some platforms do not support fsync on directories; the file rename is still atomic.
|
|
1041
|
+
}
|
|
1042
|
+
finally {
|
|
1043
|
+
if (descriptor !== undefined) {
|
|
1044
|
+
fs.closeSync(descriptor);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function corruptStore(filePath, cause) {
|
|
1049
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
1050
|
+
return new ClaudeHookStoreError("CORRUPT_STORE", `invalid Claude hook store ${filePath}: ${detail}`);
|
|
1051
|
+
}
|
|
1052
|
+
function isErrno(error, code) {
|
|
1053
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
1054
|
+
}
|
|
1055
|
+
function isObject(value) {
|
|
1056
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1057
|
+
}
|
|
1058
|
+
function clone(value) {
|
|
1059
|
+
return structuredClone(value);
|
|
1060
|
+
}
|
|
1061
|
+
function cloneOptional(value) {
|
|
1062
|
+
return value === undefined ? undefined : clone(value);
|
|
1063
|
+
}
|
|
1064
|
+
//# sourceMappingURL=claude-hook-store.js.map
|