@raidiant/notifai 0.4.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.
@@ -73,14 +73,14 @@ describe('acceptedValues', () => {
73
73
  })
74
74
 
75
75
  it('names both values for a toggle', () => {
76
- expect(acceptedValues('require_idle')).toBe('true or false')
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('require_idle', true)).toBe('yes')
83
- expect(formatValue('require_idle', false)).toBe('no')
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', () => {
@@ -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 all fourteen keys carefully — but in TypeScript
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 `require_idle = true` and had no way to learn what
7
- * it did short of reading the source, so this module is that knowledge moved to
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 a list of fourteen settings stays
17
- * a list and not an essay.
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 fourteen
26
- * flat keys and four short lists a reader can hold in their head.
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 & presence',
34
- blurb: 'When a question an agent registered is allowed to leave this terminal and reach your devices.',
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. It is honoured whether or not presence is consulted at all, because how long to wait and whether anyone is watching are separate questions, and answering one should not answer the other.',
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',
@@ -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: 'away_after_seconds = 10\n',
49
- projectToml: 'away_after_seconds = 20\n',
50
- projectLocalToml: 'away_after_seconds = 30\n',
51
- sessionToml: 'away_after_seconds = 40\n',
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' }).away_after_seconds).toMatchObject({
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' }).away_after_seconds).toMatchObject({
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 }).away_after_seconds.value).toBe(30)
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: 'away_after_seconds = 99\n',
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' }).away_after_seconds.value).toBe(99)
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' }).away_after_seconds.value).toBe(120)
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 without requiring the user to be idle', () => {
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
- expect(config.require_idle).toEqual({ value: false, source: 'default' })
96
- expect(config.away_after_seconds.value).toBe(120)
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
- /** Route a registered agent question to companion devices after the presence gate. */
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. It is configured independently of
103
- * `away_after_seconds` and honoured whether or not presence is consulted at
104
- * all — how long to wait and whether anyone is watching are separate
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 immediately by default. Both a terminal-first
191
- // grace and presence gating remain independent opt-in controls.
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: `away_after_seconds = -1`
302
- * would make a user who just typed count as absent, and an oversized reply
303
- * timeout would exhaust the hook process budget and kill it after the user has
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
- away_after_seconds: { min: 5, max: 24 * 3600 },
310
- // The runtime clamps grace + reply work below the host process ceiling; this
311
- // maximum is never copied into the installed definition.
312
- // The wire protocol's minimum reply window is 60s; matching it prevents the
313
- // server from accepting an answer after a shorter local wait has returned.
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'),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { HARNESS_CAPABILITIES, HARNESSES, HOOK_TIMING } from './harnesses.js'
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
- dormantWake: 'unsupported',
10
+ deliveryRoutes: ['hook-continuation', 'inbox-socket', 'cold-resume', 'hold-for-next-turn'],
11
11
  deliveryContract:
12
- 'live Stop continuation; no automatic wake after its bounded wait returns',
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
- dormantWake: 'unsupported',
16
+ deliveryRoutes: ['hook-continuation', 'cold-resume', 'hold-for-next-turn'],
17
17
  deliveryContract:
18
- 'live Stop continuation; no automatic wake after its bounded wait returns',
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
- dormantWake: 'unsupported',
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
- dormantWake: 'unsupported',
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
- /** 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
- })
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
- /** Whether delivery remains native after the turn-end event has returned. */
27
- dormantWake: 'unsupported' | 'native-session-prompt'
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
- dormantWake: 'unsupported',
40
- deliveryContract: 'live Stop continuation; no automatic wake after its bounded wait returns',
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
- dormantWake: 'unsupported',
45
- deliveryContract: 'live Stop continuation; no automatic wake after its bounded wait returns',
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
- dormantWake: 'unsupported',
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
- dormantWake: 'unsupported',
53
+ deliveryRoutes: ['unsupported'],
56
54
  deliveryContract:
57
55
  'no proven answer continuation after session.idle; use a blocking reply command',
58
56
  },