@raidiant/notifai 0.2.1 → 0.4.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 +20 -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 +85 -6
- package/dist/commands.js +1133 -322
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +62 -0
- package/dist/config-schema.js +312 -0
- package/dist/config-schema.js.map +1 -0
- 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 +4 -0
- package/dist/interactive.js +604 -0
- package/dist/interactive.js.map +1 -0
- package/dist/logging.d.ts +142 -0
- package/dist/logging.js +533 -0
- package/dist/logging.js.map +1 -0
- package/dist/main.js +201 -51
- 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/banner.d.ts +22 -0
- package/dist/ui/banner.js +87 -0
- package/dist/ui/banner.js.map +1 -0
- package/dist/ui/config-view.d.ts +32 -0
- package/dist/ui/config-view.js +108 -0
- package/dist/ui/config-view.js.map +1 -0
- package/dist/ui/help.d.ts +46 -0
- package/dist/ui/help.js +105 -0
- package/dist/ui/help.js.map +1 -0
- package/dist/ui/theme.d.ts +108 -0
- package/dist/ui/theme.js +261 -0
- package/dist/ui/theme.js.map +1 -0
- package/package.json +11 -9
- package/src/atomic-file.ts +86 -7
- package/src/client.test.ts +12 -0
- package/src/client.ts +72 -4
- package/src/commands.test.ts +971 -44
- package/src/commands.ts +1323 -337
- package/src/config-schema.test.ts +152 -0
- package/src/config-schema.ts +363 -0
- package/src/config.test.ts +6 -5
- package/src/config.ts +85 -16
- 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 +48 -0
- package/src/harnesses.ts +59 -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 +1318 -240
- package/src/hooks.ts +1046 -296
- package/src/install-hooks.test.ts +90 -41
- package/src/install-hooks.ts +259 -74
- package/src/interactive.ts +692 -0
- 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 +220 -48
- package/src/opencode-plugin.ts +15 -16
- package/src/session-end-log-lock-worker.test.ts +35 -0
- package/src/skill.test.ts +11 -0
- package/src/ui/banner.ts +95 -0
- package/src/ui/config-view.test.ts +88 -0
- package/src/ui/config-view.ts +138 -0
- package/src/ui/help.ts +124 -0
- package/src/ui/theme.test.ts +92 -0
- package/src/ui/theme.ts +279 -0
package/src/file-lock.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
fstatSync,
|
|
6
|
+
lstatSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmdirSync,
|
|
12
|
+
unlinkSync,
|
|
13
|
+
} from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Serialize a short cross-process filesystem transaction.
|
|
18
|
+
*
|
|
19
|
+
* Each contender publishes all of its ownership in one unique filename. A
|
|
20
|
+
* two-phase bakery lock then orders live contenders by ticket and process
|
|
21
|
+
* identity. Because paths are never reused, recovering an entry left by a dead
|
|
22
|
+
* process cannot remove a later owner's entry; because process liveness, rather
|
|
23
|
+
* than age, decides recovery, a delayed holder is never mistaken for a dead one.
|
|
24
|
+
*
|
|
25
|
+
* Acquisition is deliberately bounded because both harness hooks and logging
|
|
26
|
+
* must fail open rather than hold an agent turn indefinitely.
|
|
27
|
+
*/
|
|
28
|
+
const FILE_LOCK_WAIT_MS = 1_000
|
|
29
|
+
const FILE_LOCK_POLL_MS = 5
|
|
30
|
+
const FILE_LOCK_TOKEN_BYTES = 12
|
|
31
|
+
const ENTRY_PATTERN = /^(choosing|ticket-(\d+))-(\d+)-([0-9a-f]+)$/
|
|
32
|
+
const lockSleep = new Int32Array(new SharedArrayBuffer(4))
|
|
33
|
+
|
|
34
|
+
export type FileLockObservation =
|
|
35
|
+
| { phase: 'choosing-published'; entry: string }
|
|
36
|
+
| { phase: 'stale-entry'; entry: string }
|
|
37
|
+
| { phase: 'waiting'; blockers: string[] }
|
|
38
|
+
|
|
39
|
+
interface FileLockOptions {
|
|
40
|
+
waitMs?: number
|
|
41
|
+
observe?: (observation: FileLockObservation) => void
|
|
42
|
+
/** Installer transactions report lock replacement after a successful action. */
|
|
43
|
+
strictRelease?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FileIdentity {
|
|
47
|
+
dev: number
|
|
48
|
+
ino: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ChoosingEntry {
|
|
52
|
+
kind: 'choosing'
|
|
53
|
+
name: string
|
|
54
|
+
pid: number
|
|
55
|
+
token: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TicketEntry {
|
|
59
|
+
kind: 'ticket'
|
|
60
|
+
name: string
|
|
61
|
+
pid: number
|
|
62
|
+
token: string
|
|
63
|
+
ticket: bigint
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type LockEntry = ChoosingEntry | TicketEntry
|
|
67
|
+
|
|
68
|
+
function parseEntry(name: string): LockEntry | null {
|
|
69
|
+
const match = ENTRY_PATTERN.exec(name)
|
|
70
|
+
if (match === null) return null
|
|
71
|
+
const pid = Number(match[3])
|
|
72
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null
|
|
73
|
+
const token = match[4]!
|
|
74
|
+
if (match[1] === 'choosing') return { kind: 'choosing', name, pid, token }
|
|
75
|
+
try {
|
|
76
|
+
const ticket = BigInt(match[2]!)
|
|
77
|
+
if (ticket <= 0n) return null
|
|
78
|
+
return { kind: 'ticket', name, pid, token, ticket }
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function processIsAlive(pid: number): boolean {
|
|
85
|
+
if (pid === process.pid) return true
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0)
|
|
88
|
+
return true
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
91
|
+
if (code === 'ESRCH') return false
|
|
92
|
+
// EPERM means the process exists but is owned by somebody else. Unknown
|
|
93
|
+
// failures are also treated as live: losing recovery is safer than allowing
|
|
94
|
+
// two holders into the transaction.
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function currentUid(): number | undefined {
|
|
100
|
+
return typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function assertOwnedDirectory(directory: string): FileIdentity {
|
|
104
|
+
const stat = lstatSync(directory)
|
|
105
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
106
|
+
throw new Error(`${directory} is not a regular directory; refusing to use it as a file lock.`)
|
|
107
|
+
}
|
|
108
|
+
const uid = currentUid()
|
|
109
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
110
|
+
throw new Error(`${directory} is owned by uid ${stat.uid}, not the current user.`)
|
|
111
|
+
}
|
|
112
|
+
return { dev: stat.dev, ino: stat.ino }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function assertSameDirectory(directory: string, expected: FileIdentity): void {
|
|
116
|
+
const current = lstatSync(directory)
|
|
117
|
+
if (
|
|
118
|
+
current.isSymbolicLink() ||
|
|
119
|
+
!current.isDirectory() ||
|
|
120
|
+
current.dev !== expected.dev ||
|
|
121
|
+
current.ino !== expected.ino
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(`${directory} changed while the file lock was active.`)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sameIdentity(file: string, expected: FileIdentity): boolean {
|
|
128
|
+
try {
|
|
129
|
+
const current = lstatSync(file)
|
|
130
|
+
return current.dev === expected.dev && current.ino === expected.ino
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Live protocol entries, pruning only unique paths whose process is gone. */
|
|
137
|
+
function liveEntries(
|
|
138
|
+
directory: string,
|
|
139
|
+
directoryIdentity: FileIdentity,
|
|
140
|
+
observe: FileLockOptions['observe'],
|
|
141
|
+
): LockEntry[] {
|
|
142
|
+
assertSameDirectory(directory, directoryIdentity)
|
|
143
|
+
const live: LockEntry[] = []
|
|
144
|
+
for (const name of readdirSync(directory)) {
|
|
145
|
+
const entry = parseEntry(name)
|
|
146
|
+
if (entry === null) continue
|
|
147
|
+
const entryPath = path.join(directory, name)
|
|
148
|
+
let stat
|
|
149
|
+
try {
|
|
150
|
+
stat = lstatSync(entryPath)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
153
|
+
throw err
|
|
154
|
+
}
|
|
155
|
+
const uid = currentUid()
|
|
156
|
+
if (stat.isSymbolicLink() || !stat.isFile() || (uid !== undefined && stat.uid !== uid)) {
|
|
157
|
+
throw new Error(`${entryPath} is not a current-user-owned regular file-lock entry.`)
|
|
158
|
+
}
|
|
159
|
+
if (processIsAlive(entry.pid)) {
|
|
160
|
+
live.push(entry)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
observe?.({ phase: 'stale-entry', entry: name })
|
|
164
|
+
try {
|
|
165
|
+
// Recheck the unique dead owner's inode so delayed recovery cannot reap
|
|
166
|
+
// a replacement published at the same path by external interference.
|
|
167
|
+
const current = lstatSync(entryPath)
|
|
168
|
+
if (current.dev === stat.dev && current.ino === stat.ino) unlinkSync(entryPath)
|
|
169
|
+
} catch {
|
|
170
|
+
// Another contender already recovered this exact dead owner's unique path.
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return live
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function publishEmpty(file: string): FileIdentity {
|
|
177
|
+
const handle = openSync(file, 'wx', 0o600)
|
|
178
|
+
try {
|
|
179
|
+
const stat = fstatSync(handle)
|
|
180
|
+
return { dev: stat.dev, ino: stat.ino }
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(handle)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function publishChoosing(
|
|
187
|
+
directory: string,
|
|
188
|
+
name: string,
|
|
189
|
+
deadline: number,
|
|
190
|
+
observe: FileLockOptions['observe'],
|
|
191
|
+
): { file: string; fileIdentity: FileIdentity; directoryIdentity: FileIdentity } {
|
|
192
|
+
const file = path.join(directory, name)
|
|
193
|
+
for (;;) {
|
|
194
|
+
try {
|
|
195
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 })
|
|
196
|
+
const directoryIdentity = assertOwnedDirectory(directory)
|
|
197
|
+
chmodSync(directory, 0o700)
|
|
198
|
+
// Recover before registration so a delayed stale cleanup can only name an
|
|
199
|
+
// old owner's unique path, never this contender's future one.
|
|
200
|
+
liveEntries(directory, directoryIdentity, observe)
|
|
201
|
+
const fileIdentity = publishEmpty(file)
|
|
202
|
+
assertSameDirectory(directory, directoryIdentity)
|
|
203
|
+
return { file, fileIdentity, directoryIdentity }
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
206
|
+
// The previous holder removed an empty rendezvous directory between our
|
|
207
|
+
// mkdir and publication. Recreate it; once our entry exists, no cooperating
|
|
208
|
+
// releaser can remove the non-empty directory underneath us.
|
|
209
|
+
if (Date.now() >= deadline) {
|
|
210
|
+
throw new Error(`timed out publishing file lock contender in ${directory}`)
|
|
211
|
+
}
|
|
212
|
+
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function comesBefore(entry: TicketEntry, ticket: bigint, pid: number, token: string): boolean {
|
|
218
|
+
if (entry.ticket !== ticket) return entry.ticket < ticket
|
|
219
|
+
if (entry.pid !== pid) return entry.pid < pid
|
|
220
|
+
return entry.token < token
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function withFileLock<T>(file: string, action: () => T, options: FileLockOptions = {}): T {
|
|
224
|
+
const waitMs = options.waitMs ?? FILE_LOCK_WAIT_MS
|
|
225
|
+
const deadline = Date.now() + waitMs
|
|
226
|
+
const pid = process.pid
|
|
227
|
+
const token = randomBytes(FILE_LOCK_TOKEN_BYTES).toString('hex')
|
|
228
|
+
const choosingName = `choosing-${pid}-${token}`
|
|
229
|
+
let ownedPath: string | null = null
|
|
230
|
+
let ownedIdentity: FileIdentity | null = null
|
|
231
|
+
let directoryIdentity: FileIdentity | null = null
|
|
232
|
+
let operationFailed = false
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const published = publishChoosing(file, choosingName, deadline, options.observe)
|
|
236
|
+
ownedPath = published.file
|
|
237
|
+
ownedIdentity = published.fileIdentity
|
|
238
|
+
directoryIdentity = published.directoryIdentity
|
|
239
|
+
options.observe?.({ phase: 'choosing-published', entry: choosingName })
|
|
240
|
+
let highest = 0n
|
|
241
|
+
for (const entry of liveEntries(file, directoryIdentity, options.observe)) {
|
|
242
|
+
if (entry.kind === 'ticket' && entry.ticket > highest) highest = entry.ticket
|
|
243
|
+
}
|
|
244
|
+
const ticket = highest + 1n
|
|
245
|
+
const ticketName = `ticket-${ticket}-${pid}-${token}`
|
|
246
|
+
const ticketPath = path.join(file, ticketName)
|
|
247
|
+
|
|
248
|
+
// The rename is the doorway: observers see either a chooser or its complete
|
|
249
|
+
// ticket, never a gap in which a live contender has no published state.
|
|
250
|
+
renameSync(ownedPath, ticketPath)
|
|
251
|
+
ownedPath = ticketPath
|
|
252
|
+
|
|
253
|
+
for (;;) {
|
|
254
|
+
const blockers: string[] = []
|
|
255
|
+
for (const entry of liveEntries(file, directoryIdentity, options.observe)) {
|
|
256
|
+
if (entry.pid === pid && entry.token === token) continue
|
|
257
|
+
if (entry.kind === 'choosing' || comesBefore(entry, ticket, pid, token)) {
|
|
258
|
+
blockers.push(entry.name)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (blockers.length === 0) {
|
|
262
|
+
assertSameDirectory(file, directoryIdentity)
|
|
263
|
+
return action()
|
|
264
|
+
}
|
|
265
|
+
options.observe?.({ phase: 'waiting', blockers })
|
|
266
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for file lock ${file}`)
|
|
267
|
+
Atomics.wait(lockSleep, 0, 0, FILE_LOCK_POLL_MS)
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
operationFailed = true
|
|
271
|
+
throw err
|
|
272
|
+
} finally {
|
|
273
|
+
let releaseChanged = false
|
|
274
|
+
if (ownedPath !== null && ownedIdentity !== null) {
|
|
275
|
+
try {
|
|
276
|
+
if (sameIdentity(ownedPath, ownedIdentity)) unlinkSync(ownedPath)
|
|
277
|
+
else releaseChanged = true
|
|
278
|
+
} catch {
|
|
279
|
+
// External cleanup cannot turn a release failure into a caller failure.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
if (directoryIdentity !== null) assertSameDirectory(file, directoryIdentity)
|
|
284
|
+
rmdirSync(file)
|
|
285
|
+
} catch {
|
|
286
|
+
// Another contender has published, or external cleanup already removed it.
|
|
287
|
+
}
|
|
288
|
+
if (releaseChanged && options.strictRelease === true && !operationFailed) {
|
|
289
|
+
throw new Error(`${ownedPath ?? file} changed while held; refusing to remove the replacement.`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Serialize a read/merge/write transaction for one user-owned target file.
|
|
296
|
+
*
|
|
297
|
+
* This is the single installer-facing lock policy: the target's direct parent
|
|
298
|
+
* is a real current-user-owned directory, remains the same inode throughout,
|
|
299
|
+
* and the bakery rendezvous lives beside the target rather than inside it.
|
|
300
|
+
*/
|
|
301
|
+
export function withTargetFileLock<T>(
|
|
302
|
+
target: string,
|
|
303
|
+
action: () => T,
|
|
304
|
+
options: Omit<FileLockOptions, 'strictRelease'> = {},
|
|
305
|
+
): T {
|
|
306
|
+
const parent = path.dirname(target)
|
|
307
|
+
mkdirSync(parent, { recursive: true })
|
|
308
|
+
const parentIdentity = assertOwnedDirectory(parent)
|
|
309
|
+
const lockDirectory = path.join(parent, `.${path.basename(target)}.notifai.lock`)
|
|
310
|
+
return withFileLock(
|
|
311
|
+
lockDirectory,
|
|
312
|
+
() => {
|
|
313
|
+
assertSameDirectory(parent, parentIdentity)
|
|
314
|
+
return action()
|
|
315
|
+
},
|
|
316
|
+
{ ...options, strictRelease: true },
|
|
317
|
+
)
|
|
318
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING } from './harnesses.js'
|
|
3
|
+
|
|
4
|
+
describe('harness continuation contract', () => {
|
|
5
|
+
it('requires one explicit native delivery contract for every shipped harness', () => {
|
|
6
|
+
expect(Object.keys(HARNESS_CAPABILITIES).sort()).toEqual([...HARNESSES].sort())
|
|
7
|
+
expect(HARNESS_CAPABILITIES).toEqual({
|
|
8
|
+
'claude-code': {
|
|
9
|
+
stopContinuation: 'decision-block',
|
|
10
|
+
dormantWake: 'unsupported',
|
|
11
|
+
deliveryContract:
|
|
12
|
+
'live Stop continuation; no automatic wake after its bounded wait returns',
|
|
13
|
+
},
|
|
14
|
+
codex: {
|
|
15
|
+
stopContinuation: 'decision-block',
|
|
16
|
+
dormantWake: 'unsupported',
|
|
17
|
+
deliveryContract:
|
|
18
|
+
'live Stop continuation; no automatic wake after its bounded wait returns',
|
|
19
|
+
},
|
|
20
|
+
cursor: {
|
|
21
|
+
stopContinuation: 'unsupported',
|
|
22
|
+
dormantWake: 'unsupported',
|
|
23
|
+
deliveryContract:
|
|
24
|
+
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
25
|
+
},
|
|
26
|
+
opencode: {
|
|
27
|
+
stopContinuation: 'unsupported',
|
|
28
|
+
dormantWake: 'unsupported',
|
|
29
|
+
deliveryContract:
|
|
30
|
+
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('derives the maximum grace from the reserves every Stop must retain', () => {
|
|
36
|
+
expect(HOOK_TIMING.maxGraceSeconds).toBe(
|
|
37
|
+
HOOK_TIMING.totalSeconds -
|
|
38
|
+
HOOK_TIMING.finalizationReserveSeconds -
|
|
39
|
+
HOOK_TIMING.submissionReserveSeconds -
|
|
40
|
+
HOOK_TIMING.minimumReplySeconds -
|
|
41
|
+
HOOK_TIMING.schedulingSlackSeconds,
|
|
42
|
+
)
|
|
43
|
+
expect(HOOK_TIMING.finalizationReserveSeconds).toBeGreaterThan(
|
|
44
|
+
HOOK_TIMING.stdoutReserveSeconds,
|
|
45
|
+
)
|
|
46
|
+
expect(HOOK_TIMING.totalSeconds + HOOK_TIMING.hostHeadroomSeconds).toBe(540)
|
|
47
|
+
})
|
|
48
|
+
})
|
package/src/harnesses.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Every harness Notifai ships. This is the sole source of the harness union. */
|
|
2
|
+
export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
3
|
+
|
|
4
|
+
export type Harness = (typeof HARNESSES)[number]
|
|
5
|
+
|
|
6
|
+
/** One canonical wall-clock budget shared by config, hooks, and installers. */
|
|
7
|
+
export const HOOK_TIMING = Object.freeze({
|
|
8
|
+
totalSeconds: 480,
|
|
9
|
+
finalizationReserveSeconds: 45,
|
|
10
|
+
submissionReserveSeconds: 40,
|
|
11
|
+
minimumReplySeconds: 60,
|
|
12
|
+
schedulingSlackSeconds: 1,
|
|
13
|
+
stdoutReserveSeconds: 5,
|
|
14
|
+
hostHeadroomSeconds: 60,
|
|
15
|
+
maxGraceSeconds: 334,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export type StopContinuation =
|
|
19
|
+
| 'decision-block'
|
|
20
|
+
| 'followup-message'
|
|
21
|
+
| 'unsupported'
|
|
22
|
+
|
|
23
|
+
export interface HarnessCapability {
|
|
24
|
+
/** How an answer is admitted to another turn in the already-open session. */
|
|
25
|
+
stopContinuation: StopContinuation
|
|
26
|
+
/** Whether delivery remains native after the turn-end event has returned. */
|
|
27
|
+
dormantWake: 'unsupported' | 'native-session-prompt'
|
|
28
|
+
/** Concise, honest readiness text for doctor and installation guidance. */
|
|
29
|
+
deliveryContract: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Exhaustive runtime contract. Adding a harness without deciding every
|
|
34
|
+
* continuation joint is a type error, not a silently degraded notification.
|
|
35
|
+
*/
|
|
36
|
+
export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
|
|
37
|
+
'claude-code': {
|
|
38
|
+
stopContinuation: 'decision-block',
|
|
39
|
+
dormantWake: 'unsupported',
|
|
40
|
+
deliveryContract: 'live Stop continuation; no automatic wake after its bounded wait returns',
|
|
41
|
+
},
|
|
42
|
+
codex: {
|
|
43
|
+
stopContinuation: 'decision-block',
|
|
44
|
+
dormantWake: 'unsupported',
|
|
45
|
+
deliveryContract: 'live Stop continuation; no automatic wake after its bounded wait returns',
|
|
46
|
+
},
|
|
47
|
+
cursor: {
|
|
48
|
+
stopContinuation: 'unsupported',
|
|
49
|
+
dormantWake: 'unsupported',
|
|
50
|
+
deliveryContract:
|
|
51
|
+
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
52
|
+
},
|
|
53
|
+
opencode: {
|
|
54
|
+
stopContinuation: 'unsupported',
|
|
55
|
+
dormantWake: 'unsupported',
|
|
56
|
+
deliveryContract:
|
|
57
|
+
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { describe, expect, it } from 'vitest'
|
|
15
|
+
import {
|
|
16
|
+
hookAdapterPath,
|
|
17
|
+
inspectHookAdapter,
|
|
18
|
+
installHookAdapter,
|
|
19
|
+
} from './hook-adapter.js'
|
|
20
|
+
import { buildHookConfig, codexHookIdentityHash, type InstalledHandler } from './install-hooks.js'
|
|
21
|
+
|
|
22
|
+
function isolated(): { root: string; homeDir: string } {
|
|
23
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-hook-adapter-'))
|
|
24
|
+
return { root, homeDir: path.join(root, 'home') }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('stable hook adapter', () => {
|
|
28
|
+
it('executes paths with spaces, quotes, and shell syntax without evaluating them', () => {
|
|
29
|
+
const { root, homeDir } = isolated()
|
|
30
|
+
const odd = path.join(root, "odd ' $(touch should-not-run)")
|
|
31
|
+
mkdirSync(odd, { recursive: true })
|
|
32
|
+
const script = path.join(odd, 'target script.js')
|
|
33
|
+
writeFileSync(script, 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n')
|
|
34
|
+
|
|
35
|
+
const installed = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
|
|
36
|
+
const run = spawnSync(installed.path, ['hook', 'stop', '--owner', 'notifai'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(run.status).toBe(0)
|
|
41
|
+
expect(JSON.parse(run.stdout)).toEqual(['hook', 'stop', '--owner', 'notifai'])
|
|
42
|
+
expect(statSync(installed.path).mode & 0o777).toBe(0o700)
|
|
43
|
+
expect(inspectHookAdapter(homeDir).problems).toEqual([])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('retargets Node and CLI paths without changing any harness definition or Codex hash', () => {
|
|
47
|
+
const { root, homeDir } = isolated()
|
|
48
|
+
const first = path.join(root, 'first.js')
|
|
49
|
+
const second = path.join(root, 'second.js')
|
|
50
|
+
writeFileSync(first, '')
|
|
51
|
+
writeFileSync(second, '')
|
|
52
|
+
const adapter = installHookAdapter({ execPath: process.execPath, scriptPath: first }, homeDir)
|
|
53
|
+
const before = buildHookConfig({
|
|
54
|
+
adapterPath: adapter.path,
|
|
55
|
+
harness: 'codex',
|
|
56
|
+
})
|
|
57
|
+
const beforeSource = readFileSync(adapter.path, 'utf8')
|
|
58
|
+
|
|
59
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: second }, homeDir)
|
|
60
|
+
const after = buildHookConfig({
|
|
61
|
+
adapterPath: hookAdapterPath(homeDir),
|
|
62
|
+
harness: 'codex',
|
|
63
|
+
})
|
|
64
|
+
const afterSource = readFileSync(adapter.path, 'utf8')
|
|
65
|
+
const stop = (config: typeof before): InstalledHandler => {
|
|
66
|
+
const handler = config['Stop']![0]!.hooks[0]!
|
|
67
|
+
return {
|
|
68
|
+
event: 'Stop',
|
|
69
|
+
groupIndex: 0,
|
|
70
|
+
handlerIndex: 0,
|
|
71
|
+
command: handler.command,
|
|
72
|
+
timeout: handler.timeout,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
expect(after).toEqual(before)
|
|
77
|
+
expect(afterSource).not.toBe(beforeSource)
|
|
78
|
+
expect(codexHookIdentityHash(stop(after))).toBe(codexHookIdentityHash(stop(before)))
|
|
79
|
+
expect(inspectHookAdapter(homeDir).target?.scriptPath).toBe(second)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('uses a fixed home-relative pathname with no XDG input surface', () => {
|
|
83
|
+
const { homeDir } = isolated()
|
|
84
|
+
expect(hookAdapterPath(homeDir)).toBe(
|
|
85
|
+
path.join(homeDir, '.notifai', 'bin', 'hook-adapter'),
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('uses the OS account home even when HOME is overridden', () => {
|
|
90
|
+
const original = process.env['HOME']
|
|
91
|
+
process.env['HOME'] = '/tmp/attacker-selected-home'
|
|
92
|
+
try {
|
|
93
|
+
expect(hookAdapterPath()).toBe(
|
|
94
|
+
path.join(os.userInfo().homedir, '.notifai', 'bin', 'hook-adapter'),
|
|
95
|
+
)
|
|
96
|
+
expect(hookAdapterPath()).not.toContain('attacker-selected-home')
|
|
97
|
+
} finally {
|
|
98
|
+
if (original === undefined) delete process.env['HOME']
|
|
99
|
+
else process.env['HOME'] = original
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('repairs permissive adapter permissions without changing its path', () => {
|
|
104
|
+
const { root, homeDir } = isolated()
|
|
105
|
+
const script = path.join(root, 'target.js')
|
|
106
|
+
writeFileSync(script, '')
|
|
107
|
+
const first = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
|
|
108
|
+
chmodSync(first.path, 0o755)
|
|
109
|
+
|
|
110
|
+
const repaired = installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir)
|
|
111
|
+
|
|
112
|
+
expect(repaired).toEqual({ path: first.path, changed: true })
|
|
113
|
+
expect(statSync(first.path).mode & 0o777).toBe(0o700)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('refuses a symlinked adapter instead of reading or replacing its target', () => {
|
|
117
|
+
const { root, homeDir } = isolated()
|
|
118
|
+
const target = path.join(root, 'foreign')
|
|
119
|
+
const adapter = hookAdapterPath(homeDir)
|
|
120
|
+
mkdirSync(path.dirname(adapter), { recursive: true })
|
|
121
|
+
writeFileSync(target, 'leave me')
|
|
122
|
+
symlinkSync(target, adapter)
|
|
123
|
+
|
|
124
|
+
expect(() =>
|
|
125
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: target }, homeDir),
|
|
126
|
+
).toThrow(/not a regular file/)
|
|
127
|
+
expect(readFileSync(target, 'utf8')).toBe('leave me')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('refuses a symlinked Notifai-owned parent directory', () => {
|
|
131
|
+
const { root, homeDir } = isolated()
|
|
132
|
+
const foreign = path.join(root, 'foreign-dir')
|
|
133
|
+
const script = path.join(root, 'target.js')
|
|
134
|
+
mkdirSync(homeDir, { recursive: true })
|
|
135
|
+
mkdirSync(foreign)
|
|
136
|
+
writeFileSync(script, '')
|
|
137
|
+
symlinkSync(foreign, path.join(homeDir, '.notifai'))
|
|
138
|
+
|
|
139
|
+
expect(() =>
|
|
140
|
+
installHookAdapter({ execPath: process.execPath, scriptPath: script }, homeDir),
|
|
141
|
+
).toThrow(/not a regular directory/)
|
|
142
|
+
expect(readFileSync(script, 'utf8')).toBe('')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('diagnoses a stale registered runtime and CLI without executing either', () => {
|
|
146
|
+
const { root, homeDir } = isolated()
|
|
147
|
+
const missingRuntime = path.join(root, 'removed-node')
|
|
148
|
+
const missingCli = path.join(root, 'removed-cli.js')
|
|
149
|
+
writeFileSync(missingRuntime, '#!/bin/sh\n')
|
|
150
|
+
chmodSync(missingRuntime, 0o700)
|
|
151
|
+
writeFileSync(missingCli, '')
|
|
152
|
+
installHookAdapter({ execPath: missingRuntime, scriptPath: missingCli }, homeDir)
|
|
153
|
+
rmSync(missingRuntime)
|
|
154
|
+
rmSync(missingCli)
|
|
155
|
+
|
|
156
|
+
expect(inspectHookAdapter(homeDir).problems).toEqual([
|
|
157
|
+
`registered runtime ${missingRuntime} is missing`,
|
|
158
|
+
`registered CLI ${missingCli} is missing`,
|
|
159
|
+
])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('fails closed instead of executing an unregistered PATH target after the registered target vanished', () => {
|
|
163
|
+
const { root, homeDir } = isolated()
|
|
164
|
+
const bin = path.join(root, 'new-bin')
|
|
165
|
+
const replacement = path.join(bin, 'notifai')
|
|
166
|
+
mkdirSync(bin, { recursive: true })
|
|
167
|
+
writeFileSync(replacement, '#!/bin/sh\nprintf "%s" "$*"\n')
|
|
168
|
+
chmodSync(replacement, 0o700)
|
|
169
|
+
const oldNode = path.join(root, 'old-node')
|
|
170
|
+
const oldCli = path.join(root, 'old-cli.js')
|
|
171
|
+
writeFileSync(oldNode, '#!/bin/sh\n')
|
|
172
|
+
chmodSync(oldNode, 0o700)
|
|
173
|
+
writeFileSync(oldCli, '')
|
|
174
|
+
const installed = installHookAdapter({ execPath: oldNode, scriptPath: oldCli }, homeDir)
|
|
175
|
+
rmSync(oldNode)
|
|
176
|
+
rmSync(oldCli)
|
|
177
|
+
|
|
178
|
+
const run = spawnSync(installed.path, ['hook', 'stop'], {
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
env: { PATH: bin },
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(run.status).toBe(127)
|
|
184
|
+
expect(run.stdout).toBe('')
|
|
185
|
+
expect(run.stderr).toMatch(/target is stale/)
|
|
186
|
+
})
|
|
187
|
+
})
|