@raidiant/notifai 5.0.0 → 6.0.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/CHANGELOG.md +27 -0
- package/README.md +2 -2
- package/dist/client.d.ts +8 -3
- package/dist/client.js +24 -3
- package/dist/client.js.map +1 -1
- package/dist/commands-auth.d.ts +14 -0
- package/dist/commands-auth.js +154 -0
- package/dist/commands-auth.js.map +1 -0
- package/dist/commands-config.d.ts +31 -0
- package/dist/commands-config.js +246 -0
- package/dist/commands-config.js.map +1 -0
- package/dist/commands-core.d.ts +102 -0
- package/dist/commands-core.js +135 -0
- package/dist/commands-core.js.map +1 -0
- package/dist/commands-devices.d.ts +31 -0
- package/dist/commands-devices.js +124 -0
- package/dist/commands-devices.js.map +1 -0
- package/dist/commands-doctor.d.ts +24 -0
- package/dist/commands-doctor.js +1011 -0
- package/dist/commands-doctor.js.map +1 -0
- package/dist/commands-harness-context.d.ts +61 -0
- package/dist/commands-harness-context.js +93 -0
- package/dist/commands-harness-context.js.map +1 -0
- package/dist/commands-hook-shape.d.ts +17 -0
- package/dist/commands-hook-shape.js +37 -0
- package/dist/commands-hook-shape.js.map +1 -0
- package/dist/commands-hooks.d.ts +115 -0
- package/dist/commands-hooks.js +1225 -0
- package/dist/commands-hooks.js.map +1 -0
- package/dist/commands-init.d.ts +35 -0
- package/dist/commands-init.js +580 -0
- package/dist/commands-init.js.map +1 -0
- package/dist/commands-io.d.ts +2 -0
- package/dist/commands-io.js +110 -0
- package/dist/commands-io.js.map +1 -0
- package/dist/commands-logs.d.ts +30 -0
- package/dist/commands-logs.js +201 -0
- package/dist/commands-logs.js.map +1 -0
- package/dist/commands-send-support.d.ts +44 -0
- package/dist/commands-send-support.js +180 -0
- package/dist/commands-send-support.js.map +1 -0
- package/dist/commands-send.d.ts +33 -0
- package/dist/commands-send.js +519 -0
- package/dist/commands-send.js.map +1 -0
- package/dist/commands-setup-proof.d.ts +15 -0
- package/dist/commands-setup-proof.js +53 -0
- package/dist/commands-setup-proof.js.map +1 -0
- package/dist/commands-skill.d.ts +10 -0
- package/dist/commands-skill.js +71 -0
- package/dist/commands-skill.js.map +1 -0
- package/dist/commands.d.ts +12 -387
- package/dist/commands.js +12 -4536
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +1 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +127 -99
- package/dist/hooks.js.map +1 -1
- package/dist/interactive.js +4 -4
- package/dist/interactive.js.map +1 -1
- package/dist/logging.js +1 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/package.json +2 -2
- package/src/client.test.ts +90 -1
- package/src/client.ts +35 -2
- package/src/commands-auth.ts +174 -0
- package/src/commands-config.ts +307 -0
- package/src/commands-core.ts +240 -0
- package/src/commands-devices.ts +150 -0
- package/src/commands-doctor.ts +1209 -0
- package/src/commands-harness-context.ts +126 -0
- package/src/commands-hook-shape.ts +53 -0
- package/src/commands-hooks.ts +1586 -0
- package/src/commands-init.ts +721 -0
- package/src/commands-io.ts +108 -0
- package/src/commands-logs.ts +243 -0
- package/src/commands-send-support.ts +224 -0
- package/src/commands-send.ts +623 -0
- package/src/commands-setup-proof.ts +71 -0
- package/src/commands-skill.ts +84 -0
- package/src/commands.test.ts +493 -39
- package/src/commands.ts +58 -5614
- package/src/config-schema.ts +1 -1
- package/src/config.test.ts +9 -0
- package/src/config.ts +7 -2
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/hooks.test.ts +351 -11
- package/src/hooks.ts +145 -97
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +4 -4
- package/src/logging.test.ts +15 -2
- package/src/logging.ts +1 -0
- package/src/main.ts +1 -1
- package/src/readiness.ts +3 -1
package/src/config-schema.ts
CHANGED
|
@@ -83,7 +83,7 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
83
83
|
label: 'Send questions to my devices',
|
|
84
84
|
group: 'questions',
|
|
85
85
|
kind: 'boolean',
|
|
86
|
-
summary: 'Whether a registered question may reach your
|
|
86
|
+
summary: 'Whether a registered question may reach your iPhone at all',
|
|
87
87
|
detail:
|
|
88
88
|
'The master switch for question routing. When this is off, a question an agent registers with `notifai ask` stays in the terminal and never leaves this machine, whatever else is configured. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
89
89
|
example: 'true',
|
package/src/config.test.ts
CHANGED
|
@@ -177,6 +177,15 @@ describe('config precedence', () => {
|
|
|
177
177
|
expect(config.wait_seconds.value).toBe(2)
|
|
178
178
|
expect(config.wait_seconds.source).toBe('flag')
|
|
179
179
|
})
|
|
180
|
+
|
|
181
|
+
it('uses the prior default when a newer CLI wrote an unknown enum value', () => {
|
|
182
|
+
const { env, cwd } = setup({
|
|
183
|
+
globalToml: 'sound = "future-sound"\ninterruption_level = "future-level"\n',
|
|
184
|
+
})
|
|
185
|
+
const config = loadConfig({ cwd, env })
|
|
186
|
+
expect(config.sound).toEqual({ value: null, source: 'default' })
|
|
187
|
+
expect(config.interruption_level).toEqual({ value: null, source: 'default' })
|
|
188
|
+
})
|
|
180
189
|
})
|
|
181
190
|
|
|
182
191
|
describe('draft building', () => {
|
package/src/config.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
CLI_SOUNDS,
|
|
3
|
+
INTERRUPTION_LEVELS,
|
|
4
|
+
REPLY_MAX_WINDOW_SECONDS,
|
|
5
|
+
} from '@raidiant/notifai-protocol'
|
|
2
6
|
import { createHash } from 'node:crypto'
|
|
3
7
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
4
8
|
import path from 'node:path'
|
|
5
9
|
import { parse as parseToml } from 'smol-toml'
|
|
6
|
-
import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
|
|
7
10
|
import { configHome, stateHome } from './platform.js'
|
|
8
11
|
|
|
9
12
|
/**
|
|
@@ -133,6 +136,8 @@ export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
|
133
136
|
* is the same discipline the numeric bounds follow.
|
|
134
137
|
*/
|
|
135
138
|
export const ENUM_CONFIG_VALUES: Partial<Record<ConfigKey, readonly string[]>> = {
|
|
139
|
+
sound: CLI_SOUNDS,
|
|
140
|
+
interruption_level: INTERRUPTION_LEVELS,
|
|
136
141
|
log_level: LOG_LEVELS,
|
|
137
142
|
}
|
|
138
143
|
|
package/src/credentials.test.ts
CHANGED
|
@@ -168,7 +168,10 @@ describe('FileStore', () => {
|
|
|
168
168
|
if (process.platform !== 'win32') {
|
|
169
169
|
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
170
170
|
}
|
|
171
|
-
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual(
|
|
171
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toEqual({
|
|
172
|
+
format: 'notifai.machine-credential.v1',
|
|
173
|
+
...SAMPLE,
|
|
174
|
+
})
|
|
172
175
|
expect(store.load()).toEqual(SAMPLE)
|
|
173
176
|
store.save(OTHER)
|
|
174
177
|
expect(store.load()).toEqual(OTHER)
|
|
@@ -190,6 +193,27 @@ describe('FileStore', () => {
|
|
|
190
193
|
writeRaw(file, `${JSON.stringify({ machineId: 'mach_fixture' })}\n`)
|
|
191
194
|
expect(new FileStore(env).load()).toBeNull()
|
|
192
195
|
})
|
|
196
|
+
|
|
197
|
+
it('reads the unversioned v1 shape while making every new save explicit', () => {
|
|
198
|
+
const { env } = sandbox('file-unversioned-v1')
|
|
199
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
200
|
+
writeRaw(file, `${JSON.stringify(SAMPLE)}\n`)
|
|
201
|
+
const store = new FileStore(env)
|
|
202
|
+
expect(store.load()).toEqual(SAMPLE)
|
|
203
|
+
store.save(SAMPLE)
|
|
204
|
+
expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({
|
|
205
|
+
format: 'notifai.machine-credential.v1',
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('does not interpret or erase a credential from a future format epoch', () => {
|
|
210
|
+
const { env } = sandbox('file-future-format')
|
|
211
|
+
const file = path.join(env.XDG_CONFIG_HOME!, 'notifai', 'credentials.json')
|
|
212
|
+
const future = `${JSON.stringify({ format: 'notifai.machine-credential.v2', ...SAMPLE })}\n`
|
|
213
|
+
writeRaw(file, future)
|
|
214
|
+
expect(new FileStore(env).load()).toBeNull()
|
|
215
|
+
expect(readFileSync(file, 'utf8')).toBe(future)
|
|
216
|
+
})
|
|
193
217
|
})
|
|
194
218
|
|
|
195
219
|
describe('WindowsDpapiStore', () => {
|
package/src/credentials.ts
CHANGED
|
@@ -44,6 +44,7 @@ export interface CredentialStoreOptions {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
const SERVICE = 'io.notifai.cli'
|
|
47
|
+
const CREDENTIAL_FORMAT = 'notifai.machine-credential.v1'
|
|
47
48
|
const DPAPI_FORMAT = 'notifai.dpapi.current-user.v1'
|
|
48
49
|
const DPAPI_ENTROPY = 'io.notifai.cli'
|
|
49
50
|
const DPAPI_TIMEOUT_MS = 15_000
|
|
@@ -136,7 +137,7 @@ export class KeychainStore implements CredentialStore {
|
|
|
136
137
|
'-a',
|
|
137
138
|
'machine',
|
|
138
139
|
'-w',
|
|
139
|
-
JSON.stringify(credential),
|
|
140
|
+
JSON.stringify(serializeCredential(credential)),
|
|
140
141
|
],
|
|
141
142
|
{ stdio: 'ignore' },
|
|
142
143
|
)
|
|
@@ -189,7 +190,7 @@ export class FileStore implements CredentialStore {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
save(credential: MachineCredential): void {
|
|
192
|
-
atomicWriteFileSync(this.filePath(), `${JSON.stringify(credential, null, 2)}\n`, {
|
|
193
|
+
atomicWriteFileSync(this.filePath(), `${JSON.stringify(serializeCredential(credential), null, 2)}\n`, {
|
|
193
194
|
mode: 0o600,
|
|
194
195
|
preserveMode: false,
|
|
195
196
|
requireCurrentUserOwner: true,
|
|
@@ -251,7 +252,7 @@ export class WindowsDpapiStore implements CredentialStore {
|
|
|
251
252
|
if (file === null) {
|
|
252
253
|
throw new Error('Windows credential store path is not configured (LOCALAPPDATA is missing)')
|
|
253
254
|
}
|
|
254
|
-
const blob = this.protect(Buffer.from(JSON.stringify(credential), 'utf8'))
|
|
255
|
+
const blob = this.protect(Buffer.from(JSON.stringify(serializeCredential(credential)), 'utf8'))
|
|
255
256
|
atomicWriteFileSync(file, `${JSON.stringify({ format: DPAPI_FORMAT, data: blob })}\n`, {
|
|
256
257
|
mode: 0o600,
|
|
257
258
|
preserveMode: false,
|
|
@@ -302,6 +303,10 @@ export class WindowsDpapiStore implements CredentialStore {
|
|
|
302
303
|
}
|
|
303
304
|
}
|
|
304
305
|
|
|
306
|
+
function serializeCredential(credential: MachineCredential): Record<string, string> {
|
|
307
|
+
return { format: CREDENTIAL_FORMAT, ...credential }
|
|
308
|
+
}
|
|
309
|
+
|
|
305
310
|
function parseCredentialJson(raw: string): MachineCredential | null {
|
|
306
311
|
try {
|
|
307
312
|
return parseCredential(JSON.parse(raw) as unknown)
|
|
@@ -313,6 +318,11 @@ function parseCredentialJson(raw: string): MachineCredential | null {
|
|
|
313
318
|
function parseCredential(raw: unknown): MachineCredential | null {
|
|
314
319
|
if (raw === null || typeof raw !== 'object') return null
|
|
315
320
|
const value = raw as Record<string, unknown>
|
|
321
|
+
const format = value['format']
|
|
322
|
+
// Unversioned credentials are the pre-epoch v1 representation. A future
|
|
323
|
+
// format is left untouched on disk/keychain and treated as unavailable;
|
|
324
|
+
// interpreting it as v1 could silently change authentication meaning.
|
|
325
|
+
if (format !== undefined && format !== CREDENTIAL_FORMAT) return null
|
|
316
326
|
const machineId = value['machineId']
|
|
317
327
|
const secret = value['secret']
|
|
318
328
|
const baseUrl = value['baseUrl']
|
package/src/hooks.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from 'node:child_process'
|
|
1
|
+
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
|
|
2
2
|
import {
|
|
3
3
|
existsSync,
|
|
4
4
|
mkdirSync,
|
|
@@ -167,7 +167,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
167
167
|
platform: 'ios' as const,
|
|
168
168
|
permission_status: 'authorized',
|
|
169
169
|
registration_healthy: true,
|
|
170
|
-
|
|
170
|
+
capabilities: ['answer'],
|
|
171
|
+
derived_status: 'working',
|
|
171
172
|
last_seen_at: null,
|
|
172
173
|
},
|
|
173
174
|
{
|
|
@@ -176,7 +177,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
176
177
|
platform: 'macos' as const,
|
|
177
178
|
permission_status: 'authorized',
|
|
178
179
|
registration_healthy: true,
|
|
179
|
-
|
|
180
|
+
capabilities: ['answer'],
|
|
181
|
+
derived_status: 'working',
|
|
180
182
|
last_seen_at: null,
|
|
181
183
|
},
|
|
182
184
|
],
|
|
@@ -337,6 +339,23 @@ function harness(replies: ReplyView[] = []): Harness {
|
|
|
337
339
|
}
|
|
338
340
|
}
|
|
339
341
|
|
|
342
|
+
function runFixtureGit(cwd: string, ...args: string[]): string {
|
|
343
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
344
|
+
encoding: 'utf8',
|
|
345
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
346
|
+
}).trim()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function initializeFixtureRepository(root: string, branch = 'branch-a'): void {
|
|
350
|
+
runFixtureGit(root, 'init')
|
|
351
|
+
runFixtureGit(root, 'config', 'user.email', 'notifai-tests@example.invalid')
|
|
352
|
+
runFixtureGit(root, 'config', 'user.name', 'Notifai Tests')
|
|
353
|
+
writeFileSync(path.join(root, 'tracked.txt'), 'fixture\n')
|
|
354
|
+
runFixtureGit(root, 'add', 'tracked.txt')
|
|
355
|
+
runFixtureGit(root, 'commit', '-m', 'fixture')
|
|
356
|
+
runFixtureGit(root, 'branch', '-M', branch)
|
|
357
|
+
}
|
|
358
|
+
|
|
340
359
|
function stdin(payload: unknown): () => Promise<string> {
|
|
341
360
|
return async () => JSON.stringify(payload)
|
|
342
361
|
}
|
|
@@ -494,6 +513,26 @@ describe('the waiter wall clock', () => {
|
|
|
494
513
|
})
|
|
495
514
|
|
|
496
515
|
describe('pushing a registered question', () => {
|
|
516
|
+
it('keeps unknown future session keys through an older read-modify-write', () => {
|
|
517
|
+
const h = harness([])
|
|
518
|
+
const sessionId = 'future-session-format'
|
|
519
|
+
const file = path.join(stateDir(h.env), 'sessions', `${sanitizeSessionId(sessionId)}.json`)
|
|
520
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
521
|
+
writeFileSync(
|
|
522
|
+
file,
|
|
523
|
+
`${JSON.stringify({
|
|
524
|
+
last_prompt_at: NOW,
|
|
525
|
+
future_session_key: { nested: ['kept'] },
|
|
526
|
+
})}\n`,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
registerQuestion(sessionId, h.env, { question: 'Still preserved?' }, NOW)
|
|
530
|
+
|
|
531
|
+
const rewritten = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
532
|
+
expect(rewritten['future_session_key']).toEqual({ nested: ['kept'] })
|
|
533
|
+
expect(rewritten['pending']).toHaveLength(1)
|
|
534
|
+
})
|
|
535
|
+
|
|
497
536
|
// Found by a live Claude Code session: a spawned agent always has a
|
|
498
537
|
// just-set last_prompt_at, and the routing gate once read that as the user
|
|
499
538
|
// being present — so its FIRST question could never escalate, which is the
|
|
@@ -864,7 +903,7 @@ describe('terminal-first grace window', () => {
|
|
|
864
903
|
).toBeGreaterThanOrEqual(60)
|
|
865
904
|
})
|
|
866
905
|
|
|
867
|
-
it('never pushes a question to a Companion
|
|
906
|
+
it('never pushes a question to a Companion that did not advertise answer', async () => {
|
|
868
907
|
const h = harness([])
|
|
869
908
|
const factory = h.deps.clientFactory
|
|
870
909
|
h.deps.clientFactory = () => {
|
|
@@ -874,7 +913,7 @@ describe('terminal-first grace window', () => {
|
|
|
874
913
|
listDevices: async () => ({
|
|
875
914
|
devices: (await client.listDevices()).devices.map((device) => ({
|
|
876
915
|
...device,
|
|
877
|
-
|
|
916
|
+
capabilities: [],
|
|
878
917
|
})),
|
|
879
918
|
}),
|
|
880
919
|
} as ApiClient
|
|
@@ -2638,6 +2677,58 @@ describe('user-prompt-submit hook', () => {
|
|
|
2638
2677
|
clearSessionState('s14', h.env)
|
|
2639
2678
|
expect(readProjectSession(project, h.env, NOW)).toBeNull()
|
|
2640
2679
|
})
|
|
2680
|
+
|
|
2681
|
+
it('round-trips future project-pointer fields while publishing a session', () => {
|
|
2682
|
+
const h = harness()
|
|
2683
|
+
const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
2684
|
+
const futureEntry = {
|
|
2685
|
+
format: 2,
|
|
2686
|
+
session: { id: 'future-session', runtime: 'future-harness' },
|
|
2687
|
+
}
|
|
2688
|
+
mkdirSync(path.dirname(pointer), { recursive: true })
|
|
2689
|
+
writeFileSync(
|
|
2690
|
+
pointer,
|
|
2691
|
+
`${JSON.stringify({
|
|
2692
|
+
future_root: { retained: true },
|
|
2693
|
+
sessions: [
|
|
2694
|
+
{
|
|
2695
|
+
session_id: 'still-running',
|
|
2696
|
+
updated_at: NOW - 1,
|
|
2697
|
+
harness: 'codex',
|
|
2698
|
+
future_entry: { retained: true },
|
|
2699
|
+
},
|
|
2700
|
+
{
|
|
2701
|
+
session_id: 'new-session',
|
|
2702
|
+
updated_at: NOW - 2,
|
|
2703
|
+
harness: 'claude-code',
|
|
2704
|
+
future_current_entry: { retained: true },
|
|
2705
|
+
},
|
|
2706
|
+
futureEntry,
|
|
2707
|
+
],
|
|
2708
|
+
})}\n`,
|
|
2709
|
+
)
|
|
2710
|
+
|
|
2711
|
+
writeProjectSession(h.deps.cwd, h.env, 'new-session', NOW, 'claude-code')
|
|
2712
|
+
|
|
2713
|
+
expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
|
|
2714
|
+
future_root: { retained: true },
|
|
2715
|
+
sessions: [
|
|
2716
|
+
{
|
|
2717
|
+
session_id: 'still-running',
|
|
2718
|
+
updated_at: NOW - 1,
|
|
2719
|
+
harness: 'codex',
|
|
2720
|
+
future_entry: { retained: true },
|
|
2721
|
+
},
|
|
2722
|
+
futureEntry,
|
|
2723
|
+
{
|
|
2724
|
+
session_id: 'new-session',
|
|
2725
|
+
updated_at: NOW,
|
|
2726
|
+
harness: 'claude-code',
|
|
2727
|
+
future_current_entry: { retained: true },
|
|
2728
|
+
},
|
|
2729
|
+
],
|
|
2730
|
+
})
|
|
2731
|
+
})
|
|
2641
2732
|
})
|
|
2642
2733
|
|
|
2643
2734
|
describe('session-end hook', () => {
|
|
@@ -2656,6 +2747,36 @@ describe('session-end hook', () => {
|
|
|
2656
2747
|
expect(records.at(-1)?.data).toMatchObject({ outcome: 'cleaned', queued_retirements: 0 })
|
|
2657
2748
|
})
|
|
2658
2749
|
|
|
2750
|
+
it('round-trips future session-state fields when SessionEnd preserves an obligation', async () => {
|
|
2751
|
+
const h = harness()
|
|
2752
|
+
const sessionId = 'session-end-future-state'
|
|
2753
|
+
const stateFile = path.join(
|
|
2754
|
+
stateDir(h.env),
|
|
2755
|
+
'sessions',
|
|
2756
|
+
`${sanitizeSessionId(sessionId)}.json`,
|
|
2757
|
+
)
|
|
2758
|
+
const acknowledgement = {
|
|
2759
|
+
request_id: 'req_future_ack',
|
|
2760
|
+
recorded_at: NOW,
|
|
2761
|
+
future_entry: { retained: true },
|
|
2762
|
+
}
|
|
2763
|
+
mkdirSync(path.dirname(stateFile), { recursive: true })
|
|
2764
|
+
writeFileSync(
|
|
2765
|
+
stateFile,
|
|
2766
|
+
`${JSON.stringify({
|
|
2767
|
+
future_root: { retained: true },
|
|
2768
|
+
acknowledgement_due: [acknowledgement],
|
|
2769
|
+
})}\n`,
|
|
2770
|
+
)
|
|
2771
|
+
|
|
2772
|
+
await hookRunCommand(h.deps, 'session-end', stdin({ session_id: sessionId }))
|
|
2773
|
+
|
|
2774
|
+
expect(JSON.parse(readFileSync(stateFile, 'utf8'))).toEqual({
|
|
2775
|
+
future_root: { retained: true },
|
|
2776
|
+
acknowledgement_due: [acknowledgement],
|
|
2777
|
+
})
|
|
2778
|
+
})
|
|
2779
|
+
|
|
2659
2780
|
it('runs the real Codex SessionEnd cleanup before contended diagnostics and exits fail-open', async () => {
|
|
2660
2781
|
const h = harness()
|
|
2661
2782
|
const sessionId = 'session-end-budget'
|
|
@@ -2772,6 +2893,54 @@ describe('session-end hook', () => {
|
|
|
2772
2893
|
).toEqual({ sessionId: 'still-running', harness: 'codex' })
|
|
2773
2894
|
})
|
|
2774
2895
|
|
|
2896
|
+
it('keeps future project-pointer data when clearing the ending session', async () => {
|
|
2897
|
+
const h = harness()
|
|
2898
|
+
const pointer = projectSessionPointerPath(h.deps.cwd, h.env)
|
|
2899
|
+
const futureEntry = {
|
|
2900
|
+
format: 2,
|
|
2901
|
+
session: { id: 'future-session', runtime: 'future-harness' },
|
|
2902
|
+
}
|
|
2903
|
+
writeSessionState('still-running', h.env, { last_prompt_at: NOW })
|
|
2904
|
+
writeSessionState('ending', h.env, { last_prompt_at: NOW })
|
|
2905
|
+
mkdirSync(path.dirname(pointer), { recursive: true })
|
|
2906
|
+
writeFileSync(
|
|
2907
|
+
pointer,
|
|
2908
|
+
`${JSON.stringify({
|
|
2909
|
+
future_root: { retained: true },
|
|
2910
|
+
sessions: [
|
|
2911
|
+
{
|
|
2912
|
+
session_id: 'still-running',
|
|
2913
|
+
updated_at: NOW - 1,
|
|
2914
|
+
harness: 'codex',
|
|
2915
|
+
future_entry: { retained: true },
|
|
2916
|
+
},
|
|
2917
|
+
futureEntry,
|
|
2918
|
+
{ session_id: 'ending', updated_at: NOW, harness: 'claude-code' },
|
|
2919
|
+
],
|
|
2920
|
+
})}\n`,
|
|
2921
|
+
)
|
|
2922
|
+
|
|
2923
|
+
await hookRunCommand(
|
|
2924
|
+
h.deps,
|
|
2925
|
+
'session-end',
|
|
2926
|
+
stdin({ session_id: 'ending', cwd: h.deps.cwd }),
|
|
2927
|
+
'claude-code',
|
|
2928
|
+
)
|
|
2929
|
+
|
|
2930
|
+
expect(JSON.parse(readFileSync(pointer, 'utf8'))).toEqual({
|
|
2931
|
+
future_root: { retained: true },
|
|
2932
|
+
sessions: [
|
|
2933
|
+
{
|
|
2934
|
+
session_id: 'still-running',
|
|
2935
|
+
updated_at: NOW - 1,
|
|
2936
|
+
harness: 'codex',
|
|
2937
|
+
future_entry: { retained: true },
|
|
2938
|
+
},
|
|
2939
|
+
futureEntry,
|
|
2940
|
+
],
|
|
2941
|
+
})
|
|
2942
|
+
})
|
|
2943
|
+
|
|
2775
2944
|
it('preserves incomplete live state and reports why it cannot retire it', async () => {
|
|
2776
2945
|
const h = harness()
|
|
2777
2946
|
writeSessionState('s-incomplete', h.env, {
|
|
@@ -2803,6 +2972,164 @@ describe('malformed input', () => {
|
|
|
2803
2972
|
})
|
|
2804
2973
|
|
|
2805
2974
|
describe('telling concurrent agents apart', () => {
|
|
2975
|
+
it('uses each hook event branch while keeping one immutable session identity', async () => {
|
|
2976
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
2977
|
+
initializeFixtureRepository(h.deps.cwd)
|
|
2978
|
+
registerQuestion(
|
|
2979
|
+
'session-branch-transition',
|
|
2980
|
+
h.env,
|
|
2981
|
+
{
|
|
2982
|
+
question: 'Ship it?',
|
|
2983
|
+
source: {
|
|
2984
|
+
session_id: 'session-branch-transition',
|
|
2985
|
+
session_label: 'Branch transition',
|
|
2986
|
+
harness: 'claude-code',
|
|
2987
|
+
branch: 'branch-a',
|
|
2988
|
+
},
|
|
2989
|
+
},
|
|
2990
|
+
NOW,
|
|
2991
|
+
)
|
|
2992
|
+
|
|
2993
|
+
runFixtureGit(h.deps.cwd, 'checkout', '-b', 'branch-b')
|
|
2994
|
+
await hookRunCommand(
|
|
2995
|
+
h.deps,
|
|
2996
|
+
'stop',
|
|
2997
|
+
stdin({ session_id: 'session-branch-transition', cwd: h.deps.cwd }),
|
|
2998
|
+
'claude-code',
|
|
2999
|
+
)
|
|
3000
|
+
|
|
3001
|
+
const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
|
|
3002
|
+
expect(question?.draft.source).toEqual({
|
|
3003
|
+
session_id: 'session-branch-transition',
|
|
3004
|
+
session_label: 'Branch transition',
|
|
3005
|
+
harness: 'claude-code',
|
|
3006
|
+
branch: 'branch-b',
|
|
3007
|
+
})
|
|
3008
|
+
|
|
3009
|
+
runFixtureGit(h.deps.cwd, 'checkout', 'branch-a')
|
|
3010
|
+
h.recorder.acknowledged ??= new Set()
|
|
3011
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
3012
|
+
await hookRunCommand(
|
|
3013
|
+
h.deps,
|
|
3014
|
+
'stop',
|
|
3015
|
+
stdin({
|
|
3016
|
+
session_id: 'session-branch-transition',
|
|
3017
|
+
cwd: h.deps.cwd,
|
|
3018
|
+
stop_hook_active: true,
|
|
3019
|
+
}),
|
|
3020
|
+
'claude-code',
|
|
3021
|
+
)
|
|
3022
|
+
|
|
3023
|
+
const retirement = h.recorder.submitted.find(
|
|
3024
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
3025
|
+
)
|
|
3026
|
+
expect(retirement?.draft.source).toEqual({
|
|
3027
|
+
session_id: 'session-branch-transition',
|
|
3028
|
+
session_label: 'Branch transition',
|
|
3029
|
+
harness: 'claude-code',
|
|
3030
|
+
branch: 'branch-a',
|
|
3031
|
+
})
|
|
3032
|
+
})
|
|
3033
|
+
|
|
3034
|
+
it('clears registration-time branch and worktree on a detached-HEAD hook event', async () => {
|
|
3035
|
+
const h = harness([])
|
|
3036
|
+
initializeFixtureRepository(h.deps.cwd)
|
|
3037
|
+
registerQuestion(
|
|
3038
|
+
'session-detached',
|
|
3039
|
+
h.env,
|
|
3040
|
+
{
|
|
3041
|
+
question: 'Ship it?',
|
|
3042
|
+
source: {
|
|
3043
|
+
session_id: 'session-detached',
|
|
3044
|
+
session_label: 'Detached review',
|
|
3045
|
+
harness: 'claude-code',
|
|
3046
|
+
branch: 'branch-a',
|
|
3047
|
+
worktree: 'stale-worktree',
|
|
3048
|
+
},
|
|
3049
|
+
},
|
|
3050
|
+
NOW,
|
|
3051
|
+
)
|
|
3052
|
+
runFixtureGit(h.deps.cwd, 'checkout', '--detach', 'HEAD')
|
|
3053
|
+
|
|
3054
|
+
await hookRunCommand(
|
|
3055
|
+
h.deps,
|
|
3056
|
+
'stop',
|
|
3057
|
+
stdin({ session_id: 'session-detached', cwd: h.deps.cwd }),
|
|
3058
|
+
'claude-code',
|
|
3059
|
+
)
|
|
3060
|
+
|
|
3061
|
+
const question = h.recorder.submitted.find((entry) => entry.draft.event === 'agent_question')
|
|
3062
|
+
expect(question?.draft.source).toEqual({
|
|
3063
|
+
session_id: 'session-detached',
|
|
3064
|
+
session_label: 'Detached review',
|
|
3065
|
+
harness: 'claude-code',
|
|
3066
|
+
})
|
|
3067
|
+
})
|
|
3068
|
+
|
|
3069
|
+
it('captures entry into and exit from a linked worktree on the same session', async () => {
|
|
3070
|
+
const h = harness([reply({ text: 'Yes' })])
|
|
3071
|
+
initializeFixtureRepository(h.deps.cwd, 'main')
|
|
3072
|
+
const linked = `${h.deps.cwd}-linked`
|
|
3073
|
+
runFixtureGit(h.deps.cwd, 'worktree', 'add', '-b', 'worktree-branch', linked)
|
|
3074
|
+
try {
|
|
3075
|
+
registerQuestion(
|
|
3076
|
+
'session-worktree-transition',
|
|
3077
|
+
h.env,
|
|
3078
|
+
{
|
|
3079
|
+
question: 'Ship it?',
|
|
3080
|
+
source: {
|
|
3081
|
+
session_id: 'session-worktree-transition',
|
|
3082
|
+
session_label: 'Worktree transition',
|
|
3083
|
+
harness: 'claude-code',
|
|
3084
|
+
branch: 'main',
|
|
3085
|
+
},
|
|
3086
|
+
},
|
|
3087
|
+
NOW,
|
|
3088
|
+
)
|
|
3089
|
+
|
|
3090
|
+
await hookRunCommand(
|
|
3091
|
+
h.deps,
|
|
3092
|
+
'stop',
|
|
3093
|
+
stdin({ session_id: 'session-worktree-transition', cwd: linked }),
|
|
3094
|
+
'claude-code',
|
|
3095
|
+
)
|
|
3096
|
+
const question = h.recorder.submitted.find(
|
|
3097
|
+
(entry) => entry.draft.event === 'agent_question',
|
|
3098
|
+
)
|
|
3099
|
+
expect(question?.draft.source).toEqual({
|
|
3100
|
+
session_id: 'session-worktree-transition',
|
|
3101
|
+
session_label: 'Worktree transition',
|
|
3102
|
+
harness: 'claude-code',
|
|
3103
|
+
branch: 'worktree-branch',
|
|
3104
|
+
worktree: path.basename(linked),
|
|
3105
|
+
})
|
|
3106
|
+
|
|
3107
|
+
h.recorder.acknowledged ??= new Set()
|
|
3108
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
3109
|
+
await hookRunCommand(
|
|
3110
|
+
h.deps,
|
|
3111
|
+
'stop',
|
|
3112
|
+
stdin({
|
|
3113
|
+
session_id: 'session-worktree-transition',
|
|
3114
|
+
cwd: h.deps.cwd,
|
|
3115
|
+
stop_hook_active: true,
|
|
3116
|
+
}),
|
|
3117
|
+
'claude-code',
|
|
3118
|
+
)
|
|
3119
|
+
const retirement = h.recorder.submitted.find(
|
|
3120
|
+
(entry) => entry.draft.event === 'question_retired',
|
|
3121
|
+
)
|
|
3122
|
+
expect(retirement?.draft.source).toEqual({
|
|
3123
|
+
session_id: 'session-worktree-transition',
|
|
3124
|
+
session_label: 'Worktree transition',
|
|
3125
|
+
harness: 'claude-code',
|
|
3126
|
+
branch: 'main',
|
|
3127
|
+
})
|
|
3128
|
+
} finally {
|
|
3129
|
+
runFixtureGit(h.deps.cwd, 'worktree', 'remove', '--force', linked)
|
|
3130
|
+
}
|
|
3131
|
+
})
|
|
3132
|
+
|
|
2806
3133
|
it('stamps structured Source Context on the question it pushes', async () => {
|
|
2807
3134
|
const h = harness([])
|
|
2808
3135
|
registerQuestion(
|
|
@@ -3881,11 +4208,9 @@ describe('escalation waiter delivery seam', () => {
|
|
|
3881
4208
|
|
|
3882
4209
|
|
|
3883
4210
|
describe('session state across a prompt', () => {
|
|
3884
|
-
it('carries
|
|
3885
|
-
// The prompt
|
|
3886
|
-
//
|
|
3887
|
-
// acknowledgement obligation once vanished. This fails if a future field is
|
|
3888
|
-
// added to SessionState and not carried across.
|
|
4211
|
+
it('carries known and future session state across a typed prompt', async () => {
|
|
4212
|
+
// The prompt transition deliberately changes pending/retiring state and the
|
|
4213
|
+
// continuation count, but fields introduced by a newer CLI remain opaque.
|
|
3889
4214
|
const h = harness([])
|
|
3890
4215
|
const before = {
|
|
3891
4216
|
last_prompt_at: AWAY,
|
|
@@ -3903,7 +4228,11 @@ describe('session state across a prompt', () => {
|
|
|
3903
4228
|
acknowledgement_due: [{ request_id: 'req_carry_ack', recorded_at: NOW }],
|
|
3904
4229
|
acknowledgement_blocks: 2,
|
|
3905
4230
|
}
|
|
3906
|
-
|
|
4231
|
+
const futureSessionValue = { nested: ['kept'], revision: 2 }
|
|
4232
|
+
writeSessionState('carry-all', h.env, {
|
|
4233
|
+
...before,
|
|
4234
|
+
future_session_key: futureSessionValue,
|
|
4235
|
+
} as SessionState & { future_session_key: typeof futureSessionValue })
|
|
3907
4236
|
|
|
3908
4237
|
await hookRunCommand(
|
|
3909
4238
|
h.deps,
|
|
@@ -3915,6 +4244,17 @@ describe('session state across a prompt', () => {
|
|
|
3915
4244
|
for (const key of Object.keys(before) as (keyof typeof before)[]) {
|
|
3916
4245
|
expect(after[key], `${key} did not survive the prompt`).toBeDefined()
|
|
3917
4246
|
}
|
|
4247
|
+
const persisted = JSON.parse(
|
|
4248
|
+
readFileSync(
|
|
4249
|
+
path.join(
|
|
4250
|
+
stateDir(h.env),
|
|
4251
|
+
'sessions',
|
|
4252
|
+
`${sanitizeSessionId('carry-all')}.json`,
|
|
4253
|
+
),
|
|
4254
|
+
'utf8',
|
|
4255
|
+
),
|
|
4256
|
+
) as Record<string, unknown>
|
|
4257
|
+
expect(persisted['future_session_key']).toEqual(futureSessionValue)
|
|
3918
4258
|
// The one field a prompt resets rather than carries.
|
|
3919
4259
|
expect(after.continuation?.count).toBe(0)
|
|
3920
4260
|
expect(after.acknowledgement_blocks).toBe(2)
|