@raidiant/notifai 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +15 -10
- package/dist/commands.js +321 -88
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +2 -2
- package/dist/config-schema.js +9 -35
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +5 -38
- package/dist/config.js +11 -32
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +3 -13
- package/dist/harnesses.js +6 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +22 -7
- package/dist/install-hooks.js +43 -28
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +1 -1
- package/dist/interactive.js +2 -4
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +6 -13
- package/dist/main.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +9 -9
- package/src/claude-wake.test.ts +320 -0
- package/src/claude-wake.ts +489 -0
- package/src/codex-wake.test.ts +253 -0
- package/src/codex-wake.ts +288 -0
- package/src/commands.test.ts +393 -40
- package/src/commands.ts +433 -109
- package/src/config-schema.test.ts +3 -3
- package/src/config-schema.ts +11 -40
- package/src/config.test.ts +21 -16
- package/src/config.ts +15 -69
- package/src/harnesses.test.ts +7 -21
- package/src/harnesses.ts +17 -19
- package/src/hooks.test.ts +780 -290
- package/src/hooks.ts +466 -339
- package/src/install-hooks.test.ts +44 -3
- package/src/install-hooks.ts +43 -28
- package/src/interactive.ts +2 -7
- package/src/logging.test.ts +3 -3
- package/src/main.ts +6 -12
- package/src/release.ts +51 -0
- package/src/skill.test.ts +23 -0
- package/src/ui/config-view.test.ts +5 -5
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
- package/src/idle.test.ts +0 -32
- package/src/idle.ts +0 -41
|
@@ -73,14 +73,14 @@ describe('acceptedValues', () => {
|
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
it('names both values for a toggle', () => {
|
|
76
|
-
expect(acceptedValues('
|
|
76
|
+
expect(acceptedValues('ask_notifications')).toBe('true or false')
|
|
77
77
|
})
|
|
78
78
|
})
|
|
79
79
|
|
|
80
80
|
describe('formatValue', () => {
|
|
81
81
|
it('renders toggles as words rather than JSON', () => {
|
|
82
|
-
expect(formatValue('
|
|
83
|
-
expect(formatValue('
|
|
82
|
+
expect(formatValue('ask_notifications', true)).toBe('yes')
|
|
83
|
+
expect(formatValue('ask_notifications', false)).toBe('no')
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
it('explains what an unset value means instead of printing null', () => {
|
package/src/config-schema.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What every configuration key means, in the reader's words.
|
|
3
3
|
*
|
|
4
|
-
* `config.ts` already documented
|
|
4
|
+
* `config.ts` already documented every key carefully — but in TypeScript
|
|
5
5
|
* comments, which is the one audience that never needed them. Someone running
|
|
6
|
-
* `notifai config show` saw `
|
|
7
|
-
* it did short of reading the source, so this module is that knowledge
|
|
8
|
-
* where it can be printed.
|
|
6
|
+
* `notifai config show` saw `ask_grace_seconds = 0` and had no way to learn
|
|
7
|
+
* what it did short of reading the source, so this module is that knowledge
|
|
8
|
+
* moved to where it can be printed.
|
|
9
9
|
*
|
|
10
10
|
* It is the single source for `config show`, `config set`'s errors, `config
|
|
11
11
|
* explain`, the help footer, and the interactive settings screen. One
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `summary` is the one-line form that fits beside a value in a list.
|
|
15
15
|
* `detail` is the paragraph shown once the reader has asked for this key
|
|
16
|
-
* specifically — progressive disclosure, so
|
|
17
|
-
*
|
|
16
|
+
* specifically — progressive disclosure, so the list of settings stays a list
|
|
17
|
+
* and not an essay.
|
|
18
18
|
*/
|
|
19
19
|
import { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
|
|
20
20
|
import { LOG_LEVELS, configBounds, type ConfigKey } from './config.js'
|
|
@@ -22,16 +22,16 @@ import { LOG_LEVELS, configBounds, type ConfigKey } from './config.js'
|
|
|
22
22
|
export type ConfigKind = 'string' | 'url' | 'integer' | 'boolean' | 'enum' | 'list'
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Which surface a key belongs to. Grouping is the difference between
|
|
26
|
-
*
|
|
25
|
+
* Which surface a key belongs to. Grouping is the difference between a flat
|
|
26
|
+
* list of keys and a few short ones a reader can hold in their head.
|
|
27
27
|
*/
|
|
28
28
|
export type ConfigGroup = 'questions' | 'delivery' | 'project' | 'diagnostics' | 'connection'
|
|
29
29
|
|
|
30
30
|
export const CONFIG_GROUPS: { id: ConfigGroup; title: string; blurb: string }[] = [
|
|
31
31
|
{
|
|
32
32
|
id: 'questions',
|
|
33
|
-
title: 'Questions
|
|
34
|
-
blurb: '
|
|
33
|
+
title: 'Questions',
|
|
34
|
+
blurb: 'Whether a question an agent registered may leave this terminal and reach your devices, and how soon.',
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
id: 'delivery',
|
|
@@ -93,25 +93,6 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
93
93
|
'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 the presence settings say. Turn it off to stop being reached for a while without uninstalling the harness hooks.',
|
|
94
94
|
example: 'true',
|
|
95
95
|
},
|
|
96
|
-
require_idle: {
|
|
97
|
-
label: 'Only when I have stepped away',
|
|
98
|
-
group: 'questions',
|
|
99
|
-
kind: 'boolean',
|
|
100
|
-
summary: 'Whether sitting at this keyboard holds a question back',
|
|
101
|
-
detail:
|
|
102
|
-
'Off (the default) means notify me even while I am using this machine. `ask_grace_seconds` still runs, so the terminal is offered the question first; I just do not need to walk away before it may leave. Turn this on when local keyboard or mouse activity should keep questions in the terminal.\n\nThis is separate from `ask_notifications`, which switches routing off altogether: wanting to be reached only while away is different from not wanting to be reached.',
|
|
103
|
-
example: 'false',
|
|
104
|
-
},
|
|
105
|
-
away_after_seconds: {
|
|
106
|
-
label: 'Away after',
|
|
107
|
-
group: 'questions',
|
|
108
|
-
kind: 'integer',
|
|
109
|
-
unit: 's',
|
|
110
|
-
summary: 'Keyboard and mouse idle time before this machine counts as unattended',
|
|
111
|
-
detail:
|
|
112
|
-
'How long the keyboard and mouse must be quiet before you count as away. Only consulted while `require_idle` is on.\n\nWhere the operating system exposes no idle signal, silence since your last prompt is used instead, which is the conservative reading.',
|
|
113
|
-
example: '120',
|
|
114
|
-
},
|
|
115
96
|
ask_grace_seconds: {
|
|
116
97
|
label: 'Terminal-first grace',
|
|
117
98
|
group: 'questions',
|
|
@@ -119,19 +100,9 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
|
|
|
119
100
|
unit: 's',
|
|
120
101
|
summary: 'Optional delay before a question may reach your devices',
|
|
121
102
|
detail:
|
|
122
|
-
'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nA timer and nothing more.
|
|
103
|
+
'Zero (the default) sends the question to your devices as soon as the agent turn ends. Set a positive duration to offer the terminal an exclusive answer window first; the timer is measured from the moment the agent asked.\n\nA timer and nothing more. Whether you happen to be at this keyboard does not change it: waiting no longer holds your terminal, so there is nothing your being here would protect you from.',
|
|
123
104
|
example: '0',
|
|
124
105
|
},
|
|
125
|
-
hook_reply_timeout_seconds: {
|
|
126
|
-
label: 'Hook wait for an answer',
|
|
127
|
-
group: 'questions',
|
|
128
|
-
kind: 'integer',
|
|
129
|
-
unit: 's',
|
|
130
|
-
summary: 'How long the harness hook blocks waiting for your answer',
|
|
131
|
-
detail:
|
|
132
|
-
'After a question has gone out, the harness continuation owner waits up to this long for your answer. The device reply window uses the same bound, so Notifai never accepts an answer after an agent with no dormant-session wake has already returned.\n\nThe ceiling exists because command-hook harnesses bound their Stop processes. Grace plus this value plus installer headroom stays inside that budget.',
|
|
133
|
-
example: '480',
|
|
134
|
-
},
|
|
135
106
|
|
|
136
107
|
sound: {
|
|
137
108
|
label: 'Sound',
|
package/src/config.test.ts
CHANGED
|
@@ -45,22 +45,22 @@ function setup(options: {
|
|
|
45
45
|
describe('harness config layers', () => {
|
|
46
46
|
it('orders session over project-local over project over global', () => {
|
|
47
47
|
const { env, cwd } = setup({
|
|
48
|
-
globalToml: '
|
|
49
|
-
projectToml: '
|
|
50
|
-
projectLocalToml: '
|
|
51
|
-
sessionToml: '
|
|
48
|
+
globalToml: 'ask_grace_seconds = 10\n',
|
|
49
|
+
projectToml: 'ask_grace_seconds = 20\n',
|
|
50
|
+
projectLocalToml: 'ask_grace_seconds = 30\n',
|
|
51
|
+
sessionToml: 'ask_grace_seconds = 40\n',
|
|
52
52
|
sessionId: 'abc',
|
|
53
53
|
})
|
|
54
|
-
expect(loadConfig({ cwd, env, sessionId: 'abc' }).
|
|
54
|
+
expect(loadConfig({ cwd, env, sessionId: 'abc' }).ask_grace_seconds).toMatchObject({
|
|
55
55
|
value: 40,
|
|
56
56
|
source: expect.stringMatching(/^session:/),
|
|
57
57
|
})
|
|
58
58
|
// Same files, different session: the session layer simply does not apply.
|
|
59
|
-
expect(loadConfig({ cwd, env, sessionId: 'other' }).
|
|
59
|
+
expect(loadConfig({ cwd, env, sessionId: 'other' }).ask_grace_seconds).toMatchObject({
|
|
60
60
|
value: 30,
|
|
61
61
|
source: expect.stringMatching(/^project-local:/),
|
|
62
62
|
})
|
|
63
|
-
expect(loadConfig({ cwd, env }).
|
|
63
|
+
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(30)
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
it('keeps a personal toggle out of the committed project file', () => {
|
|
@@ -75,30 +75,35 @@ describe('harness config layers', () => {
|
|
|
75
75
|
|
|
76
76
|
it('keeps hostile or colliding session ids inside their own file', () => {
|
|
77
77
|
const { env, cwd } = setup({
|
|
78
|
-
sessionToml: '
|
|
78
|
+
sessionToml: 'ask_grace_seconds = 99\n',
|
|
79
79
|
sessionId: '../../etc/passwd',
|
|
80
80
|
})
|
|
81
81
|
// The id round-trips to its own state, and nothing escapes the directory.
|
|
82
|
-
expect(loadConfig({ cwd, env, sessionId: '../../etc/passwd' }).
|
|
82
|
+
expect(loadConfig({ cwd, env, sessionId: '../../etc/passwd' }).ask_grace_seconds.value).toBe(99)
|
|
83
83
|
expect(sessionConfigPath('../../etc/passwd', env)).toContain('/notifai/sessions/')
|
|
84
84
|
expect(sessionConfigPath('../../etc/passwd', env)).not.toContain('..')
|
|
85
85
|
// Ids that a sanitiser would have collapsed together must stay distinct:
|
|
86
86
|
// sharing a file meant sharing presence and pending questions.
|
|
87
87
|
expect(sessionConfigPath('a/b', env)).not.toBe(sessionConfigPath('a?b', env))
|
|
88
|
-
expect(loadConfig({ cwd, env, sessionId: 'a/b' }).
|
|
88
|
+
expect(loadConfig({ cwd, env, sessionId: 'a/b' }).ask_grace_seconds.value).toBe(0)
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
-
it('defaults question routing to on
|
|
91
|
+
it('defaults question routing to on and reaching the user straight away', () => {
|
|
92
92
|
const { env, cwd } = setup({})
|
|
93
93
|
const config = loadConfig({ cwd, env })
|
|
94
94
|
expect(config.ask_notifications.value).toBe(true)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Immediate escalation by default leaves the whole hook budget available
|
|
98
|
-
// for the reply wait. Users can still opt into a terminal-first grace.
|
|
99
|
-
expect(config.hook_reply_timeout_seconds.value).toBe(480)
|
|
95
|
+
// Nothing here asks where the user is standing. A question goes out when
|
|
96
|
+
// the turn ends unless they choose a terminal-first window.
|
|
100
97
|
expect(config.ask_grace_seconds).toEqual({ value: 0, source: 'default' })
|
|
101
98
|
})
|
|
99
|
+
|
|
100
|
+
it('bounds the terminal-first window so a reply window always survives it', () => {
|
|
101
|
+
// Config is readable from a repository, so an unbounded grace is hostile
|
|
102
|
+
// input: it would eat the waiter's whole ceiling and leave no window the
|
|
103
|
+
// server would accept an answer into.
|
|
104
|
+
const { env, cwd } = setup({ globalToml: 'ask_grace_seconds = 100000\n' })
|
|
105
|
+
expect(loadConfig({ cwd, env }).ask_grace_seconds.value).toBe(360)
|
|
106
|
+
})
|
|
102
107
|
})
|
|
103
108
|
|
|
104
109
|
describe('config precedence', () => {
|
package/src/config.ts
CHANGED
|
@@ -4,7 +4,6 @@ import os from 'node:os'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { parse as parseToml } from 'smol-toml'
|
|
6
6
|
import type { CLI_SOUNDS, INTERRUPTION_LEVELS } from '@raidiant/notifai-protocol'
|
|
7
|
-
import { HOOK_TIMING } from './harnesses.js'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Layered configuration with provenance. Most specific wins:
|
|
@@ -60,49 +59,16 @@ export interface CliConfig {
|
|
|
60
59
|
project: ResolvedValue<string | null>
|
|
61
60
|
/** Free-text notification criteria agents consult before sending. */
|
|
62
61
|
notify_criteria: ResolvedValue<string | null>
|
|
63
|
-
/**
|
|
62
|
+
/** Whether a registered agent question may reach companion devices at all. */
|
|
64
63
|
ask_notifications: ResolvedValue<boolean>
|
|
65
|
-
/**
|
|
66
|
-
* Whether being at this machine suppresses a question. Sitting at the
|
|
67
|
-
* keyboard is not the same as not wanting to be reached — needing to stop
|
|
68
|
-
* using the machine in order to be notified is its own kind of interruption.
|
|
69
|
-
*
|
|
70
|
-
* On, the default, is the original behaviour: a question stays in the
|
|
71
|
-
* terminal while the keyboard is in use, because the person is right there
|
|
72
|
-
* and a push would be redundant. Off says notify me anyway — the grace window
|
|
73
|
-
* still runs, so the question is still offered to the terminal first, it just
|
|
74
|
-
* no longer needs the user to walk away before it may leave.
|
|
75
|
-
*
|
|
76
|
-
* This is deliberately separate from `ask_notifications`, which switches
|
|
77
|
-
* escalation off altogether. Wanting to be reached while working is the
|
|
78
|
-
* opposite of not wanting to be reached.
|
|
79
|
-
*/
|
|
80
|
-
require_idle: ResolvedValue<boolean>
|
|
81
|
-
/**
|
|
82
|
-
* Keyboard/mouse idle time after which this machine counts as unattended.
|
|
83
|
-
* When the OS idle signal is unavailable, silence since the user's last prompt
|
|
84
|
-
* is the conservative fallback. Below the threshold the hooks stay out of the
|
|
85
|
-
* way entirely, because a blocking hook also blocks the terminal prompt the
|
|
86
|
-
* present user would use.
|
|
87
|
-
*
|
|
88
|
-
* Only consulted while `require_idle` is on.
|
|
89
|
-
*/
|
|
90
|
-
away_after_seconds: ResolvedValue<number>
|
|
91
|
-
/**
|
|
92
|
-
* How long a hook blocks waiting for a companion-device answer before falling through
|
|
93
|
-
* to the harness's own prompt. Runtime arbitration keeps the complete Stop
|
|
94
|
-
* invocation inside its fixed 480s owner budget and 540s adapter ceiling.
|
|
95
|
-
*/
|
|
96
|
-
hook_reply_timeout_seconds: ResolvedValue<number>
|
|
97
64
|
/**
|
|
98
65
|
* Terminal-first grace window, measured from the moment the question was
|
|
99
66
|
* sent: the agent asks in the terminal and starts a timer, and only once it
|
|
100
67
|
* elapses does the question reach companion devices.
|
|
101
68
|
*
|
|
102
|
-
* A timer, and only a timer.
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* questions, and answering one should not answer the other.
|
|
69
|
+
* A timer, and only a timer. Whether the user happens to be at this keyboard
|
|
70
|
+
* decides nothing — the waiter no longer holds their terminal, so there is
|
|
71
|
+
* nothing for their presence to protect them from.
|
|
106
72
|
*/
|
|
107
73
|
ask_grace_seconds: ResolvedValue<number>
|
|
108
74
|
/**
|
|
@@ -136,9 +102,6 @@ export const CONFIG_KEYS = [
|
|
|
136
102
|
'project',
|
|
137
103
|
'notify_criteria',
|
|
138
104
|
'ask_notifications',
|
|
139
|
-
'require_idle',
|
|
140
|
-
'away_after_seconds',
|
|
141
|
-
'hook_reply_timeout_seconds',
|
|
142
105
|
'ask_grace_seconds',
|
|
143
106
|
'log_level',
|
|
144
107
|
'log_max_bytes',
|
|
@@ -147,17 +110,12 @@ export const CONFIG_KEYS = [
|
|
|
147
110
|
export type ConfigKey = (typeof CONFIG_KEYS)[number]
|
|
148
111
|
|
|
149
112
|
/** Keys whose TOML value must parse as a boolean; used by `config set`. */
|
|
150
|
-
export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
151
|
-
'ask_notifications',
|
|
152
|
-
'require_idle',
|
|
153
|
-
]
|
|
113
|
+
export const BOOLEAN_CONFIG_KEYS: readonly ConfigKey[] = ['ask_notifications']
|
|
154
114
|
|
|
155
115
|
/** Keys whose TOML value must parse as an integer within its configured bounds. */
|
|
156
116
|
export const NUMERIC_CONFIG_KEYS: readonly ConfigKey[] = [
|
|
157
117
|
'wait_seconds',
|
|
158
118
|
'ttl_seconds',
|
|
159
|
-
'away_after_seconds',
|
|
160
|
-
'hook_reply_timeout_seconds',
|
|
161
119
|
'ask_grace_seconds',
|
|
162
120
|
'log_max_bytes',
|
|
163
121
|
'log_max_files',
|
|
@@ -187,14 +145,8 @@ const DEFAULTS = {
|
|
|
187
145
|
// Installing the hooks is the opt-in; these exist to switch the behaviour
|
|
188
146
|
// back off for a project or a session without uninstalling anything.
|
|
189
147
|
ask_notifications: true,
|
|
190
|
-
// Questions reach devices
|
|
191
|
-
//
|
|
192
|
-
require_idle: false,
|
|
193
|
-
away_after_seconds: 120,
|
|
194
|
-
// Runtime waits stay inside a 480s budget. Harnesses that require a declared
|
|
195
|
-
// Stop limit use a preference-stable 540s; Codex uses its host-owned Stop
|
|
196
|
-
// default. Neither definition changes when these preferences do.
|
|
197
|
-
hook_reply_timeout_seconds: 480,
|
|
148
|
+
// Questions reach devices as soon as the turn ends. A terminal-first grace
|
|
149
|
+
// window is an opt-in delay on top of that.
|
|
198
150
|
ask_grace_seconds: 0,
|
|
199
151
|
// On by default: the log is the only account of what a headless hook did, and
|
|
200
152
|
// a diagnostic nobody switched on before the thing went wrong is not one.
|
|
@@ -298,21 +250,18 @@ export function findProjectLocalConfigPath(startDir: string): string | null {
|
|
|
298
250
|
|
|
299
251
|
/**
|
|
300
252
|
* Ranges every layer is clamped to. Config is readable from a repository, so an
|
|
301
|
-
* out-of-range value is attacker input, not a typo:
|
|
302
|
-
* would
|
|
303
|
-
*
|
|
304
|
-
* already answered.
|
|
253
|
+
* out-of-range value is attacker input, not a typo: an unbounded grace window
|
|
254
|
+
* would consume the waiter's whole ceiling and silently leave no time at all in
|
|
255
|
+
* which the user's answer could still be accepted.
|
|
305
256
|
*/
|
|
306
257
|
const NUMERIC_BOUNDS: Partial<Record<ConfigKey, { min: number; max: number }>> = {
|
|
307
258
|
wait_seconds: { min: 0, max: 300 },
|
|
308
259
|
ttl_seconds: { min: 0, max: 7 * 24 * 3600 },
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
hook_reply_timeout_seconds: { min: 60, max: 540 },
|
|
315
|
-
ask_grace_seconds: { min: 0, max: HOOK_TIMING.maxGraceSeconds },
|
|
260
|
+
// A plain number, no longer derived from a hook budget's leftovers. Six
|
|
261
|
+
// minutes leaves two clear minutes of the waiter's 480s ceiling, so the
|
|
262
|
+
// longest window a user can choose still leaves an answer window the server
|
|
263
|
+
// will accept — with room to spare for a timer that wakes late.
|
|
264
|
+
ask_grace_seconds: { min: 0, max: 360 },
|
|
316
265
|
// The floor keeps rotation from thrashing (a file smaller than a handful of
|
|
317
266
|
// records would rotate on nearly every write); the ceiling is the point past
|
|
318
267
|
// which "local diagnostics" has become "a database on your disk".
|
|
@@ -405,9 +354,6 @@ export function loadConfig(options: {
|
|
|
405
354
|
project: resolve('project'),
|
|
406
355
|
notify_criteria: resolve('notify_criteria'),
|
|
407
356
|
ask_notifications: resolve('ask_notifications'),
|
|
408
|
-
require_idle: resolve('require_idle'),
|
|
409
|
-
away_after_seconds: resolve('away_after_seconds'),
|
|
410
|
-
hook_reply_timeout_seconds: resolve('hook_reply_timeout_seconds'),
|
|
411
357
|
ask_grace_seconds: resolve('ask_grace_seconds'),
|
|
412
358
|
log_level: resolve('log_level'),
|
|
413
359
|
log_max_bytes: resolve('log_max_bytes'),
|
package/src/harnesses.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { HARNESS_CAPABILITIES, HARNESSES
|
|
2
|
+
import { HARNESS_CAPABILITIES, HARNESSES } from './harnesses.js'
|
|
3
3
|
|
|
4
4
|
describe('harness continuation contract', () => {
|
|
5
5
|
it('requires one explicit native delivery contract for every shipped harness', () => {
|
|
@@ -7,42 +7,28 @@ describe('harness continuation contract', () => {
|
|
|
7
7
|
expect(HARNESS_CAPABILITIES).toEqual({
|
|
8
8
|
'claude-code': {
|
|
9
9
|
stopContinuation: 'decision-block',
|
|
10
|
-
|
|
10
|
+
deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
|
|
11
11
|
deliveryContract:
|
|
12
|
-
'
|
|
12
|
+
'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
|
|
13
13
|
},
|
|
14
14
|
codex: {
|
|
15
15
|
stopContinuation: 'decision-block',
|
|
16
|
-
|
|
16
|
+
deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
|
|
17
17
|
deliveryContract:
|
|
18
|
-
'live Stop continuation;
|
|
18
|
+
'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
|
|
19
19
|
},
|
|
20
20
|
cursor: {
|
|
21
21
|
stopContinuation: 'unsupported',
|
|
22
|
-
|
|
22
|
+
deliveryRoutes: ['unsupported'],
|
|
23
23
|
deliveryContract:
|
|
24
24
|
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
25
25
|
},
|
|
26
26
|
opencode: {
|
|
27
27
|
stopContinuation: 'unsupported',
|
|
28
|
-
|
|
28
|
+
deliveryRoutes: ['unsupported'],
|
|
29
29
|
deliveryContract:
|
|
30
30
|
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
31
31
|
},
|
|
32
32
|
})
|
|
33
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
34
|
})
|
package/src/harnesses.ts
CHANGED
|
@@ -3,17 +3,13 @@ export const HARNESSES = ['claude-code', 'codex', 'cursor', 'opencode'] as const
|
|
|
3
3
|
|
|
4
4
|
export type Harness = (typeof HARNESSES)[number]
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
stdoutReserveSeconds: 5,
|
|
14
|
-
hostHeadroomSeconds: 60,
|
|
15
|
-
maxGraceSeconds: 334,
|
|
16
|
-
})
|
|
6
|
+
export type DeliveryRoute =
|
|
7
|
+
| 'hook-continuation'
|
|
8
|
+
| 'inbox-socket'
|
|
9
|
+
| 'owned-control-plane'
|
|
10
|
+
| 'cold-resume'
|
|
11
|
+
| 'hold-for-next-turn'
|
|
12
|
+
| 'unsupported'
|
|
17
13
|
|
|
18
14
|
export type StopContinuation =
|
|
19
15
|
| 'decision-block'
|
|
@@ -23,8 +19,8 @@ export type StopContinuation =
|
|
|
23
19
|
export interface HarnessCapability {
|
|
24
20
|
/** How an answer is admitted to another turn in the already-open session. */
|
|
25
21
|
stopContinuation: StopContinuation
|
|
26
|
-
/**
|
|
27
|
-
|
|
22
|
+
/** Available answer-delivery routes, best current route first. */
|
|
23
|
+
deliveryRoutes: readonly DeliveryRoute[]
|
|
28
24
|
/** Concise, honest readiness text for doctor and installation guidance. */
|
|
29
25
|
deliveryContract: string
|
|
30
26
|
}
|
|
@@ -36,23 +32,25 @@ export interface HarnessCapability {
|
|
|
36
32
|
export const HARNESS_CAPABILITIES: Record<Harness, HarnessCapability> = {
|
|
37
33
|
'claude-code': {
|
|
38
34
|
stopContinuation: 'decision-block',
|
|
39
|
-
|
|
40
|
-
deliveryContract:
|
|
35
|
+
deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
|
|
36
|
+
deliveryContract:
|
|
37
|
+
'the Stop hook returns at once and waits out of band, then posts the answer into this same session over its own inbox socket; a session that has stopped is resumed only once a liveness probe proves it stopped',
|
|
41
38
|
},
|
|
42
39
|
codex: {
|
|
43
40
|
stopContinuation: 'decision-block',
|
|
44
|
-
|
|
45
|
-
deliveryContract:
|
|
41
|
+
deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
|
|
42
|
+
deliveryContract:
|
|
43
|
+
'live Stop continuation while the turn is held; once it returns, a stopped thread is resumed behind its writer lock and anything else waits for the next turn',
|
|
46
44
|
},
|
|
47
45
|
cursor: {
|
|
48
46
|
stopContinuation: 'unsupported',
|
|
49
|
-
|
|
47
|
+
deliveryRoutes: ['unsupported'],
|
|
50
48
|
deliveryContract:
|
|
51
49
|
'the hook can return a live follow-up, but the invoking shell exposes no exact conversation id; asynchronous ask is unsupported',
|
|
52
50
|
},
|
|
53
51
|
opencode: {
|
|
54
52
|
stopContinuation: 'unsupported',
|
|
55
|
-
|
|
53
|
+
deliveryRoutes: ['unsupported'],
|
|
56
54
|
deliveryContract:
|
|
57
55
|
'no proven answer continuation after session.idle; use a blocking reply command',
|
|
58
56
|
},
|