@raidiant/notifai 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -4
- package/dist/atomic-file.d.ts +4 -0
- package/dist/atomic-file.js +60 -10
- package/dist/atomic-file.js.map +1 -1
- package/dist/client.d.ts +25 -2
- package/dist/client.js +45 -1
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +56 -7
- package/dist/commands.js +980 -318
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +1 -1
- package/dist/config-schema.js +46 -8
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +38 -3
- package/dist/config.js +61 -14
- package/dist/config.js.map +1 -1
- package/dist/file-lock.d.ts +26 -0
- package/dist/file-lock.js +268 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/harnesses.d.ts +28 -0
- package/dist/harnesses.js +40 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/hook-adapter.d.ts +27 -0
- package/dist/hook-adapter.js +188 -0
- package/dist/hook-adapter.js.map +1 -0
- package/dist/hook-input.d.ts +10 -0
- package/dist/hook-input.js +64 -0
- package/dist/hook-input.js.map +1 -0
- package/dist/hooks.d.ts +66 -10
- package/dist/hooks.js +751 -276
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +40 -16
- package/dist/install-hooks.js +217 -73
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +3 -3
- package/dist/interactive.js.map +1 -1
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +97 -36
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -7
- package/dist/opencode-plugin.js +12 -13
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/ui/config-view.js +3 -0
- package/dist/ui/config-view.js.map +1 -1
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +3 -2
- package/src/atomic-file.ts +86 -7
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +1261 -77
- package/src/commands.ts +1482 -375
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +57 -44
- package/src/config.test.ts +22 -16
- package/src/config.ts +81 -66
- package/src/file-lock-process-worker.test.ts +64 -0
- package/src/file-lock.test.ts +279 -0
- package/src/file-lock.ts +318 -0
- package/src/harnesses.test.ts +34 -0
- package/src/harnesses.ts +57 -0
- package/src/hook-adapter.test.ts +187 -0
- package/src/hook-adapter.ts +212 -0
- package/src/hook-input.test.ts +39 -0
- package/src/hook-input.ts +72 -0
- package/src/hooks.test.ts +1985 -417
- package/src/hooks.ts +1338 -461
- package/src/install-hooks.test.ts +131 -41
- package/src/install-hooks.ts +284 -84
- package/src/interactive.ts +3 -8
- package/src/logging-process-worker.test.ts +39 -0
- package/src/logging.test.ts +453 -0
- package/src/logging.ts +639 -0
- package/src/main.ts +113 -32
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +34 -0
- package/src/ui/config-view.test.ts +9 -7
- package/src/ui/config-view.ts +3 -0
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { createConnection } from 'node:net'
|
|
6
|
+
import type {
|
|
7
|
+
ContinuationEvent,
|
|
8
|
+
DeliveryOutcome,
|
|
9
|
+
EscalationDeliveryRoute,
|
|
10
|
+
} from './hooks.js'
|
|
11
|
+
|
|
12
|
+
/** Claude Code's currently observed inbox protocol. Unknown versions fail closed. */
|
|
13
|
+
export const CLAUDE_PEER_PROTOCOL = 1
|
|
14
|
+
|
|
15
|
+
/** The first Claude Code release that publishes a session inbox socket. */
|
|
16
|
+
export const CLAUDE_MIN_INBOX_VERSION = '2.1.224'
|
|
17
|
+
|
|
18
|
+
/** The inbox socket is a Unix domain socket; no Windows implementation exists. */
|
|
19
|
+
const INBOX_PLATFORMS: ReadonlySet<string> = new Set(['darwin', 'linux'])
|
|
20
|
+
|
|
21
|
+
/** macOS verifies own-child ancestry only while the posting process is alive. */
|
|
22
|
+
export const CLAUDE_POST_SEND_LIVENESS_MS = 8_000
|
|
23
|
+
|
|
24
|
+
export type ClaudeLiveStatus = 'idle' | 'busy'
|
|
25
|
+
|
|
26
|
+
export interface ClaudeSessionDescriptor {
|
|
27
|
+
pid: number
|
|
28
|
+
sessionId: string
|
|
29
|
+
cwd: string
|
|
30
|
+
startedAt: number
|
|
31
|
+
procStart: string
|
|
32
|
+
version: string
|
|
33
|
+
peerProtocol: number
|
|
34
|
+
messagingSocketPath: string
|
|
35
|
+
status: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ClaudeAgentObservation {
|
|
39
|
+
pid: number
|
|
40
|
+
sessionId: string
|
|
41
|
+
startedAt: number
|
|
42
|
+
status: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ClaudeWakeAdapters {
|
|
46
|
+
listAgents(): Promise<unknown>
|
|
47
|
+
readDescriptor(pid: number): unknown
|
|
48
|
+
sendSocket(socketPath: string, line: string): Promise<void>
|
|
49
|
+
resume(sessionId: string, cwd: string, context: string): Promise<void>
|
|
50
|
+
sleep(milliseconds: number): Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ClaudeWakeObservation =
|
|
54
|
+
| {
|
|
55
|
+
state: 'live-idle' | 'live-busy'
|
|
56
|
+
descriptor: ClaudeSessionDescriptor
|
|
57
|
+
}
|
|
58
|
+
| { state: 'stopped' }
|
|
59
|
+
| { state: 'unknown'; reason: string }
|
|
60
|
+
|
|
61
|
+
const LIVE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy'])
|
|
62
|
+
|
|
63
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
64
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseAgent(value: unknown): ClaudeAgentObservation | null {
|
|
68
|
+
if (!isRecord(value)) return null
|
|
69
|
+
if (
|
|
70
|
+
typeof value['pid'] !== 'number' ||
|
|
71
|
+
!Number.isInteger(value['pid']) ||
|
|
72
|
+
value['pid'] <= 0 ||
|
|
73
|
+
typeof value['sessionId'] !== 'string' ||
|
|
74
|
+
value['sessionId'] === '' ||
|
|
75
|
+
typeof value['startedAt'] !== 'number' ||
|
|
76
|
+
!Number.isFinite(value['startedAt']) ||
|
|
77
|
+
typeof value['status'] !== 'string'
|
|
78
|
+
) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
pid: value['pid'],
|
|
83
|
+
sessionId: value['sessionId'],
|
|
84
|
+
startedAt: value['startedAt'],
|
|
85
|
+
status: value['status'],
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseDescriptor(value: unknown): ClaudeSessionDescriptor | null {
|
|
90
|
+
if (!isRecord(value)) return null
|
|
91
|
+
if (
|
|
92
|
+
typeof value['pid'] !== 'number' ||
|
|
93
|
+
!Number.isInteger(value['pid']) ||
|
|
94
|
+
value['pid'] <= 0 ||
|
|
95
|
+
typeof value['sessionId'] !== 'string' ||
|
|
96
|
+
value['sessionId'] === '' ||
|
|
97
|
+
typeof value['cwd'] !== 'string' ||
|
|
98
|
+
value['cwd'] === '' ||
|
|
99
|
+
typeof value['startedAt'] !== 'number' ||
|
|
100
|
+
!Number.isFinite(value['startedAt']) ||
|
|
101
|
+
typeof value['procStart'] !== 'string' ||
|
|
102
|
+
value['procStart'] === '' ||
|
|
103
|
+
typeof value['version'] !== 'string' ||
|
|
104
|
+
value['version'] === '' ||
|
|
105
|
+
typeof value['peerProtocol'] !== 'number' ||
|
|
106
|
+
!Number.isInteger(value['peerProtocol']) ||
|
|
107
|
+
typeof value['messagingSocketPath'] !== 'string' ||
|
|
108
|
+
value['messagingSocketPath'] === '' ||
|
|
109
|
+
typeof value['status'] !== 'string'
|
|
110
|
+
) {
|
|
111
|
+
return null
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
pid: value['pid'],
|
|
115
|
+
sessionId: value['sessionId'],
|
|
116
|
+
cwd: value['cwd'],
|
|
117
|
+
startedAt: value['startedAt'],
|
|
118
|
+
procStart: value['procStart'],
|
|
119
|
+
version: value['version'],
|
|
120
|
+
peerProtocol: value['peerProtocol'],
|
|
121
|
+
messagingSocketPath: value['messagingSocketPath'],
|
|
122
|
+
status: value['status'],
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Observe one exact Claude Code session without guessing.
|
|
128
|
+
*
|
|
129
|
+
* `claude agents --json` is the liveness oracle: Claude Code itself validates
|
|
130
|
+
* each registry row against both PID and process start time before returning it.
|
|
131
|
+
* The descriptor supplies the socket and peer protocol, but is never trusted as
|
|
132
|
+
* proof that a process still owns the session.
|
|
133
|
+
*/
|
|
134
|
+
export async function observeClaudeSession(
|
|
135
|
+
sessionId: string,
|
|
136
|
+
adapters: Pick<ClaudeWakeAdapters, 'listAgents' | 'readDescriptor'>,
|
|
137
|
+
): Promise<ClaudeWakeObservation> {
|
|
138
|
+
let listed: unknown
|
|
139
|
+
try {
|
|
140
|
+
listed = await adapters.listAgents()
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
state: 'unknown',
|
|
144
|
+
reason: `claude agents probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(listed)) {
|
|
148
|
+
return { state: 'unknown', reason: 'claude agents returned an invalid document' }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const matchingRaw = listed.filter(
|
|
152
|
+
(value) => isRecord(value) && value['sessionId'] === sessionId,
|
|
153
|
+
)
|
|
154
|
+
if (matchingRaw.length === 0) return { state: 'stopped' }
|
|
155
|
+
if (matchingRaw.length !== 1) {
|
|
156
|
+
return { state: 'unknown', reason: 'claude agents returned duplicate session owners' }
|
|
157
|
+
}
|
|
158
|
+
const agent = parseAgent(matchingRaw[0])
|
|
159
|
+
if (agent === null) {
|
|
160
|
+
return { state: 'unknown', reason: 'claude agents returned an invalid matching row' }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let descriptorRaw: unknown
|
|
164
|
+
try {
|
|
165
|
+
descriptorRaw = adapters.readDescriptor(agent.pid)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return {
|
|
168
|
+
state: 'unknown',
|
|
169
|
+
reason: `Claude session descriptor could not be read: ${err instanceof Error ? err.message : String(err)}`,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const descriptor = parseDescriptor(descriptorRaw)
|
|
173
|
+
if (descriptor === null) {
|
|
174
|
+
return { state: 'unknown', reason: 'Claude session descriptor has an unknown shape' }
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
descriptor.pid !== agent.pid ||
|
|
178
|
+
descriptor.sessionId !== agent.sessionId ||
|
|
179
|
+
descriptor.startedAt !== agent.startedAt
|
|
180
|
+
) {
|
|
181
|
+
return { state: 'unknown', reason: 'Claude liveness probe and descriptor disagree' }
|
|
182
|
+
}
|
|
183
|
+
if (descriptor.peerProtocol !== CLAUDE_PEER_PROTOCOL) {
|
|
184
|
+
return {
|
|
185
|
+
state: 'unknown',
|
|
186
|
+
reason: `unsupported Claude peer protocol ${descriptor.peerProtocol}`,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!LIVE_STATUSES.has(agent.status)) {
|
|
190
|
+
return { state: 'unknown', reason: `Claude session status ${agent.status} is not wakeable` }
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
state: agent.status === 'idle' ? 'live-idle' : 'live-busy',
|
|
194
|
+
descriptor,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export type ClaudeInboxReadiness =
|
|
199
|
+
| { state: 'ready'; socketPath: string; version: string }
|
|
200
|
+
| { state: 'unavailable'; reason: string }
|
|
201
|
+
|
|
202
|
+
/** Ordered comparison of dotted release numbers, ignoring any suffix. */
|
|
203
|
+
function versionAtLeast(version: string, minimum: string): boolean {
|
|
204
|
+
const parse = (value: string): number[] =>
|
|
205
|
+
value.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
|
206
|
+
const left = parse(version)
|
|
207
|
+
const right = parse(minimum)
|
|
208
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
209
|
+
const a = left[index] ?? 0
|
|
210
|
+
const b = right[index] ?? 0
|
|
211
|
+
if (a !== b) return a > b
|
|
212
|
+
}
|
|
213
|
+
return true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether an answer could reach this exact Claude session over its inbox
|
|
218
|
+
* socket, decided from evidence already on disk.
|
|
219
|
+
*
|
|
220
|
+
* Read-only by construction: it reads the session descriptor and asks whether
|
|
221
|
+
* the socket file exists, and never connects to it. A diagnostic that delivers
|
|
222
|
+
* a message is not a diagnostic.
|
|
223
|
+
*
|
|
224
|
+
* Every negative answer here is a degradation, not a failure — the accepted
|
|
225
|
+
* journal still replays the answer at the session's next turn. What it buys is
|
|
226
|
+
* that the reason is nameable in advance instead of being discovered as
|
|
227
|
+
* silence. The common one is `--bare`, which binds no socket at all (and runs
|
|
228
|
+
* no hooks either, so nothing would have registered a question in the first
|
|
229
|
+
* place).
|
|
230
|
+
*/
|
|
231
|
+
export function inspectClaudeInbox(options: {
|
|
232
|
+
pid: number
|
|
233
|
+
platform: NodeJS.Platform
|
|
234
|
+
readDescriptor: (pid: number) => unknown
|
|
235
|
+
socketExists: (socketPath: string) => boolean
|
|
236
|
+
}): ClaudeInboxReadiness {
|
|
237
|
+
if (!INBOX_PLATFORMS.has(options.platform)) {
|
|
238
|
+
return {
|
|
239
|
+
state: 'unavailable',
|
|
240
|
+
reason: `Claude Code publishes no inbox socket on ${options.platform}; it exists on macOS and Linux only`,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let raw: unknown
|
|
244
|
+
try {
|
|
245
|
+
raw = options.readDescriptor(options.pid)
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return {
|
|
248
|
+
state: 'unavailable',
|
|
249
|
+
reason: `no session descriptor for pid ${options.pid} (${err instanceof Error ? err.message : String(err)}); a session started with \`--bare\` binds no inbox socket`,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const descriptor = parseDescriptor(raw)
|
|
253
|
+
if (descriptor === null) {
|
|
254
|
+
return {
|
|
255
|
+
state: 'unavailable',
|
|
256
|
+
reason: `the session descriptor for pid ${options.pid} has an unknown shape`,
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (descriptor.peerProtocol !== CLAUDE_PEER_PROTOCOL) {
|
|
260
|
+
return {
|
|
261
|
+
state: 'unavailable',
|
|
262
|
+
reason: `this session speaks inbox protocol ${descriptor.peerProtocol}, and only ${CLAUDE_PEER_PROTOCOL} is known; refusing to guess at an undocumented wire format`,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!versionAtLeast(descriptor.version, CLAUDE_MIN_INBOX_VERSION)) {
|
|
266
|
+
return {
|
|
267
|
+
state: 'unavailable',
|
|
268
|
+
reason: `Claude Code ${descriptor.version} is older than ${CLAUDE_MIN_INBOX_VERSION}, which is where the inbox socket starts`,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!options.socketExists(descriptor.messagingSocketPath)) {
|
|
272
|
+
return {
|
|
273
|
+
state: 'unavailable',
|
|
274
|
+
reason: `the descriptor names ${descriptor.messagingSocketPath}, but no socket exists there`,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
state: 'ready',
|
|
279
|
+
socketPath: descriptor.messagingSocketPath,
|
|
280
|
+
version: descriptor.version,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function socketLine(context: string): string {
|
|
285
|
+
return `${JSON.stringify({
|
|
286
|
+
type: 'user',
|
|
287
|
+
message: { role: 'user', content: context },
|
|
288
|
+
})}\n`
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Claude's own-child delivery route.
|
|
293
|
+
*
|
|
294
|
+
* A successful socket write proves delivery to Claude Code, not model
|
|
295
|
+
* consumption — and this route reports exactly that, no more. It reports
|
|
296
|
+
* `delivered` all the same, because the write is the strongest acknowledgement
|
|
297
|
+
* this route can ever obtain: the message it posts starts a brand-new turn
|
|
298
|
+
* rather than continuing this one, so no later hook will confirm anything about
|
|
299
|
+
* it. An answer delivered once and settled is strictly better than an answer
|
|
300
|
+
* redelivered on every turn-end for ever.
|
|
301
|
+
*
|
|
302
|
+
* Every path that hands nothing over reports `held`, so the accepted journal
|
|
303
|
+
* replays it. Unknown state deliberately returns without throwing, so the
|
|
304
|
+
* journal becomes `hold-for-next-turn` instead of failing the hook.
|
|
305
|
+
*/
|
|
306
|
+
export function claudeWakeRoute(options: {
|
|
307
|
+
sessionId: string
|
|
308
|
+
cwd: string
|
|
309
|
+
sourcePid: number
|
|
310
|
+
adapters?: ClaudeWakeAdapters
|
|
311
|
+
}): EscalationDeliveryRoute {
|
|
312
|
+
const adapters = options.adapters ?? systemClaudeWakeAdapters()
|
|
313
|
+
let sourceDescriptor: ClaudeSessionDescriptor | null = null
|
|
314
|
+
try {
|
|
315
|
+
const parsed = parseDescriptor(adapters.readDescriptor(options.sourcePid))
|
|
316
|
+
if (
|
|
317
|
+
parsed !== null &&
|
|
318
|
+
parsed.sessionId === options.sessionId &&
|
|
319
|
+
parsed.pid === options.sourcePid
|
|
320
|
+
) {
|
|
321
|
+
sourceDescriptor = parsed
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
// Delivery fails closed below; route construction must never break the hook.
|
|
325
|
+
}
|
|
326
|
+
return {
|
|
327
|
+
kind: 'inbox-socket',
|
|
328
|
+
async deliver(event: ContinuationEvent): Promise<DeliveryOutcome> {
|
|
329
|
+
const observation = await observeClaudeSession(options.sessionId, adapters)
|
|
330
|
+
if (observation.state === 'unknown') {
|
|
331
|
+
return {
|
|
332
|
+
notes: [`holding the accepted answer for the next turn: ${observation.reason}`],
|
|
333
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason: observation.reason },
|
|
334
|
+
acknowledgement: 'held',
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (sourceDescriptor === null) {
|
|
338
|
+
const reason = 'the Stop-hook process cannot prove exact Claude session ownership'
|
|
339
|
+
return {
|
|
340
|
+
notes: [`holding the accepted answer for the next turn: ${reason}`],
|
|
341
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason },
|
|
342
|
+
acknowledgement: 'held',
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (observation.state === 'stopped') {
|
|
346
|
+
// Probe immediately before spawn. A prior descriptor or dead socket is
|
|
347
|
+
// never sufficient: resuming a live session creates a divergent ghost.
|
|
348
|
+
const confirmed = await observeClaudeSession(options.sessionId, adapters)
|
|
349
|
+
if (confirmed.state !== 'stopped') {
|
|
350
|
+
const reason =
|
|
351
|
+
confirmed.state === 'unknown'
|
|
352
|
+
? confirmed.reason
|
|
353
|
+
: 'the Claude session became live before cold resume'
|
|
354
|
+
return {
|
|
355
|
+
notes: [`holding the accepted answer for the next turn: ${reason}`],
|
|
356
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason },
|
|
357
|
+
acknowledgement: 'held',
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
await adapters.resume(options.sessionId, sourceDescriptor.cwd, event.context)
|
|
361
|
+
return {
|
|
362
|
+
notes: ['cold-resumed the stopped Claude session with its accepted answer'],
|
|
363
|
+
log: { route: 'cold-resume', stage: 'delivered' },
|
|
364
|
+
acknowledgement: 'delivered',
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (
|
|
369
|
+
observation.descriptor.pid !== options.sourcePid ||
|
|
370
|
+
sourceDescriptor.startedAt !== observation.descriptor.startedAt
|
|
371
|
+
) {
|
|
372
|
+
const reason = 'the Stop-hook process is not the observed exact Claude session child'
|
|
373
|
+
return {
|
|
374
|
+
notes: [`holding the accepted answer for the next turn: ${reason}`],
|
|
375
|
+
log: { route: 'hold-for-next-turn', stage: 'queued', reason },
|
|
376
|
+
acknowledgement: 'held',
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
await adapters.sendSocket(
|
|
380
|
+
observation.descriptor.messagingSocketPath,
|
|
381
|
+
socketLine(event.context),
|
|
382
|
+
)
|
|
383
|
+
await adapters.sleep(CLAUDE_POST_SEND_LIVENESS_MS)
|
|
384
|
+
return {
|
|
385
|
+
notes: [
|
|
386
|
+
observation.state === 'live-idle'
|
|
387
|
+
? 'posted the accepted answer to the live Claude session'
|
|
388
|
+
: 'queued the accepted answer for the busy Claude session',
|
|
389
|
+
],
|
|
390
|
+
log: {
|
|
391
|
+
route: 'inbox-socket',
|
|
392
|
+
stage: 'delivered',
|
|
393
|
+
session_state: observation.state,
|
|
394
|
+
},
|
|
395
|
+
// The write completed: Claude Code holds the message. Nothing later
|
|
396
|
+
// reports on it, so this is where the journal settles.
|
|
397
|
+
acknowledgement: 'delivered',
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function runClaude(args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): Promise<string> {
|
|
404
|
+
return new Promise((resolve, reject) => {
|
|
405
|
+
const child = spawn('claude', args, {
|
|
406
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
407
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
408
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
409
|
+
})
|
|
410
|
+
const stdout: Buffer[] = []
|
|
411
|
+
const stderr: Buffer[] = []
|
|
412
|
+
child.stdout?.on('data', (chunk: Buffer | string) => stdout.push(Buffer.from(chunk)))
|
|
413
|
+
child.stderr?.on('data', (chunk: Buffer | string) => stderr.push(Buffer.from(chunk)))
|
|
414
|
+
child.once('error', reject)
|
|
415
|
+
child.once('exit', (code, signal) => {
|
|
416
|
+
if (code === 0) {
|
|
417
|
+
resolve(Buffer.concat(stdout).toString())
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
reject(
|
|
421
|
+
new Error(
|
|
422
|
+
`claude ${args[0] ?? ''} exited ${code === null ? `on ${String(signal)}` : String(code)}: ${Buffer.concat(stderr).toString().trim()}`,
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
})
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function coldResumeEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
430
|
+
const next = { ...env }
|
|
431
|
+
for (const key of Object.keys(next)) {
|
|
432
|
+
if (key === 'CLAUDECODE' || key === 'CLAUDE_PID' || key.startsWith('CLAUDE_CODE_')) {
|
|
433
|
+
delete next[key]
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return next
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function systemClaudeWakeAdapters(
|
|
440
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
441
|
+
): ClaudeWakeAdapters {
|
|
442
|
+
return {
|
|
443
|
+
async listAgents() {
|
|
444
|
+
const output = await runClaude(['agents', '--json'], { env })
|
|
445
|
+
return JSON.parse(output) as unknown
|
|
446
|
+
},
|
|
447
|
+
readDescriptor(pid) {
|
|
448
|
+
const file = path.join(os.homedir(), '.claude', 'sessions', `${pid}.json`)
|
|
449
|
+
return JSON.parse(readFileSync(file, 'utf8')) as unknown
|
|
450
|
+
},
|
|
451
|
+
sendSocket(socketPath, line) {
|
|
452
|
+
return new Promise<void>((resolve, reject) => {
|
|
453
|
+
const socket = createConnection(socketPath)
|
|
454
|
+
let settled = false
|
|
455
|
+
const fail = (err: Error): void => {
|
|
456
|
+
if (settled) return
|
|
457
|
+
settled = true
|
|
458
|
+
socket.destroy()
|
|
459
|
+
reject(err)
|
|
460
|
+
}
|
|
461
|
+
socket.once('error', fail)
|
|
462
|
+
socket.once('connect', () => {
|
|
463
|
+
socket.end(line, () => {
|
|
464
|
+
if (settled) return
|
|
465
|
+
settled = true
|
|
466
|
+
resolve()
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
},
|
|
471
|
+
async resume(sessionId, cwd, context) {
|
|
472
|
+
if (!existsSync(cwd)) throw new Error(`Claude session cwd no longer exists: ${cwd}`)
|
|
473
|
+
await runClaude(
|
|
474
|
+
[
|
|
475
|
+
'-p',
|
|
476
|
+
'--resume',
|
|
477
|
+
sessionId,
|
|
478
|
+
'--output-format',
|
|
479
|
+
'json',
|
|
480
|
+
context,
|
|
481
|
+
],
|
|
482
|
+
{ cwd, env: coldResumeEnvironment(env) },
|
|
483
|
+
)
|
|
484
|
+
},
|
|
485
|
+
sleep(milliseconds) {
|
|
486
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
487
|
+
},
|
|
488
|
+
}
|
|
489
|
+
}
|
package/src/client.test.ts
CHANGED
|
@@ -51,6 +51,18 @@ describe('a server that never answers', () => {
|
|
|
51
51
|
await expect(client.listDevices()).rejects.toThrow(/did not respond within/)
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
+
it('clamps a late request to the caller whole-operation deadline', async () => {
|
|
55
|
+
const baseUrl = await serving(() => {})
|
|
56
|
+
const started = Date.now()
|
|
57
|
+
const client = createClient(baseUrl, null, {
|
|
58
|
+
timeoutMs: 5_000,
|
|
59
|
+
deadlineAt: started + 200,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
|
|
63
|
+
expect(Date.now() - started).toBeLessThan(2_000)
|
|
64
|
+
})
|
|
65
|
+
|
|
54
66
|
it('allows for the long poll it asked the server to hold', async () => {
|
|
55
67
|
// The deadline is per request, so a 25s long poll must not be cut off by
|
|
56
68
|
// the ordinary budget — otherwise waiting for a reply could never work.
|
package/src/client.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SubmissionReceipt,
|
|
14
14
|
SubmitNotificationRequestT,
|
|
15
15
|
} from '@raidiant/notifai-protocol'
|
|
16
|
+
import type { Logger } from './logging.js'
|
|
16
17
|
|
|
17
18
|
export class ApiCallError extends Error {
|
|
18
19
|
constructor(
|
|
@@ -28,6 +29,18 @@ export class ApiCallError extends Error {
|
|
|
28
29
|
|
|
29
30
|
export class NetworkError extends Error {}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether a replies-poll failure is temporary while the durable request stays
|
|
34
|
+
* live. Transport faults, throttling, request timeouts, and server failures
|
|
35
|
+
* can recover on a later poll; client errors cannot be repaired by retrying
|
|
36
|
+
* the same authenticated request.
|
|
37
|
+
*/
|
|
38
|
+
export function isRetryableReplyPollError(err: unknown): boolean {
|
|
39
|
+
if (err instanceof NetworkError) return true
|
|
40
|
+
if (!(err instanceof ApiCallError)) return false
|
|
41
|
+
return err.status >= 500 || err.status === 429 || err.status === 408
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/**
|
|
32
45
|
* A deadline that fired reads as an abort, which says nothing useful on its
|
|
33
46
|
* own. Naming the timeout is what lets someone tell a hung server apart from a
|
|
@@ -57,8 +70,8 @@ export interface ApiClient {
|
|
|
57
70
|
requestId: string,
|
|
58
71
|
options: { waitSeconds: number; afterSeq: number },
|
|
59
72
|
): Promise<ListRepliesResponse>
|
|
60
|
-
/** Retire a question
|
|
61
|
-
closeReplies(requestId: string): Promise<
|
|
73
|
+
/** Retire a question and return the replies committed before the close fence. */
|
|
74
|
+
closeReplies(requestId: string): Promise<ListRepliesResponse>
|
|
62
75
|
createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
|
|
63
76
|
uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
|
|
64
77
|
health(): Promise<boolean>
|
|
@@ -71,6 +84,21 @@ export interface ClientOptions {
|
|
|
71
84
|
* whole network path stays inside the budget the harness allows them.
|
|
72
85
|
*/
|
|
73
86
|
timeoutMs?: number
|
|
87
|
+
/**
|
|
88
|
+
* Absolute wall-clock ceiling for the caller's whole operation. This is
|
|
89
|
+
* intentionally separate from `timeoutMs`: a late long poll must not get a
|
|
90
|
+
* fresh per-request allowance after its owning harness hook is nearly done.
|
|
91
|
+
*/
|
|
92
|
+
deadlineAt?: number
|
|
93
|
+
/** Test seam for `deadlineAt`; production uses the wall clock. */
|
|
94
|
+
now?: () => number
|
|
95
|
+
/**
|
|
96
|
+
* Records each call locally. Worth having because the two failures that cost
|
|
97
|
+
* the most time — a server older than this CLI, and a network path that only
|
|
98
|
+
* fails inside a hook — both look identical from outside and are told apart
|
|
99
|
+
* instantly by a status code and a duration.
|
|
100
|
+
*/
|
|
101
|
+
logger?: Pick<Logger, 'debug' | 'error'>
|
|
74
102
|
}
|
|
75
103
|
|
|
76
104
|
/** Generous enough for a slow link, short enough that nothing hangs for ever. */
|
|
@@ -83,6 +111,8 @@ export function createClient(
|
|
|
83
111
|
): ApiClient {
|
|
84
112
|
const root = baseUrl.replace(/\/$/, '')
|
|
85
113
|
const budgetMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
114
|
+
const now = options.now ?? Date.now
|
|
115
|
+
const logger = options.logger
|
|
86
116
|
|
|
87
117
|
async function call<T>(
|
|
88
118
|
method: string,
|
|
@@ -90,12 +120,47 @@ export function createClient(
|
|
|
90
120
|
body?: unknown,
|
|
91
121
|
/** Seconds the server was asked to hold the connection open. */
|
|
92
122
|
serverWaitSeconds = 0,
|
|
123
|
+
): Promise<T> {
|
|
124
|
+
const startedAt = Date.now()
|
|
125
|
+
try {
|
|
126
|
+
const result = await callOnce<T>(method, apiPath, body, serverWaitSeconds)
|
|
127
|
+
logger?.debug('http.call', {
|
|
128
|
+
method,
|
|
129
|
+
path: apiPath,
|
|
130
|
+
ok: true,
|
|
131
|
+
duration_ms: Date.now() - startedAt,
|
|
132
|
+
})
|
|
133
|
+
return result
|
|
134
|
+
} catch (err) {
|
|
135
|
+
logger?.error('http.call', {
|
|
136
|
+
method,
|
|
137
|
+
path: apiPath,
|
|
138
|
+
ok: false,
|
|
139
|
+
duration_ms: Date.now() - startedAt,
|
|
140
|
+
...(err instanceof ApiCallError
|
|
141
|
+
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
142
|
+
: { message: String(err) }),
|
|
143
|
+
})
|
|
144
|
+
throw err
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function callOnce<T>(
|
|
149
|
+
method: string,
|
|
150
|
+
apiPath: string,
|
|
151
|
+
body?: unknown,
|
|
152
|
+
serverWaitSeconds = 0,
|
|
93
153
|
): Promise<T> {
|
|
94
154
|
// Without this, a server that accepts the connection and then never answers
|
|
95
155
|
// hangs until the harness kills the whole hook — and the reply-poll deadline
|
|
96
156
|
// cannot interrupt an individual fetch. The signal covers the
|
|
97
157
|
// body read too, not just the response headers.
|
|
98
|
-
const
|
|
158
|
+
const requestBudgetMs = budgetMs + serverWaitSeconds * 1000
|
|
159
|
+
const remainingMs =
|
|
160
|
+
options.deadlineAt === undefined
|
|
161
|
+
? requestBudgetMs
|
|
162
|
+
: Math.max(1, options.deadlineAt - now())
|
|
163
|
+
const limitMs = Math.max(1, Math.min(requestBudgetMs, remainingMs))
|
|
99
164
|
const signal = AbortSignal.timeout(limitMs)
|
|
100
165
|
let response: Response
|
|
101
166
|
try {
|
|
@@ -154,7 +219,10 @@ export function createClient(
|
|
|
154
219
|
waitSeconds,
|
|
155
220
|
),
|
|
156
221
|
closeReplies: (requestId) =>
|
|
157
|
-
call(
|
|
222
|
+
call<ListRepliesResponse>(
|
|
223
|
+
'POST',
|
|
224
|
+
`/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`,
|
|
225
|
+
),
|
|
158
226
|
createMediaUpload: (body) => call('POST', '/api/v1/media', body),
|
|
159
227
|
uploadMedia: async (grant, bytes) => {
|
|
160
228
|
let response: Response
|