@raidiant/notifai 5.0.0 → 7.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 +51 -0
- package/README.md +2 -2
- package/dist/atomic-file.js +13 -0
- package/dist/atomic-file.js.map +1 -1
- 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 +65 -0
- package/dist/commands-harness-context.js +97 -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 +183 -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 +4 -4
- package/dist/config.js +6 -4
- package/dist/config.js.map +1 -1
- package/dist/credentials.js +13 -3
- package/dist/credentials.js.map +1 -1
- package/dist/generated-session-label.d.ts +2 -0
- package/dist/generated-session-label.js +152 -0
- package/dist/generated-session-label.js.map +1 -0
- 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/invocation-context.d.ts +3 -0
- package/dist/invocation-context.js +23 -6
- package/dist/invocation-context.js.map +1 -1
- package/dist/logging.js +1 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +3 -3
- package/dist/main.js.map +1 -1
- package/dist/opencode-plugin.js +18 -3
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/readiness.d.ts +3 -1
- package/dist/readiness.js.map +1 -1
- package/dist/session-labels.d.ts +30 -0
- package/dist/session-labels.js +278 -0
- package/dist/session-labels.js.map +1 -0
- package/package.json +2 -2
- package/src/atomic-file.ts +12 -0
- 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 +134 -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 +227 -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 +567 -42
- package/src/commands.ts +58 -5614
- package/src/config-schema.ts +1 -1
- package/src/config.test.ts +9 -0
- package/src/config.ts +10 -5
- package/src/credentials.test.ts +25 -1
- package/src/credentials.ts +13 -3
- package/src/generated-session-label.ts +155 -0
- package/src/hooks.test.ts +355 -16
- package/src/hooks.ts +145 -97
- package/src/install-hooks.test.ts +84 -1
- package/src/interactive.test.ts +19 -1
- package/src/interactive.ts +4 -4
- package/src/invocation-context.test.ts +115 -16
- package/src/invocation-context.ts +32 -11
- package/src/logging.test.ts +15 -2
- package/src/logging.ts +1 -0
- package/src/main.ts +9 -3
- package/src/opencode-plugin.ts +18 -3
- package/src/readiness.ts +3 -1
- package/src/session-labels.test.ts +303 -0
- package/src/session-labels.ts +360 -0
- package/src/skill.test.ts +13 -0
|
@@ -0,0 +1,1586 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
|
|
3
|
+
CAPABILITIES_V1,
|
|
4
|
+
QUESTION_TEXT_MAX_LENGTH,
|
|
5
|
+
REPLY_MAX_QUESTIONS,
|
|
6
|
+
validateDraft,
|
|
7
|
+
type NotificationDraftT,
|
|
8
|
+
type QuestionT,
|
|
9
|
+
} from '@raidiant/notifai-protocol'
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
lstatSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
} from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { atomicWriteFileSync } from './atomic-file.js'
|
|
19
|
+
import { claudeWakeRoute } from './claude-wake.js'
|
|
20
|
+
import { ApiCallError } from './client.js'
|
|
21
|
+
import { codexWakeRoute } from './codex-wake.js'
|
|
22
|
+
import { loadConfig, type CliConfig } from './config.js'
|
|
23
|
+
import { withTargetFileLock } from './file-lock.js'
|
|
24
|
+
import { HARNESS_CAPABILITIES, HARNESS_LABELS } from './harnesses.js'
|
|
25
|
+
import {
|
|
26
|
+
inspectHookAdapter,
|
|
27
|
+
installHookAdapter,
|
|
28
|
+
isNpxAdapterTarget,
|
|
29
|
+
type HookAdapterTarget,
|
|
30
|
+
} from './hook-adapter.js'
|
|
31
|
+
import {
|
|
32
|
+
clearAcknowledgementObligation,
|
|
33
|
+
handleSessionEnd,
|
|
34
|
+
handleStop,
|
|
35
|
+
handleUserPromptSubmit,
|
|
36
|
+
parseHookInput,
|
|
37
|
+
pruneAbandonedSessions,
|
|
38
|
+
readMatchingProjectSessionPointer,
|
|
39
|
+
readProjectSession,
|
|
40
|
+
readSessionState,
|
|
41
|
+
registerQuestion,
|
|
42
|
+
waiterCeilingSeconds,
|
|
43
|
+
type EscalationDeliveryRoute,
|
|
44
|
+
type HookContext,
|
|
45
|
+
type HookHarness,
|
|
46
|
+
} from './hooks.js'
|
|
47
|
+
import {
|
|
48
|
+
BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
49
|
+
HARNESSES,
|
|
50
|
+
applyPlan,
|
|
51
|
+
buildCursorHookConfig,
|
|
52
|
+
buildHookConfig,
|
|
53
|
+
codexCoexistenceNotes,
|
|
54
|
+
codexHomeNote,
|
|
55
|
+
codexLayerDir,
|
|
56
|
+
codexLayerPaths,
|
|
57
|
+
codexRepresentationProblems,
|
|
58
|
+
codexTrustProblems,
|
|
59
|
+
detectHarness,
|
|
60
|
+
detectedHarnesses,
|
|
61
|
+
findInstallations,
|
|
62
|
+
handlerEvent,
|
|
63
|
+
hookDefinitionFiles,
|
|
64
|
+
loadCursorSettings,
|
|
65
|
+
loadSettings,
|
|
66
|
+
mergeCursorHooks,
|
|
67
|
+
mergeHooks,
|
|
68
|
+
removeCursorHooks,
|
|
69
|
+
removeHooks,
|
|
70
|
+
settingsFile,
|
|
71
|
+
stopHandlerIsDetached,
|
|
72
|
+
withCodexLayerTransaction,
|
|
73
|
+
type Harness,
|
|
74
|
+
type Installation,
|
|
75
|
+
} from './install-hooks.js'
|
|
76
|
+
import { logConfigResolved, logSettingsFrom } from './logging.js'
|
|
77
|
+
import { isOurOpencodePlugin, opencodePluginSource } from './opencode-plugin.js'
|
|
78
|
+
import { packageVersion } from './release.js'
|
|
79
|
+
import {
|
|
80
|
+
CHOICE_USAGE,
|
|
81
|
+
buildDraft,
|
|
82
|
+
parseChoices,
|
|
83
|
+
slugify,
|
|
84
|
+
validateMediaInputs,
|
|
85
|
+
type DraftInvocation,
|
|
86
|
+
} from './send.js'
|
|
87
|
+
import {
|
|
88
|
+
EXIT,
|
|
89
|
+
authedClient,
|
|
90
|
+
loadLoggedConfig,
|
|
91
|
+
log,
|
|
92
|
+
makeClient,
|
|
93
|
+
rejectedPaths,
|
|
94
|
+
reportError,
|
|
95
|
+
type CommandDeps,
|
|
96
|
+
} from './commands-core.js'
|
|
97
|
+
import {
|
|
98
|
+
claudeSessionPid,
|
|
99
|
+
resolveActiveHarness,
|
|
100
|
+
type ActiveHarnessSession,
|
|
101
|
+
} from './commands-harness-context.js'
|
|
102
|
+
import { stopShapeProblems } from './commands-hook-shape.js'
|
|
103
|
+
import {
|
|
104
|
+
acknowledgementCommand,
|
|
105
|
+
printAcknowledgementStatus,
|
|
106
|
+
resolveDraftInvocation,
|
|
107
|
+
uploadImage,
|
|
108
|
+
waitForReply,
|
|
109
|
+
} from './commands-send-support.js'
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// hook / ask / close — harness integration
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
export const HOOK_EVENTS = ['user-prompt-submit', 'stop', 'session-end'] as const
|
|
116
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number]
|
|
117
|
+
|
|
118
|
+
/** SessionEnd cleanup must precede every diagnostic that can wait on a file lock. */
|
|
119
|
+
export function hookDefersDiagnosticsUntilAfterCleanup(
|
|
120
|
+
event: unknown,
|
|
121
|
+
): event is 'session-end' {
|
|
122
|
+
return event === 'session-end'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Runs one harness hook. Contract with every harness: hook JSON arrives on
|
|
127
|
+
* stdin, the decision (if any) goes to stdout, diagnostics go to stderr, and
|
|
128
|
+
* exit 0 with no stdout means "no decision — carry on as normal".
|
|
129
|
+
*
|
|
130
|
+
* Every failure path in here must reach that no-decision state. A hook that
|
|
131
|
+
* throws, or that blocks past the harness's timeout, degrades the agent for a
|
|
132
|
+
* feature the user only asked to make it more convenient.
|
|
133
|
+
*/
|
|
134
|
+
export async function hookRunCommand(
|
|
135
|
+
deps: CommandDeps,
|
|
136
|
+
event: string,
|
|
137
|
+
readStdin: () => Promise<string>,
|
|
138
|
+
harness?: HookHarness,
|
|
139
|
+
): Promise<number> {
|
|
140
|
+
if (!(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
141
|
+
deps.io.err(`Unknown hook event "${event}". Valid: ${HOOK_EVENTS.join(', ')}`)
|
|
142
|
+
return EXIT.usage
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// One clock owns the complete Stop invocation, including stdin, config,
|
|
146
|
+
// credentials, and client construction. Starting this inside `handleStop`
|
|
147
|
+
// would grant slow setup a second budget and let the harness kill us before
|
|
148
|
+
// an accepted answer is journaled or written to stdout.
|
|
149
|
+
const now = deps.now ?? Date.now
|
|
150
|
+
// The waiter may spend a long wall clock exactly when no turn is held open
|
|
151
|
+
// for it, which is the same condition the installer used to declare
|
|
152
|
+
// `async: true`. One predicate decides it for both.
|
|
153
|
+
const detachedWaiter = stopHandlerIsDetached(harness, deps.hookPlatform ?? process.platform)
|
|
154
|
+
const processDeadlineAt = now() + waiterCeilingSeconds(detachedWaiter) * 1000
|
|
155
|
+
|
|
156
|
+
const logger = log(deps)
|
|
157
|
+
logger.bind({ cmd: `hook ${event}` })
|
|
158
|
+
let started = false
|
|
159
|
+
const start = (data: Record<string, unknown> = {}): void => {
|
|
160
|
+
if (started) return
|
|
161
|
+
started = true
|
|
162
|
+
logger.info('hook.start', { hook: event, harness: harness ?? 'unknown', ...data })
|
|
163
|
+
}
|
|
164
|
+
const failureData = (err: unknown): Record<string, unknown> =>
|
|
165
|
+
err instanceof ApiCallError
|
|
166
|
+
? { status: err.status, code: err.code, message: err.message, details: err.details }
|
|
167
|
+
: { message: err instanceof Error ? err.message : String(err) }
|
|
168
|
+
|
|
169
|
+
let raw: string
|
|
170
|
+
try {
|
|
171
|
+
raw = await readStdin()
|
|
172
|
+
} catch (err) {
|
|
173
|
+
start({ input: 'unavailable' })
|
|
174
|
+
logger.error('hook.end', {
|
|
175
|
+
hook: event,
|
|
176
|
+
outcome: 'ignored',
|
|
177
|
+
reason: 'input-read-failed',
|
|
178
|
+
...failureData(err),
|
|
179
|
+
})
|
|
180
|
+
return EXIT.ok
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (raw.trim() !== '') {
|
|
184
|
+
try {
|
|
185
|
+
const parsed: unknown = JSON.parse(raw)
|
|
186
|
+
if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object')
|
|
187
|
+
} catch {
|
|
188
|
+
start({ input: 'malformed' })
|
|
189
|
+
logger.error('hook.end', { hook: event, outcome: 'ignored', reason: 'malformed-input' })
|
|
190
|
+
deps.io.err('notifai: ignored malformed or truncated hook input; no routing action was taken')
|
|
191
|
+
return EXIT.ok
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let envelope = parseHookInput(raw)
|
|
196
|
+
if (harness === 'cursor') {
|
|
197
|
+
const sessionId = envelope.session_id ?? envelope.conversation_id
|
|
198
|
+
const cwd = envelope.cwd ?? envelope.workspace_roots?.[0]
|
|
199
|
+
envelope = {
|
|
200
|
+
...envelope,
|
|
201
|
+
...(sessionId === undefined ? {} : { session_id: sessionId }),
|
|
202
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
203
|
+
stop_hook_active:
|
|
204
|
+
envelope.stop_hook_active ??
|
|
205
|
+
(typeof envelope.loop_count === 'number' && envelope.loop_count > 0),
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const cwd = envelope.cwd ?? deps.cwd
|
|
210
|
+
const sessionEnd = hookDefersDiagnosticsUntilAfterCleanup(event)
|
|
211
|
+
logger.bind({ session: envelope.session_id ?? null })
|
|
212
|
+
let config: CliConfig | null = null
|
|
213
|
+
let configFailure: unknown
|
|
214
|
+
try {
|
|
215
|
+
config = loadConfig({ cwd, env: deps.env, sessionId: envelope.session_id })
|
|
216
|
+
// The hook's project is the session's, not this process's, and the log
|
|
217
|
+
// settings that apply are that project's too. Keeping a mutable bootstrap
|
|
218
|
+
// logger lets this more-specific layer turn logging back on.
|
|
219
|
+
logger.adopt(logSettingsFrom(config))
|
|
220
|
+
logger.bind({ project: config.project.value })
|
|
221
|
+
if (!sessionEnd) {
|
|
222
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
223
|
+
logConfigResolved(logger, config)
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
configFailure = err
|
|
227
|
+
if (!sessionEnd) {
|
|
228
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
229
|
+
logger.error('hook.end', {
|
|
230
|
+
hook: event,
|
|
231
|
+
outcome: 'failed',
|
|
232
|
+
reason: 'config-failed',
|
|
233
|
+
...failureData(err),
|
|
234
|
+
})
|
|
235
|
+
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
236
|
+
return EXIT.ok
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Everything below is inside one fail-open boundary. Credential loading,
|
|
241
|
+
// client construction and hook handling can all throw, and a hook that exits
|
|
242
|
+
// non-zero makes the harness report a failure — strictly worse than skipping.
|
|
243
|
+
try {
|
|
244
|
+
if (sessionEnd) {
|
|
245
|
+
// Codex gives SessionEnd one second total. Do every durable cleanup write
|
|
246
|
+
// before lifecycle diagnostics: the log lock is deliberately allowed to
|
|
247
|
+
// wait that long, and a busy log must never preserve ended-session state
|
|
248
|
+
// or its inherited configuration. The resolved config above is retained
|
|
249
|
+
// in memory so logging still uses the ending session's settings afterwards.
|
|
250
|
+
const outcome = handleSessionEnd(deps.env, envelope, (deps.now ?? Date.now)())
|
|
251
|
+
start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
252
|
+
if (config !== null) logConfigResolved(logger, config)
|
|
253
|
+
const data = { hook: event, decided: false, ...outcome.log }
|
|
254
|
+
if (configFailure === undefined) logger.info('hook.end', data)
|
|
255
|
+
else {
|
|
256
|
+
logger.error('hook.end', {
|
|
257
|
+
...data,
|
|
258
|
+
reason: 'config-failed',
|
|
259
|
+
config_error: failureData(configFailure),
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
263
|
+
return EXIT.ok
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Non-SessionEnd hooks cannot reach here without resolved configuration.
|
|
267
|
+
const resolved = config!
|
|
268
|
+
const credential = deps.store.load()
|
|
269
|
+
if (!credential) {
|
|
270
|
+
logger.error('hook.end', { hook: event, outcome: 'not-paired' })
|
|
271
|
+
deps.io.err('notifai: hook skipped: this machine is not paired; run `notifai login`')
|
|
272
|
+
return EXIT.ok
|
|
273
|
+
}
|
|
274
|
+
// Pin authenticated traffic to the origin the credential was issued for. A
|
|
275
|
+
// repository can commit `.notifai/config.toml`, and honouring a base_url
|
|
276
|
+
// from it would hand this machine's bearer token to whatever host it names.
|
|
277
|
+
const baseUrl = credential.baseUrl
|
|
278
|
+
if (resolved.base_url.source !== 'default' && resolved.base_url.value !== baseUrl) {
|
|
279
|
+
deps.io.err(
|
|
280
|
+
`notifai: ignoring base_url from ${resolved.base_url.source}; hooks only talk to ${baseUrl}`,
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
// UserPromptSubmit runs in front of the user's own prompt under a 15s
|
|
284
|
+
// harness ceiling and can make two calls, so each gets a small slice of it;
|
|
285
|
+
// Stop is allowed to block and keeps the ordinary budget.
|
|
286
|
+
const client = makeClient(
|
|
287
|
+
deps,
|
|
288
|
+
baseUrl,
|
|
289
|
+
`Bearer nfm_${credential.machineId}.${credential.secret}`,
|
|
290
|
+
{
|
|
291
|
+
timeoutMs: event === 'user-prompt-submit' ? 4_000 : 20_000,
|
|
292
|
+
...(event === 'stop' ? { deadlineAt: processDeadlineAt, now } : {}),
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
const ctx: HookContext = {
|
|
296
|
+
client,
|
|
297
|
+
config: resolved,
|
|
298
|
+
env: deps.env,
|
|
299
|
+
now,
|
|
300
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
301
|
+
waitForFirstReply: async (requestId, timeoutSeconds) => {
|
|
302
|
+
const result = await waitForReply(client, requestId, {
|
|
303
|
+
timeoutSeconds,
|
|
304
|
+
afterSeq: 0,
|
|
305
|
+
now: deps.now,
|
|
306
|
+
sleep: deps.sleep,
|
|
307
|
+
})
|
|
308
|
+
return {
|
|
309
|
+
replies: result.response.replies,
|
|
310
|
+
timedOut: result.timedOut,
|
|
311
|
+
degraded: result.degraded,
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
log: logger,
|
|
315
|
+
...(harness === undefined ? {} : { harness }),
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Real clock, deliberately, not `deps.now`. This compares against file
|
|
319
|
+
// mtimes, which are wall-clock facts — handing it a virtual or skewed clock
|
|
320
|
+
// would have it delete live session state as "abandoned".
|
|
321
|
+
// Daily state pruning is housekeeping, not part of the Stop delivery
|
|
322
|
+
// contract. Its directory scan has no useful bound, so keep it on the
|
|
323
|
+
// short prompt path and never spend the answer owner's finite budget on it.
|
|
324
|
+
if (event !== 'stop') pruneAbandonedSessions(deps.env)
|
|
325
|
+
|
|
326
|
+
const outcome =
|
|
327
|
+
event === 'user-prompt-submit'
|
|
328
|
+
? await handleUserPromptSubmit(ctx, envelope)
|
|
329
|
+
: await handleStop(
|
|
330
|
+
ctx,
|
|
331
|
+
envelope,
|
|
332
|
+
processDeadlineAt,
|
|
333
|
+
stopWakeRoute(deps, harness, envelope.session_id, cwd),
|
|
334
|
+
)
|
|
335
|
+
// Answer diagnostics are already persisted once as hook.answer. Keep every
|
|
336
|
+
// other note in the lifecycle record without duplicating the user's text.
|
|
337
|
+
const notes = outcome.notes.filter((note) => !/^(?:late )?answer from /.test(note))
|
|
338
|
+
logger.info('hook.end', {
|
|
339
|
+
hook: event,
|
|
340
|
+
// A hook that returns stdout has taken over the turn; one that does not
|
|
341
|
+
// has handed the terminal back. That distinction is the whole contract.
|
|
342
|
+
decided: outcome.stdout !== undefined,
|
|
343
|
+
...(notes.length === 0 ? {} : { notes }),
|
|
344
|
+
...outcome.log,
|
|
345
|
+
})
|
|
346
|
+
for (const note of outcome.notes) deps.io.err(`notifai: ${note}`)
|
|
347
|
+
if (outcome.stdout !== undefined) deps.io.out(outcome.stdout)
|
|
348
|
+
return EXIT.ok
|
|
349
|
+
} catch (err) {
|
|
350
|
+
// SessionEnd defers its start record until after cleanup; if cleanup itself
|
|
351
|
+
// fails, begin the after-the-fact lifecycle here before recording why.
|
|
352
|
+
if (sessionEnd) start({ cwd, stop_hook_active: envelope.stop_hook_active ?? null })
|
|
353
|
+
// The hook still exits 0 — handing the terminal back is always right. What
|
|
354
|
+
// this adds is that the reason survives, including the server's own words.
|
|
355
|
+
logger.error('hook.end', {
|
|
356
|
+
hook: event,
|
|
357
|
+
outcome: 'failed',
|
|
358
|
+
reason: 'execution-failed',
|
|
359
|
+
...failureData(err),
|
|
360
|
+
})
|
|
361
|
+
for (const line of describeHookFailure(err)) deps.io.err(`notifai: ${line}`)
|
|
362
|
+
return EXIT.ok
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The last meter for an answer this Stop hook accepted, chosen by harness.
|
|
368
|
+
*
|
|
369
|
+
* Both wake adapters need the harness process that invoked this hook: Claude's
|
|
370
|
+
* to prove exact own-child session ownership before it posts to the inbox
|
|
371
|
+
* socket, Codex's to know whether its own stdout is still a live continuation
|
|
372
|
+
* channel. Without an exact session id neither can prove anything, so the
|
|
373
|
+
* waiter falls back to the plain blocking Stop continuation.
|
|
374
|
+
*/
|
|
375
|
+
function stopWakeRoute(
|
|
376
|
+
deps: CommandDeps,
|
|
377
|
+
harness: Harness | undefined,
|
|
378
|
+
sessionId: string | undefined,
|
|
379
|
+
cwd: string,
|
|
380
|
+
): EscalationDeliveryRoute | undefined {
|
|
381
|
+
if (sessionId === undefined) return undefined
|
|
382
|
+
if (harness === 'claude-code') {
|
|
383
|
+
if ((deps.hookPlatform ?? process.platform) === 'win32') return undefined
|
|
384
|
+
return claudeWakeRoute({
|
|
385
|
+
sessionId,
|
|
386
|
+
cwd,
|
|
387
|
+
sourcePid: deps.claudeSourcePid ?? claudeSessionPid(deps.env),
|
|
388
|
+
...(deps.claudeWake === undefined ? {} : { adapters: deps.claudeWake }),
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
if (harness === 'codex') {
|
|
392
|
+
return codexWakeRoute({
|
|
393
|
+
threadId: sessionId,
|
|
394
|
+
cwd,
|
|
395
|
+
sourcePid: deps.codexSourcePid ?? process.ppid,
|
|
396
|
+
env: deps.env,
|
|
397
|
+
...(deps.codexWake === undefined ? {} : { adapters: deps.codexWake }),
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
return undefined
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* What went wrong, in terms of what to do about it.
|
|
406
|
+
*
|
|
407
|
+
* On 2026-08-03 a contract change shipped without the server deploy that goes
|
|
408
|
+
* with it. The CLI stamped `lifecycle` on every question draft, the deployed
|
|
409
|
+
* server rejected the unknown field, and escalation stopped working entirely —
|
|
410
|
+
* announced as "hook failed, deferring to the terminal", which reads like a
|
|
411
|
+
* flaky network. The information needed to diagnose it in one second was
|
|
412
|
+
* already in hand: a 422 whose details name the offending path. It was being
|
|
413
|
+
* thrown away by `String(err)`.
|
|
414
|
+
*
|
|
415
|
+
* A hook still exits 0 whatever this says. Handing the terminal back is always
|
|
416
|
+
* right; the only question is whether the user is told anything they can use.
|
|
417
|
+
*/
|
|
418
|
+
export function describeHookFailure(err: unknown): string[] {
|
|
419
|
+
if (!(err instanceof ApiCallError)) {
|
|
420
|
+
return [`hook failed, deferring to the terminal (${String(err)})`]
|
|
421
|
+
}
|
|
422
|
+
const lines = [`hook failed, deferring to the terminal (${err.code}: ${err.message})`]
|
|
423
|
+
const paths = rejectedPaths(err.details)
|
|
424
|
+
if (paths.length > 0) lines.push(`the server rejected: ${paths.join(', ')}`)
|
|
425
|
+
// A 422 on a draft this CLI built is not a user error — this CLI's own
|
|
426
|
+
// contract produced it. Either the server is behind, or the two disagree.
|
|
427
|
+
if (err.status === 422) {
|
|
428
|
+
lines.push(
|
|
429
|
+
'this build sent a field the server did not accept, which usually means the server ' +
|
|
430
|
+
'is older than this CLI — check with `notifai doctor`',
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
return lines
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
export interface AskFlags {
|
|
438
|
+
/** Emit the registration and its turn obligation as one JSON object. */
|
|
439
|
+
json?: boolean
|
|
440
|
+
choice?: string[]
|
|
441
|
+
/** The single question is multi-select: several answers may be chosen. */
|
|
442
|
+
multi?: boolean
|
|
443
|
+
/** Optional Markdown context appended after the question block. */
|
|
444
|
+
body?: string
|
|
445
|
+
/** Raw JSON for a multi-question form; replaces the positional question. */
|
|
446
|
+
form?: string
|
|
447
|
+
image?: string[]
|
|
448
|
+
imageAlt?: string[]
|
|
449
|
+
project?: string
|
|
450
|
+
sessionId?: string
|
|
451
|
+
sessionLabel?: string
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The `--form` document: what an agent writes to ask several things at once. */
|
|
455
|
+
interface AskFormQuestion {
|
|
456
|
+
text: string
|
|
457
|
+
choices?: string[]
|
|
458
|
+
multi?: boolean
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export interface BuiltQuestions {
|
|
462
|
+
questions: QuestionT[]
|
|
463
|
+
/** Canonical Markdown body: question block first, optional context second. */
|
|
464
|
+
body: string
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Turn ask input into questions plus their canonical body. Everything is
|
|
469
|
+
* validated at registration because a later hook failure is easy to miss.
|
|
470
|
+
*/
|
|
471
|
+
export function buildQuestions(
|
|
472
|
+
flags: AskFlags,
|
|
473
|
+
question: string | undefined,
|
|
474
|
+
): { ok: true; questions: QuestionT[]; body: string } | { ok: false; error: string } {
|
|
475
|
+
if (flags.form !== undefined) {
|
|
476
|
+
if (question !== undefined || flags.choice?.length || flags.multi) {
|
|
477
|
+
return { ok: false, error: '--form replaces the positional question, --choice, and --multi.' }
|
|
478
|
+
}
|
|
479
|
+
let parsed: unknown
|
|
480
|
+
try {
|
|
481
|
+
parsed = JSON.parse(flags.form)
|
|
482
|
+
} catch {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
error: '--form must be JSON: {"questions": [{"text", "choices"?, "multi"?}], "body"?}.',
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
489
|
+
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
|
|
490
|
+
}
|
|
491
|
+
const record = parsed as Record<string, unknown>
|
|
492
|
+
const unknownKeys = Object.keys(record).filter((key) => key !== 'questions' && key !== 'body')
|
|
493
|
+
if (unknownKeys.length > 0) {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
error: `Unknown --form ${unknownKeys.length === 1 ? 'key' : 'keys'}: ${unknownKeys.join(', ')}. Use "body" for Markdown context.`,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (!Array.isArray(record['questions'])) {
|
|
500
|
+
return { ok: false, error: `--form needs a "questions" array (1-${REPLY_MAX_QUESTIONS} entries).` }
|
|
501
|
+
}
|
|
502
|
+
const formQuestions = record['questions']
|
|
503
|
+
if (formQuestions.length < 1 || formQuestions.length > REPLY_MAX_QUESTIONS) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
error: `A form asks 1-${REPLY_MAX_QUESTIONS} questions; this one has ${formQuestions.length}.`,
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (record['body'] !== undefined && typeof record['body'] !== 'string') {
|
|
510
|
+
return { ok: false, error: '"body" must be a Markdown string.' }
|
|
511
|
+
}
|
|
512
|
+
if (flags.body !== undefined && record['body'] !== undefined) {
|
|
513
|
+
return { ok: false, error: 'Pass form context in either --body or the form "body" key, not both.' }
|
|
514
|
+
}
|
|
515
|
+
const questions: QuestionT[] = []
|
|
516
|
+
const usedIds = new Set<string>()
|
|
517
|
+
for (const [index, entry] of formQuestions.entries()) {
|
|
518
|
+
if (typeof entry !== 'object' || entry === null || typeof (entry as AskFormQuestion).text !== 'string') {
|
|
519
|
+
return { ok: false, error: `Question ${index + 1} needs a "text" string.` }
|
|
520
|
+
}
|
|
521
|
+
const spec = entry as AskFormQuestion
|
|
522
|
+
const built = buildOneQuestion(spec.text, spec.choices, spec.multi === true, index, usedIds)
|
|
523
|
+
if ('error' in built) return { ok: false, error: `Question ${index + 1}: ${built.error}` }
|
|
524
|
+
questions.push(built.question)
|
|
525
|
+
}
|
|
526
|
+
const context = flags.body ?? (record['body'] as string | undefined)
|
|
527
|
+
const questionBlock = questions.map((entry, index) => `${index + 1}. ${entry.text}`).join('\n')
|
|
528
|
+
return {
|
|
529
|
+
ok: true,
|
|
530
|
+
questions,
|
|
531
|
+
body:
|
|
532
|
+
context !== undefined && context.trim() !== ''
|
|
533
|
+
? `${questionBlock}\n\n${context}`
|
|
534
|
+
: questionBlock,
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (question === undefined || question.trim() === '') {
|
|
539
|
+
return { ok: false, error: 'The question cannot be empty.' }
|
|
540
|
+
}
|
|
541
|
+
const built = buildOneQuestion(question, flags.choice, flags.multi === true, 0, new Set())
|
|
542
|
+
if ('error' in built) return { ok: false, error: built.error }
|
|
543
|
+
const context = flags.body
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
questions: [built.question],
|
|
547
|
+
body:
|
|
548
|
+
context !== undefined && context.trim() !== ''
|
|
549
|
+
? `${built.question.text}\n\n${context}`
|
|
550
|
+
: built.question.text,
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function buildOneQuestion(
|
|
555
|
+
text: string,
|
|
556
|
+
choiceLabels: string[] | undefined,
|
|
557
|
+
multi: boolean,
|
|
558
|
+
index: number,
|
|
559
|
+
usedIds: Set<string>,
|
|
560
|
+
): { question: QuestionT } | { error: string } {
|
|
561
|
+
const trimmed = text.trim()
|
|
562
|
+
if (trimmed === '') return { error: 'the question text cannot be empty.' }
|
|
563
|
+
if (trimmed.length > QUESTION_TEXT_MAX_LENGTH) {
|
|
564
|
+
return {
|
|
565
|
+
error:
|
|
566
|
+
`a question must be readable where it is answered: keep it within ` +
|
|
567
|
+
`${QUESTION_TEXT_MAX_LENGTH} characters and put the longer context in --body.`,
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const choices = parseChoices(choiceLabels)
|
|
571
|
+
if (choices === 'invalid') return { error: CHOICE_USAGE }
|
|
572
|
+
if (multi && choices === null) {
|
|
573
|
+
return { error: '--multi needs answers to select between; add --choice.' }
|
|
574
|
+
}
|
|
575
|
+
let id = slugify(trimmed)
|
|
576
|
+
if (id === '' || usedIds.has(id)) id = `q${index + 1}`
|
|
577
|
+
usedIds.add(id)
|
|
578
|
+
return {
|
|
579
|
+
question: {
|
|
580
|
+
id,
|
|
581
|
+
text: trimmed,
|
|
582
|
+
...(choices !== null ? { choices } : {}),
|
|
583
|
+
...(multi ? { multi: true } : {}),
|
|
584
|
+
},
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function buildAskDraft(
|
|
589
|
+
config: CliConfig,
|
|
590
|
+
built: BuiltQuestions,
|
|
591
|
+
flags: AskFlags,
|
|
592
|
+
invocation: DraftInvocation,
|
|
593
|
+
mediaIds: string[],
|
|
594
|
+
): { ok: true; draft: NotificationDraftT } | { ok: false; error: string } {
|
|
595
|
+
const result = buildDraft(
|
|
596
|
+
config,
|
|
597
|
+
{
|
|
598
|
+
title: built.questions[0]!.text,
|
|
599
|
+
body: built.body,
|
|
600
|
+
...(flags.project !== undefined ? { project: flags.project } : {}),
|
|
601
|
+
...(mediaIds.length > 0 ? { image: mediaIds } : {}),
|
|
602
|
+
...(flags.imageAlt !== undefined ? { imageAlt: flags.imageAlt } : {}),
|
|
603
|
+
reply: true,
|
|
604
|
+
questions: built.questions,
|
|
605
|
+
},
|
|
606
|
+
invocation,
|
|
607
|
+
)
|
|
608
|
+
if (!result.ok) return result
|
|
609
|
+
const capabilities = CAPABILITIES_V1.describe(result.platform)
|
|
610
|
+
if (capabilities === null) return { ok: false, error: 'No iOS capability contract is available.' }
|
|
611
|
+
const validation = validateDraft(result.draft, capabilities)
|
|
612
|
+
if (!validation.ok) {
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
error: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join('\n'),
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { ok: true, draft: result.draft }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function recordRegisteredQuestion(
|
|
622
|
+
deps: CommandDeps,
|
|
623
|
+
sessionId: string,
|
|
624
|
+
built: BuiltQuestions,
|
|
625
|
+
draft: NotificationDraftT,
|
|
626
|
+
json = false,
|
|
627
|
+
): number {
|
|
628
|
+
try {
|
|
629
|
+
registerQuestion(
|
|
630
|
+
sessionId,
|
|
631
|
+
deps.env,
|
|
632
|
+
{
|
|
633
|
+
question: built.questions[0]!.text,
|
|
634
|
+
questions: built.questions,
|
|
635
|
+
body: draft.presentation.body,
|
|
636
|
+
...(draft.project !== undefined ? { project: draft.project } : {}),
|
|
637
|
+
...(draft.source !== undefined ? { source: draft.source } : {}),
|
|
638
|
+
...(draft.presentation.media !== undefined ? { media: draft.presentation.media } : {}),
|
|
639
|
+
},
|
|
640
|
+
(deps.now ?? Date.now)(),
|
|
641
|
+
)
|
|
642
|
+
} catch (err) {
|
|
643
|
+
log(deps).error('ask.registered', { ok: false, session: sessionId, message: String(err) })
|
|
644
|
+
deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`)
|
|
645
|
+
return EXIT.failed
|
|
646
|
+
}
|
|
647
|
+
log(deps).info('ask.registered', {
|
|
648
|
+
ok: true,
|
|
649
|
+
session: sessionId,
|
|
650
|
+
questions: built.questions.length,
|
|
651
|
+
text: built.questions[0]!.text,
|
|
652
|
+
choices: built.questions[0]!.choices?.length ?? 0,
|
|
653
|
+
media: draft.presentation.media?.length ?? 0,
|
|
654
|
+
})
|
|
655
|
+
// The block below is the densest guidance this CLI prints, and until now it
|
|
656
|
+
// was prose only: an agent could not read back the choice ids it must branch
|
|
657
|
+
// on without asking the server for them. The JSON form carries the same
|
|
658
|
+
// obligation as data.
|
|
659
|
+
if (json) {
|
|
660
|
+
deps.io.out(
|
|
661
|
+
JSON.stringify(
|
|
662
|
+
{
|
|
663
|
+
registered: true,
|
|
664
|
+
questions: built.questions.map((entry) => ({
|
|
665
|
+
id: entry.id,
|
|
666
|
+
text: entry.text,
|
|
667
|
+
...(entry.choices === undefined ? {} : { choices: entry.choices }),
|
|
668
|
+
...(entry.multi === true ? { multi: true } : {}),
|
|
669
|
+
})),
|
|
670
|
+
next: {
|
|
671
|
+
end_turn: true,
|
|
672
|
+
in_this_turn:
|
|
673
|
+
'Ask the question in the conversation and say what concrete work each possible answer will make you resume, then end the turn.',
|
|
674
|
+
route_neutral:
|
|
675
|
+
'Never say where the answer must arrive; it returns by whatever route the harness supports.',
|
|
676
|
+
on_answer:
|
|
677
|
+
'Acknowledge it, then resume the committed work without asking the user to confirm again.',
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
null,
|
|
681
|
+
2,
|
|
682
|
+
),
|
|
683
|
+
)
|
|
684
|
+
return EXIT.ok
|
|
685
|
+
}
|
|
686
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
687
|
+
const prefix = built.questions.length > 1 ? `${index + 1}. ` : ''
|
|
688
|
+
if (entry.choices !== undefined) {
|
|
689
|
+
const kind = entry.multi === true ? 'answers offered (several may be chosen)' : 'answers offered'
|
|
690
|
+
deps.io.out(`${prefix}${entry.text} — ${kind}: ${entry.choices.map((choice) => choice.label).join(' / ')}`)
|
|
691
|
+
} else if (built.questions.length > 1) {
|
|
692
|
+
deps.io.out(`${prefix}${entry.text} — free text`)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
deps.io.out(
|
|
696
|
+
built.questions.length > 1
|
|
697
|
+
? `${built.questions.length} questions registered as one form. Ask them in the conversation, state the concrete work you will resume for their answers, then end your turn.`
|
|
698
|
+
: 'Question registered. Ask it in the conversation, state the concrete work you will resume when the answer arrives, then end your turn.',
|
|
699
|
+
)
|
|
700
|
+
deps.io.out('Before ending this turn, pre-commit in your own words to the work you will resume:')
|
|
701
|
+
for (const [index, entry] of built.questions.entries()) {
|
|
702
|
+
const questionPrefix = built.questions.length > 1 ? `Question ${index + 1}, ` : ''
|
|
703
|
+
if (entry.choices !== undefined) {
|
|
704
|
+
for (const choice of entry.choices) {
|
|
705
|
+
deps.io.out(
|
|
706
|
+
`- ${questionPrefix}If the answer is ${JSON.stringify(choice.label)}: state the concrete work you will resume.`,
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
deps.io.out(
|
|
710
|
+
`- ${questionPrefix}For an unexpected typed answer: state how it will determine the concrete work you resume.`,
|
|
711
|
+
)
|
|
712
|
+
} else {
|
|
713
|
+
deps.io.out(
|
|
714
|
+
`- ${questionPrefix}For the free-text answer: state how its content will determine the concrete work you resume.`,
|
|
715
|
+
)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
deps.io.out(
|
|
719
|
+
'When the answer arrives, resume the matching work without asking the user to confirm again. Frame this as work you will resume, not as approval you receive.',
|
|
720
|
+
)
|
|
721
|
+
deps.io.out(
|
|
722
|
+
'A Notifai answer cannot answer a harness permission prompt or interactive picker; leave those to the harness and user.',
|
|
723
|
+
)
|
|
724
|
+
return EXIT.ok
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function uploadAskMedia(
|
|
728
|
+
deps: CommandDeps,
|
|
729
|
+
config: CliConfig,
|
|
730
|
+
sessionId: string,
|
|
731
|
+
built: BuiltQuestions,
|
|
732
|
+
flags: AskFlags,
|
|
733
|
+
invocation: DraftInvocation,
|
|
734
|
+
): Promise<number> {
|
|
735
|
+
const authed = authedClient(deps, config)
|
|
736
|
+
if (!authed) return EXIT.auth
|
|
737
|
+
const mediaIds: string[] = []
|
|
738
|
+
for (const image of flags.image ?? []) {
|
|
739
|
+
if (image.startsWith('med_')) {
|
|
740
|
+
mediaIds.push(image)
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
const uploaded = await uploadImage(deps, authed.client, image)
|
|
744
|
+
if (!uploaded.ok) {
|
|
745
|
+
if (uploaded.error !== null) deps.io.err(uploaded.error)
|
|
746
|
+
return uploaded.exit
|
|
747
|
+
}
|
|
748
|
+
mediaIds.push(uploaded.mediaId)
|
|
749
|
+
}
|
|
750
|
+
const ready = buildAskDraft(config, built, flags, invocation, mediaIds)
|
|
751
|
+
if (!ready.ok) {
|
|
752
|
+
deps.io.err(ready.error)
|
|
753
|
+
return EXIT.usage
|
|
754
|
+
}
|
|
755
|
+
return recordRegisteredQuestion(deps, sessionId, built, ready.draft, flags.json === true)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Registers a question for turn-end routing. Returns immediately so the agent
|
|
760
|
+
* can ask in prose and end its turn; the terminal keeps the question to itself
|
|
761
|
+
* for `ask_grace_seconds` before it reaches any device.
|
|
762
|
+
*/
|
|
763
|
+
export function askCommand(
|
|
764
|
+
deps: CommandDeps,
|
|
765
|
+
question: string | undefined,
|
|
766
|
+
flags: AskFlags,
|
|
767
|
+
): number | Promise<number> {
|
|
768
|
+
// Validate before route discovery. A malformed question belongs to the
|
|
769
|
+
// caller and should not be hidden behind whichever harness setup issue
|
|
770
|
+
// happens to exist on this machine.
|
|
771
|
+
const built = buildQuestions(flags, question)
|
|
772
|
+
if (!built.ok) {
|
|
773
|
+
deps.io.err(built.error)
|
|
774
|
+
return EXIT.usage
|
|
775
|
+
}
|
|
776
|
+
const mediaInputError = validateMediaInputs(flags.image, flags.imageAlt)
|
|
777
|
+
if (mediaInputError !== null) {
|
|
778
|
+
deps.io.err(mediaInputError)
|
|
779
|
+
return EXIT.usage
|
|
780
|
+
}
|
|
781
|
+
const routingConfig = loadConfig({ cwd: deps.cwd, env: deps.env })
|
|
782
|
+
if (!routingConfig.ask_notifications.value) {
|
|
783
|
+
deps.io.err(
|
|
784
|
+
'Question routing is disabled by ask_notifications=false; enable it or use a blocking `notifai send --reply` question.',
|
|
785
|
+
)
|
|
786
|
+
return EXIT.usage
|
|
787
|
+
}
|
|
788
|
+
if (deps.store.load() === null) {
|
|
789
|
+
deps.io.err(
|
|
790
|
+
'Question routing is not paired on this machine; run `notifai login` before registering an asynchronous question.',
|
|
791
|
+
)
|
|
792
|
+
return EXIT.usage
|
|
793
|
+
}
|
|
794
|
+
// An agent calling this gets no hook payload. Harness-native environment
|
|
795
|
+
// markers identify the active owner, while UserPromptSubmit adds the hook's
|
|
796
|
+
// canonical id to the directory's concurrent-session index.
|
|
797
|
+
const now = (deps.now ?? Date.now)()
|
|
798
|
+
const { active, contested } = resolveActiveHarness(deps.env, deps.cwd, now)
|
|
799
|
+
let sessionId: string | undefined
|
|
800
|
+
if (active !== null) {
|
|
801
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
802
|
+
const activeInstalled = installations.some(
|
|
803
|
+
(installation) => installation.harness === active.harness,
|
|
804
|
+
)
|
|
805
|
+
if (!activeInstalled) {
|
|
806
|
+
for (const line of diagnoseActiveHarnessSession(
|
|
807
|
+
active,
|
|
808
|
+
'not-installed',
|
|
809
|
+
installations,
|
|
810
|
+
contested,
|
|
811
|
+
)) {
|
|
812
|
+
deps.io.err(line)
|
|
813
|
+
}
|
|
814
|
+
return EXIT.usage
|
|
815
|
+
}
|
|
816
|
+
if (active.sessionId === undefined) {
|
|
817
|
+
for (const problem of activeQuestionRouteProblems(deps, active, installations)) {
|
|
818
|
+
deps.io.err(`Question routing is not ready: ${problem}`)
|
|
819
|
+
}
|
|
820
|
+
return EXIT.usage
|
|
821
|
+
}
|
|
822
|
+
const projectPointer = readMatchingProjectSessionPointer(
|
|
823
|
+
deps.cwd,
|
|
824
|
+
deps.env,
|
|
825
|
+
now,
|
|
826
|
+
active.sessionId,
|
|
827
|
+
active.harness,
|
|
828
|
+
)
|
|
829
|
+
if (projectPointer === null) {
|
|
830
|
+
for (const line of diagnoseActiveHarnessSession(
|
|
831
|
+
active,
|
|
832
|
+
'not-fired',
|
|
833
|
+
installations,
|
|
834
|
+
contested,
|
|
835
|
+
)) {
|
|
836
|
+
deps.io.err(line)
|
|
837
|
+
}
|
|
838
|
+
return EXIT.usage
|
|
839
|
+
}
|
|
840
|
+
const routeProblems = activeQuestionRouteProblems(deps, active, installations)
|
|
841
|
+
if (routeProblems.length > 0) {
|
|
842
|
+
for (const problem of routeProblems) deps.io.err(`Question routing is not ready: ${problem}`)
|
|
843
|
+
return EXIT.usage
|
|
844
|
+
}
|
|
845
|
+
if (readSessionState(projectPointer.sessionId, deps.env).last_stop_at === undefined) {
|
|
846
|
+
deps.io.err(
|
|
847
|
+
`Question routing is not ready: this ${active.label} session has fired UserPromptSubmit, but its Stop hook has not been observed. End one harmless turn, send a new prompt, then run \`notifai doctor\`.`,
|
|
848
|
+
)
|
|
849
|
+
return EXIT.usage
|
|
850
|
+
}
|
|
851
|
+
sessionId = projectPointer.sessionId
|
|
852
|
+
} else {
|
|
853
|
+
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
854
|
+
deps.io.err(
|
|
855
|
+
'Could not prove which live harness session owns this command, so Notifai will not register a question that could be delivered to the wrong or already-ended agent. Run it from a supported harness with exact session identity, or use a blocking `notifai send --reply` question.',
|
|
856
|
+
)
|
|
857
|
+
return EXIT.usage
|
|
858
|
+
}
|
|
859
|
+
if (!sessionId) {
|
|
860
|
+
for (const line of diagnoseMissingSession(deps)) deps.io.err(line)
|
|
861
|
+
return EXIT.usage
|
|
862
|
+
}
|
|
863
|
+
const source = resolveDraftInvocation(deps, flags, active)
|
|
864
|
+
if (!source.ok) {
|
|
865
|
+
deps.io.err(source.error)
|
|
866
|
+
return EXIT.usage
|
|
867
|
+
}
|
|
868
|
+
if (source.invocation.source?.session_id !== sessionId) {
|
|
869
|
+
deps.io.err(
|
|
870
|
+
`Question routing is not ready: --session-id or NOTIFAI_SESSION_ID does not match the exact active ${active.label} session; refusing cross-session routing.`,
|
|
871
|
+
)
|
|
872
|
+
return EXIT.usage
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Placeholders let every body, source, project, media, and payload limit fail
|
|
876
|
+
// before an upload starts. The real ids replace them only after this passes.
|
|
877
|
+
const placeholders = (flags.image ?? []).map((_, index) => `med_pending_${index + 1}`)
|
|
878
|
+
const preflight = buildAskDraft(routingConfig, built, flags, source.invocation, placeholders)
|
|
879
|
+
if (!preflight.ok) {
|
|
880
|
+
deps.io.err(preflight.error)
|
|
881
|
+
return EXIT.usage
|
|
882
|
+
}
|
|
883
|
+
if ((flags.image?.length ?? 0) > 0) {
|
|
884
|
+
return uploadAskMedia(
|
|
885
|
+
deps,
|
|
886
|
+
routingConfig,
|
|
887
|
+
sessionId,
|
|
888
|
+
built,
|
|
889
|
+
flags,
|
|
890
|
+
source.invocation,
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
return recordRegisteredQuestion(deps, sessionId, built, preflight.draft, flags.json === true)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* One fail-closed admission gate shared by every active harness route.
|
|
899
|
+
* Finding a file is not readiness: the exact session, one current definition,
|
|
900
|
+
* the stable adapter, a long enough Stop owner, and (for Codex) trust must all
|
|
901
|
+
* be true before `ask` is allowed to create an answerable notification.
|
|
902
|
+
*/
|
|
903
|
+
export function activeQuestionRouteProblems(
|
|
904
|
+
deps: CommandDeps,
|
|
905
|
+
active: ActiveHarnessSession,
|
|
906
|
+
installations: Installation[],
|
|
907
|
+
): string[] {
|
|
908
|
+
const problems: string[] = []
|
|
909
|
+
if (active.sessionId === undefined) {
|
|
910
|
+
problems.push(
|
|
911
|
+
`the active ${active.label} shell does not expose an exact session id; a project-level last-writer pointer can cross-wire two sessions. Use a blocking \`notifai send --reply\` question`,
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
const capability = HARNESS_CAPABILITIES[active.harness]
|
|
915
|
+
if (capability.stopContinuation === 'unsupported') {
|
|
916
|
+
problems.push(`${active.label}: ${capability.deliveryContract}`)
|
|
917
|
+
}
|
|
918
|
+
const matching = installations.filter(
|
|
919
|
+
(installation) => installation.harness === active.harness,
|
|
920
|
+
)
|
|
921
|
+
if (matching.length > 1) {
|
|
922
|
+
problems.push(
|
|
923
|
+
`${matching.length} ${active.label} definitions are active (${matching.map((entry) => entry.file).join(', ')}); keep either project or global routing`,
|
|
924
|
+
)
|
|
925
|
+
}
|
|
926
|
+
for (const installation of matching) {
|
|
927
|
+
for (const problem of installation.problems ?? []) {
|
|
928
|
+
problems.push(`${installation.file}: ${problem}`)
|
|
929
|
+
}
|
|
930
|
+
for (const handler of installation.handlers) {
|
|
931
|
+
const event = handlerEvent(handler.command)
|
|
932
|
+
if (event !== null && !(HOOK_EVENTS as readonly string[]).includes(event)) {
|
|
933
|
+
problems.push(
|
|
934
|
+
`${handler.event} in ${installation.file} names the unsupported event ${event}; reinstall the ${active.label} hooks`,
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
for (const problem of inspectHookAdapter(deps.hookAdapterHome, deps.hookPlatform).problems) {
|
|
940
|
+
problems.push(problem)
|
|
941
|
+
}
|
|
942
|
+
for (const installation of matching) problems.push(...stopShapeProblems(installation, deps.hookPlatform))
|
|
943
|
+
problems.push(...codexTrustProblems(matching, deps.env))
|
|
944
|
+
return [...new Set(problems)]
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
type ActiveHarnessProblem = 'not-installed' | 'not-fired' | 'different-session'
|
|
948
|
+
|
|
949
|
+
function diagnoseActiveHarnessSession(
|
|
950
|
+
active: ActiveHarnessSession,
|
|
951
|
+
problem: ActiveHarnessProblem,
|
|
952
|
+
installations: Installation[],
|
|
953
|
+
contested: ActiveHarnessSession[] = [],
|
|
954
|
+
): string[] {
|
|
955
|
+
// Naming one harness confidently is wrong when the environment carries the
|
|
956
|
+
// markers of several and none of them has fired here: whichever one the
|
|
957
|
+
// agent is told to prompt may not be the one it is running in.
|
|
958
|
+
const ambiguity =
|
|
959
|
+
contested.length > 1
|
|
960
|
+
? [
|
|
961
|
+
`Several harness markers are present here (${contested.map((candidate) => candidate.label).join(', ')}) and none names a session that has fired in this directory, so ${active.label} is a guess from the environment. Send the prompt in the harness you are actually running.`,
|
|
962
|
+
]
|
|
963
|
+
: []
|
|
964
|
+
if (problem === 'not-installed') {
|
|
965
|
+
const others = installations.map((installation) => installation.harness)
|
|
966
|
+
return [
|
|
967
|
+
`Could not register the question for the active ${active.label} session: Notifai ${active.label} hooks are not installed for this project.`,
|
|
968
|
+
...ambiguity,
|
|
969
|
+
...(others.length === 0
|
|
970
|
+
? []
|
|
971
|
+
: [
|
|
972
|
+
`Hooks installed for ${[...new Set(others)].join(', ')} do not route an active ${active.label} session.`,
|
|
973
|
+
]),
|
|
974
|
+
`Run \`notifai hooks install --harness ${active.harness}\`, then send one ${active.label} prompt and run \`notifai doctor\`.`,
|
|
975
|
+
`Retry \`notifai ask\` only after doctor reports that the ${active.label} hooks fired.`,
|
|
976
|
+
]
|
|
977
|
+
}
|
|
978
|
+
if (problem === 'different-session') {
|
|
979
|
+
return [
|
|
980
|
+
`Could not register the question for the active ${active.label} session: the project pointer belongs to another ${active.label} session or harness.`,
|
|
981
|
+
...ambiguity,
|
|
982
|
+
`Refusing to guess or cross-wire the question. Send one prompt in this ${active.label} session, then run \`notifai doctor\`.`,
|
|
983
|
+
`Retry \`notifai ask\` only after doctor reports that this active ${active.label} session fired the hooks.`,
|
|
984
|
+
]
|
|
985
|
+
}
|
|
986
|
+
return [
|
|
987
|
+
`Could not register the question for the active ${active.label} session: Notifai hooks are installed, but this session has not published its pointer.`,
|
|
988
|
+
...ambiguity,
|
|
989
|
+
`Send one ${active.label} prompt, then run \`notifai doctor\`.`,
|
|
990
|
+
`Retry \`notifai ask\` only after doctor reports that the active ${active.label} session fired the hooks.`,
|
|
991
|
+
]
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Why `ask` cannot see a session, in terms of what to do about it.
|
|
996
|
+
*
|
|
997
|
+
* Only a UserPromptSubmit hook firing produces the pointer this reads, and the
|
|
998
|
+
* old message answered every cause with "run `notifai hooks install` and send
|
|
999
|
+
* one prompt". The useful next action depends on the harness: some reload
|
|
1000
|
+
* project hook files, OpenCode loads its plugin at startup, and Codex should be
|
|
1001
|
+
* checked after a prompt before assuming that a new session is required.
|
|
1002
|
+
*/
|
|
1003
|
+
function diagnoseMissingSession(deps: CommandDeps): string[] {
|
|
1004
|
+
const installations = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform)
|
|
1005
|
+
if (installations.length === 0) {
|
|
1006
|
+
return [
|
|
1007
|
+
'Could not tell which harness session this is: no Notifai hooks are installed for this project.',
|
|
1008
|
+
'Run `notifai hooks install`, then follow the activation instruction it prints.',
|
|
1009
|
+
]
|
|
1010
|
+
}
|
|
1011
|
+
const where = installations.map((i) => `${i.harness} in ${i.file}`).join(', ')
|
|
1012
|
+
return [
|
|
1013
|
+
`Could not tell which harness session this is. Notifai hooks are installed (${where}),`,
|
|
1014
|
+
'but no usable session pointer from the last 24 hours exists here.',
|
|
1015
|
+
hookActivationAdvice(installations),
|
|
1016
|
+
'Do not bypass this with a guessed session id; run from the exact active harness session or use a blocking `notifai send --reply` question.',
|
|
1017
|
+
]
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** The least disruptive verified way to make each installed adapter run once. */
|
|
1021
|
+
export function hookActivationAdvice(installations: Installation[]): string {
|
|
1022
|
+
const harnesses = new Set(installations.map((installation) => installation.harness))
|
|
1023
|
+
const advice: string[] = []
|
|
1024
|
+
if (
|
|
1025
|
+
installations.some(
|
|
1026
|
+
(installation) => installation.harness === 'claude-code' && !installation.global,
|
|
1027
|
+
)
|
|
1028
|
+
) {
|
|
1029
|
+
advice.push('Claude Code: send one new prompt; project hook files reload without a restart')
|
|
1030
|
+
}
|
|
1031
|
+
if (
|
|
1032
|
+
installations.some(
|
|
1033
|
+
(installation) => installation.harness === 'claude-code' && installation.global,
|
|
1034
|
+
)
|
|
1035
|
+
) {
|
|
1036
|
+
advice.push('Claude Code global hooks: send one prompt; start a new session only if it does not fire')
|
|
1037
|
+
}
|
|
1038
|
+
if (
|
|
1039
|
+
installations.some(
|
|
1040
|
+
(installation) => installation.harness === 'cursor' && !installation.global,
|
|
1041
|
+
)
|
|
1042
|
+
) {
|
|
1043
|
+
advice.push(
|
|
1044
|
+
'Cursor: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired',
|
|
1045
|
+
)
|
|
1046
|
+
}
|
|
1047
|
+
if (
|
|
1048
|
+
installations.some(
|
|
1049
|
+
(installation) => installation.harness === 'cursor' && installation.global,
|
|
1050
|
+
)
|
|
1051
|
+
) {
|
|
1052
|
+
advice.push('Cursor global hooks: send one prompt; start a new session only if it does not fire')
|
|
1053
|
+
}
|
|
1054
|
+
if (harnesses.has('codex')) {
|
|
1055
|
+
advice.push(
|
|
1056
|
+
'Codex: send one prompt, then run `notifai doctor`; start a new session only if it still has not fired',
|
|
1057
|
+
)
|
|
1058
|
+
}
|
|
1059
|
+
if (harnesses.has('opencode')) {
|
|
1060
|
+
advice.push(
|
|
1061
|
+
'OpenCode: restart it, then send one prompt; plugins load at startup, but non-blocking question continuation is intentionally unsupported',
|
|
1062
|
+
)
|
|
1063
|
+
}
|
|
1064
|
+
return `${advice.join('. ')}.`
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Record the one Agent Acknowledgement associated with a replied-to request.
|
|
1069
|
+
*
|
|
1070
|
+
* `--text` is optional here because the acknowledgement is not: an account may
|
|
1071
|
+
* turn the agent's written reply off, and the receipt must still be recorded so
|
|
1072
|
+
* the user sees that an agent read the answer. The service holds the account's
|
|
1073
|
+
* snapshot, so it — not this process — decides whether text was owed.
|
|
1074
|
+
*/
|
|
1075
|
+
export async function acknowledgeCommand(
|
|
1076
|
+
deps: CommandDeps,
|
|
1077
|
+
requestId: string,
|
|
1078
|
+
flags: { text?: string; json?: boolean },
|
|
1079
|
+
): Promise<number> {
|
|
1080
|
+
const text = flags.text?.trim() ?? ''
|
|
1081
|
+
if (flags.text !== undefined && text.length === 0) {
|
|
1082
|
+
deps.io.err('--text must contain non-whitespace text. Drop it to acknowledge without text.')
|
|
1083
|
+
return EXIT.usage
|
|
1084
|
+
}
|
|
1085
|
+
if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
|
|
1086
|
+
deps.io.err(
|
|
1087
|
+
`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming. Shorten it: an acknowledgement is a receipt, not a report.`,
|
|
1088
|
+
)
|
|
1089
|
+
return EXIT.usage
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const logger = log(deps)
|
|
1093
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1094
|
+
logger.info('acknowledgement.attempted', {
|
|
1095
|
+
request_id: requestId,
|
|
1096
|
+
text,
|
|
1097
|
+
characters: text.length,
|
|
1098
|
+
})
|
|
1099
|
+
const authed = authedClient(deps, config)
|
|
1100
|
+
if (!authed) return EXIT.auth
|
|
1101
|
+
try {
|
|
1102
|
+
const result = await authed.client.putAgentAcknowledgement(
|
|
1103
|
+
requestId,
|
|
1104
|
+
text.length > 0 ? { text } : {},
|
|
1105
|
+
)
|
|
1106
|
+
const output = {
|
|
1107
|
+
request_id: requestId,
|
|
1108
|
+
outcome: result.status,
|
|
1109
|
+
acknowledgement: result.agent_acknowledgement,
|
|
1110
|
+
agent_acknowledgement_required: true,
|
|
1111
|
+
}
|
|
1112
|
+
logger.info('acknowledgement.outcome', {
|
|
1113
|
+
request_id: requestId,
|
|
1114
|
+
outcome: result.status,
|
|
1115
|
+
text: result.agent_acknowledgement.text,
|
|
1116
|
+
created_at: result.agent_acknowledgement.created_at,
|
|
1117
|
+
agent_acknowledgement_required: true,
|
|
1118
|
+
})
|
|
1119
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
1120
|
+
if (sessionId !== null) {
|
|
1121
|
+
clearAcknowledgementObligation(sessionId, deps.env, requestId)
|
|
1122
|
+
}
|
|
1123
|
+
if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
|
|
1124
|
+
else {
|
|
1125
|
+
deps.io.out(
|
|
1126
|
+
`Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`,
|
|
1127
|
+
)
|
|
1128
|
+
}
|
|
1129
|
+
return EXIT.ok
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId })
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
1136
|
+
export async function closeCommand(
|
|
1137
|
+
deps: CommandDeps,
|
|
1138
|
+
requestId: string,
|
|
1139
|
+
flags: { json?: boolean } = {},
|
|
1140
|
+
): Promise<number> {
|
|
1141
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
1142
|
+
const authed = authedClient(deps, config)
|
|
1143
|
+
if (!authed) return EXIT.auth
|
|
1144
|
+
try {
|
|
1145
|
+
const response = await authed.client.closeReplies(requestId)
|
|
1146
|
+
if (flags.json) {
|
|
1147
|
+
deps.io.out(
|
|
1148
|
+
JSON.stringify(
|
|
1149
|
+
{
|
|
1150
|
+
...response,
|
|
1151
|
+
acknowledgement_command: acknowledgementCommand(
|
|
1152
|
+
response.request_id,
|
|
1153
|
+
response.agent_acknowledgement_required,
|
|
1154
|
+
response.agent_acknowledgement_text_required,
|
|
1155
|
+
response.agent_acknowledgement,
|
|
1156
|
+
response.replies.length > 0,
|
|
1157
|
+
),
|
|
1158
|
+
},
|
|
1159
|
+
null,
|
|
1160
|
+
2,
|
|
1161
|
+
),
|
|
1162
|
+
)
|
|
1163
|
+
} else {
|
|
1164
|
+
deps.io.out(`Closed the reply window for ${requestId}.`)
|
|
1165
|
+
if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
|
|
1166
|
+
}
|
|
1167
|
+
return EXIT.ok
|
|
1168
|
+
} catch (err) {
|
|
1169
|
+
return reportError(deps, err)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
export interface HooksInstallFlags {
|
|
1174
|
+
harness?: string
|
|
1175
|
+
global?: boolean
|
|
1176
|
+
/** Test seam; production resolves the running CLI. */
|
|
1177
|
+
execPath?: string
|
|
1178
|
+
scriptPath?: string
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** True when this process is `npx` / `npm exec`, not a global or linked install. */
|
|
1182
|
+
export function runningViaNpx(env: NodeJS.ProcessEnv, scriptPath: string): boolean {
|
|
1183
|
+
if (env['npm_command'] === 'exec') return true
|
|
1184
|
+
return scriptPath.includes(`${path.sep}_npx${path.sep}`)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function fileHookInstallTarget(
|
|
1188
|
+
target: HookAdapterTarget | undefined,
|
|
1189
|
+
): { execPath: string; scriptPath: string } | undefined {
|
|
1190
|
+
if (target === undefined || isNpxAdapterTarget(target)) return undefined
|
|
1191
|
+
return target
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function resolveHookAdapterTarget(deps: CommandDeps, flags: HooksInstallFlags): HookAdapterTarget {
|
|
1195
|
+
if (deps.hookInstallTarget !== undefined && isNpxAdapterTarget(deps.hookInstallTarget)) {
|
|
1196
|
+
return deps.hookInstallTarget
|
|
1197
|
+
}
|
|
1198
|
+
const fileTarget = fileHookInstallTarget(deps.hookInstallTarget)
|
|
1199
|
+
const execPath = flags.execPath ?? fileTarget?.execPath ?? process.execPath
|
|
1200
|
+
const scriptPath = flags.scriptPath ?? fileTarget?.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1201
|
+
if (runningViaNpx(deps.env, scriptPath)) {
|
|
1202
|
+
const version = packageVersion()
|
|
1203
|
+
const npmCli = deps.env['npm_execpath']
|
|
1204
|
+
if (version === null) {
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
'Could not read this CLI version, so an npx hook target cannot be pinned. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
|
|
1207
|
+
)
|
|
1208
|
+
}
|
|
1209
|
+
if (typeof npmCli !== 'string' || npmCli === '') {
|
|
1210
|
+
throw new Error(
|
|
1211
|
+
'This process looks like npx but npm_execpath is missing. Install `@raidiant/notifai` globally and rerun `notifai hooks install`.',
|
|
1212
|
+
)
|
|
1213
|
+
}
|
|
1214
|
+
return { kind: 'npx', execPath, npmCli, spec: `@raidiant/notifai@${version}` }
|
|
1215
|
+
}
|
|
1216
|
+
return { execPath, scriptPath }
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function printHooksInstallClose(deps: CommandDeps, harness: Harness, file: string): void {
|
|
1220
|
+
const label = HARNESS_LABELS[harness]
|
|
1221
|
+
if (deps.io.interactive === true && deps.io.note) {
|
|
1222
|
+
void deps.io.note(`${file}\nSend one ${label} prompt, then run \`notifai doctor\`.`, `${label} hooks installed`)
|
|
1223
|
+
return
|
|
1224
|
+
}
|
|
1225
|
+
deps.io.out(`Installed ${harness} hooks in ${file}`)
|
|
1226
|
+
deps.io.out(`Send one ${label} prompt, then check \`notifai doctor\`.`)
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
export function hooksInstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
|
|
1230
|
+
if (flags.harness === undefined) {
|
|
1231
|
+
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
1232
|
+
if (detected.length === 0) {
|
|
1233
|
+
deps.io.err(`Could not tell which harness you mean — pass --harness <${HARNESSES.join('|')}>.`)
|
|
1234
|
+
return EXIT.usage
|
|
1235
|
+
}
|
|
1236
|
+
let ok = true
|
|
1237
|
+
for (const harness of detected) {
|
|
1238
|
+
if (hooksInstallCommand(deps, { ...flags, harness }) !== EXIT.ok) ok = false
|
|
1239
|
+
}
|
|
1240
|
+
return ok ? EXIT.ok : EXIT.failed
|
|
1241
|
+
}
|
|
1242
|
+
const harness = resolveHarness(deps, flags.harness)
|
|
1243
|
+
if (!harness) return EXIT.usage
|
|
1244
|
+
const adapterTarget = resolveHookAdapterTarget(deps, flags)
|
|
1245
|
+
const scriptPath =
|
|
1246
|
+
flags.scriptPath ?? fileHookInstallTarget(adapterTarget)?.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1247
|
+
const hookPlatform = deps.hookPlatform ?? process.platform
|
|
1248
|
+
const nodePath = adapterTarget.execPath
|
|
1249
|
+
let adapterPath: string
|
|
1250
|
+
try {
|
|
1251
|
+
adapterPath = installHookAdapter(adapterTarget, deps.hookAdapterHome, hookPlatform).path
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
deps.io.err(`Could not prepare the stable hook adapter: ${String(err)}`)
|
|
1254
|
+
return EXIT.failed
|
|
1255
|
+
}
|
|
1256
|
+
const wantGlobal = flags.global === true
|
|
1257
|
+
const existing = findInstallations(deps.cwd, deps.env, deps.hookAdapterHome, deps.hookPlatform).filter(
|
|
1258
|
+
(installation) => installation.harness === harness,
|
|
1259
|
+
)
|
|
1260
|
+
const otherScope = existing.filter((installation) => installation.global !== wantGlobal)
|
|
1261
|
+
if (!wantGlobal && otherScope.some((installation) => installation.global)) {
|
|
1262
|
+
const globalFile = otherScope.find((installation) => installation.global)?.file
|
|
1263
|
+
deps.io.out(
|
|
1264
|
+
`${HARNESS_LABELS[harness]} hooks already cover this machine (${globalFile}). This project does not need its own copy. To wire only this project: notifai hooks uninstall --harness ${harness} --global && notifai hooks install --harness ${harness}`,
|
|
1265
|
+
)
|
|
1266
|
+
return EXIT.ok
|
|
1267
|
+
}
|
|
1268
|
+
if (wantGlobal && otherScope.some((installation) => !installation.global)) {
|
|
1269
|
+
if (hooksUninstallCommand(deps, { ...flags, global: false, harness }) !== EXIT.ok) {
|
|
1270
|
+
return EXIT.failed
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const codexPaths =
|
|
1274
|
+
harness === 'codex'
|
|
1275
|
+
? codexLayerPaths(wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
1276
|
+
: null
|
|
1277
|
+
const settingsTarget =
|
|
1278
|
+
codexPaths?.configToml ?? settingsFile(harness, wantGlobal, deps.cwd, deps.env, hookPlatform)
|
|
1279
|
+
|
|
1280
|
+
// OpenCode's adapter is a generated plugin module rather than a handler
|
|
1281
|
+
// merged into a settings document, so it owns the whole file.
|
|
1282
|
+
if (harness === 'opencode') {
|
|
1283
|
+
return installOpencodePlugin(deps, settingsTarget, {
|
|
1284
|
+
adapterPath,
|
|
1285
|
+
timeoutSeconds: BLOCKING_STOP_TIMEOUT_SECONDS,
|
|
1286
|
+
platform: hookPlatform,
|
|
1287
|
+
nodePath,
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (harness === 'cursor') {
|
|
1292
|
+
try {
|
|
1293
|
+
withTargetFileLock(settingsTarget, () => {
|
|
1294
|
+
const document = loadCursorSettings(settingsTarget)
|
|
1295
|
+
const result = mergeCursorHooks(
|
|
1296
|
+
document,
|
|
1297
|
+
buildCursorHookConfig({
|
|
1298
|
+
adapterPath,
|
|
1299
|
+
harness: 'cursor',
|
|
1300
|
+
platform: hookPlatform,
|
|
1301
|
+
nodePath,
|
|
1302
|
+
}),
|
|
1303
|
+
scriptPath,
|
|
1304
|
+
)
|
|
1305
|
+
applyPlan(settingsTarget, result.document)
|
|
1306
|
+
return result
|
|
1307
|
+
})
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
deps.io.err(String(err))
|
|
1310
|
+
return EXIT.failed
|
|
1311
|
+
}
|
|
1312
|
+
printHooksInstallClose(deps, harness, settingsTarget)
|
|
1313
|
+
return EXIT.ok
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
const installInto = (file: string): { file: string; foreignStopCount: number } => {
|
|
1317
|
+
const document = loadSettings(file)
|
|
1318
|
+
const foreignStopCount = foreignStopHandlers(document).length
|
|
1319
|
+
const result = mergeHooks(
|
|
1320
|
+
document,
|
|
1321
|
+
buildHookConfig({
|
|
1322
|
+
adapterPath,
|
|
1323
|
+
harness,
|
|
1324
|
+
platform: hookPlatform,
|
|
1325
|
+
nodePath,
|
|
1326
|
+
}),
|
|
1327
|
+
scriptPath,
|
|
1328
|
+
)
|
|
1329
|
+
applyPlan(file, result.document)
|
|
1330
|
+
return { file, foreignStopCount }
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
let installed: { file: string; foreignStopCount: number }
|
|
1334
|
+
try {
|
|
1335
|
+
installed =
|
|
1336
|
+
codexPaths === null
|
|
1337
|
+
? withTargetFileLock(settingsTarget, () => installInto(settingsTarget))
|
|
1338
|
+
: withCodexLayerTransaction(codexPaths, (inspection) => installInto(inspection.writeTarget))
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
deps.io.err(String(err))
|
|
1341
|
+
return EXIT.failed
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (harness === 'codex') {
|
|
1345
|
+
const layer = flags.global ? null : codexLayerDir(deps.cwd)
|
|
1346
|
+
if (layer !== null) mkdirSync(layer, { recursive: true })
|
|
1347
|
+
}
|
|
1348
|
+
printHooksInstallClose(deps, harness, installed.file)
|
|
1349
|
+
if (installed.foreignStopCount > 0) {
|
|
1350
|
+
deps.io.out(
|
|
1351
|
+
"This layer already has a Stop handler. Codex runs every matching handler, so Notifai's Stop and the existing one will both fire.",
|
|
1352
|
+
)
|
|
1353
|
+
}
|
|
1354
|
+
if (harness === 'codex') {
|
|
1355
|
+
for (const problem of codexRepresentationProblems(deps.cwd, deps.env, hookPlatform)) {
|
|
1356
|
+
deps.io.out(problem)
|
|
1357
|
+
}
|
|
1358
|
+
for (const note of codexCoexistenceNotes(deps.cwd, deps.env, hookPlatform)) {
|
|
1359
|
+
deps.io.out(note)
|
|
1360
|
+
}
|
|
1361
|
+
if (flags.global) {
|
|
1362
|
+
const home = codexHomeNote(deps.env, hookPlatform)
|
|
1363
|
+
if (home !== null) deps.io.out(home)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return EXIT.ok
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function foreignStopHandlers(document: { hooks?: Record<string, { hooks?: { command: string }[] }[]> }): { command: string }[] {
|
|
1370
|
+
const groups = document.hooks?.['Stop']
|
|
1371
|
+
if (!Array.isArray(groups)) return []
|
|
1372
|
+
return groups
|
|
1373
|
+
.flatMap((group) => group.hooks ?? [])
|
|
1374
|
+
.filter((handler) => !/ hook (user-prompt-submit|stop|session-end)\b/.test(handler.command))
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Writes the OpenCode plugin, replacing any Notifai plugin already there —
|
|
1379
|
+
* including one a different checkout wrote, matched on the managed marker for
|
|
1380
|
+
* the same reason command hooks are.
|
|
1381
|
+
*/
|
|
1382
|
+
function installOpencodePlugin(
|
|
1383
|
+
deps: CommandDeps,
|
|
1384
|
+
file: string,
|
|
1385
|
+
options: {
|
|
1386
|
+
adapterPath: string
|
|
1387
|
+
timeoutSeconds: number
|
|
1388
|
+
platform?: NodeJS.Platform
|
|
1389
|
+
nodePath?: string
|
|
1390
|
+
},
|
|
1391
|
+
): number {
|
|
1392
|
+
try {
|
|
1393
|
+
withTargetFileLock(file, () => {
|
|
1394
|
+
if (existsSync(file)) {
|
|
1395
|
+
assertOwnedRegularFile(file)
|
|
1396
|
+
const existing = readFileSync(file, 'utf8')
|
|
1397
|
+
if (!isOurOpencodePlugin(existing)) {
|
|
1398
|
+
throw new Error(`${file} exists and was not written by Notifai; move it aside first.`)
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
atomicWriteFileSync(file, opencodePluginSource(options), {
|
|
1402
|
+
mode: 0o600,
|
|
1403
|
+
preserveMode: false,
|
|
1404
|
+
requireCurrentUserOwner: true,
|
|
1405
|
+
})
|
|
1406
|
+
})
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
deps.io.err(String(err))
|
|
1409
|
+
return EXIT.failed
|
|
1410
|
+
}
|
|
1411
|
+
printHooksInstallClose(deps, 'opencode', file)
|
|
1412
|
+
return EXIT.ok
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
export function hooksUninstallCommand(deps: CommandDeps, flags: HooksInstallFlags): number {
|
|
1416
|
+
const harness = resolveHarness(deps, flags.harness)
|
|
1417
|
+
if (!harness) return EXIT.usage
|
|
1418
|
+
const scriptPath = flags.scriptPath ?? process.argv[1] ?? 'notifai'
|
|
1419
|
+
const global = flags.global ?? false
|
|
1420
|
+
const codexPaths =
|
|
1421
|
+
harness === 'codex'
|
|
1422
|
+
? codexLayerPaths(global, deps.cwd, deps.env, deps.hookPlatform)
|
|
1423
|
+
: null
|
|
1424
|
+
const file =
|
|
1425
|
+
codexPaths?.configToml ?? settingsFile(harness, global, deps.cwd, deps.env, deps.hookPlatform)
|
|
1426
|
+
if (harness === 'opencode') {
|
|
1427
|
+
try {
|
|
1428
|
+
return withTargetFileLock(file, () => {
|
|
1429
|
+
if (!existsSync(file)) {
|
|
1430
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
1431
|
+
return EXIT.ok
|
|
1432
|
+
}
|
|
1433
|
+
assertOwnedRegularFile(file)
|
|
1434
|
+
// We own the whole file, but only if we wrote it.
|
|
1435
|
+
if (!isOurOpencodePlugin(readFileSync(file, 'utf8'))) {
|
|
1436
|
+
deps.io.out(`Left ${file} alone: Notifai did not write it.`)
|
|
1437
|
+
return EXIT.ok
|
|
1438
|
+
}
|
|
1439
|
+
rmSync(file, { force: true })
|
|
1440
|
+
deps.io.out(`Removed the Notifai OpenCode plugin at ${file}`)
|
|
1441
|
+
return EXIT.ok
|
|
1442
|
+
})
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
deps.io.err(String(err))
|
|
1445
|
+
return EXIT.failed
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if (harness === 'cursor') {
|
|
1449
|
+
let stripped: ReturnType<typeof removeCursorHooks> | null
|
|
1450
|
+
try {
|
|
1451
|
+
stripped = withTargetFileLock(file, () => {
|
|
1452
|
+
if (!existsSync(file)) return null
|
|
1453
|
+
const document = loadCursorSettings(file)
|
|
1454
|
+
const result = removeCursorHooks(document, scriptPath)
|
|
1455
|
+
applyPlan(file, result.document)
|
|
1456
|
+
return result
|
|
1457
|
+
})
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
deps.io.err(String(err))
|
|
1460
|
+
return EXIT.failed
|
|
1461
|
+
}
|
|
1462
|
+
if (stripped === null) {
|
|
1463
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
1464
|
+
return EXIT.ok
|
|
1465
|
+
}
|
|
1466
|
+
deps.io.out(
|
|
1467
|
+
stripped.replaced.length > 0
|
|
1468
|
+
? `Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${file}`
|
|
1469
|
+
: `No Notifai hooks found in ${file}`,
|
|
1470
|
+
)
|
|
1471
|
+
return EXIT.ok
|
|
1472
|
+
}
|
|
1473
|
+
const removeInstalledHooks = (): { existing: string[]; removedAny: boolean } => {
|
|
1474
|
+
const files =
|
|
1475
|
+
codexPaths === null
|
|
1476
|
+
? hookDefinitionFiles(harness, global, deps.cwd, deps.env, deps.hookPlatform)
|
|
1477
|
+
: [codexPaths.hooksJson, codexPaths.configToml]
|
|
1478
|
+
const existing = files.filter((candidate) => existsSync(candidate))
|
|
1479
|
+
let removedAny = false
|
|
1480
|
+
for (const candidate of existing) {
|
|
1481
|
+
const removeFromCandidate = () => {
|
|
1482
|
+
const document = loadSettings(candidate)
|
|
1483
|
+
const result = removeHooks(document, scriptPath)
|
|
1484
|
+
if (result.replaced.length > 0) applyPlan(candidate, result.document)
|
|
1485
|
+
return result
|
|
1486
|
+
}
|
|
1487
|
+
const stripped =
|
|
1488
|
+
codexPaths === null
|
|
1489
|
+
? withTargetFileLock(candidate, removeFromCandidate)
|
|
1490
|
+
: removeFromCandidate()
|
|
1491
|
+
if (stripped.replaced.length > 0) {
|
|
1492
|
+
removedAny = true
|
|
1493
|
+
deps.io.out(`Removed Notifai hooks (${stripped.replaced.join(', ')}) from ${candidate}`)
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return { existing, removedAny }
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
let result: { existing: string[]; removedAny: boolean }
|
|
1500
|
+
try {
|
|
1501
|
+
result =
|
|
1502
|
+
codexPaths === null
|
|
1503
|
+
? removeInstalledHooks()
|
|
1504
|
+
: withCodexLayerTransaction(codexPaths, removeInstalledHooks)
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
deps.io.err(String(err))
|
|
1507
|
+
return EXIT.failed
|
|
1508
|
+
}
|
|
1509
|
+
if (result.existing.length === 0) {
|
|
1510
|
+
deps.io.out(`Nothing to remove: ${file} does not exist.`)
|
|
1511
|
+
return EXIT.ok
|
|
1512
|
+
}
|
|
1513
|
+
if (!result.removedAny) {
|
|
1514
|
+
deps.io.out(`No Notifai hooks found in ${result.existing.join(', ')}`)
|
|
1515
|
+
}
|
|
1516
|
+
return EXIT.ok
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
function assertOwnedRegularFile(file: string): void {
|
|
1520
|
+
const stat = lstatSync(file)
|
|
1521
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1522
|
+
throw new Error(`${file} is not a regular file; refusing to read or replace it.`)
|
|
1523
|
+
}
|
|
1524
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
|
1525
|
+
if (uid !== undefined && stat.uid !== uid) {
|
|
1526
|
+
throw new Error(`${file} is owned by uid ${stat.uid}, not the current user.`)
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function resolveHarness(deps: CommandDeps, requested: string | undefined): Harness | null {
|
|
1531
|
+
if (requested !== undefined) {
|
|
1532
|
+
if ((HARNESSES as readonly string[]).includes(requested)) return requested as Harness
|
|
1533
|
+
deps.io.err(
|
|
1534
|
+
`Unknown harness "${requested}". Supported: ${HARNESSES.join(', ')}.`,
|
|
1535
|
+
)
|
|
1536
|
+
return null
|
|
1537
|
+
}
|
|
1538
|
+
const detected = detectHarness(deps.cwd, deps.env)
|
|
1539
|
+
if (!detected) {
|
|
1540
|
+
deps.io.err(`Could not tell which harness to install for — pass --harness <${HARNESSES.join('|')}>.`)
|
|
1541
|
+
return null
|
|
1542
|
+
}
|
|
1543
|
+
return detected
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* Which harnesses to wire. An explicit `--harness` still wins as a singleton.
|
|
1548
|
+
* Otherwise: every detected harness, or a human picker when detection is empty
|
|
1549
|
+
* or names more than one.
|
|
1550
|
+
*/
|
|
1551
|
+
export async function pickHarnessesToInstall(
|
|
1552
|
+
deps: CommandDeps,
|
|
1553
|
+
requested?: string,
|
|
1554
|
+
): Promise<Harness[] | null> {
|
|
1555
|
+
if (requested !== undefined) {
|
|
1556
|
+
const harness = resolveHarness(deps, requested)
|
|
1557
|
+
return harness === null ? null : [harness]
|
|
1558
|
+
}
|
|
1559
|
+
const detected = detectedHarnesses(deps.cwd, deps.env)
|
|
1560
|
+
if (detected.length === 1) return detected
|
|
1561
|
+
if (deps.io.interactive === true && deps.io.multiselect) {
|
|
1562
|
+
const picked = await deps.io.multiselect(
|
|
1563
|
+
'Which agent harnesses should Notifai wire here?',
|
|
1564
|
+
HARNESSES.map((name) => ({
|
|
1565
|
+
value: name,
|
|
1566
|
+
label: HARNESS_LABELS[name],
|
|
1567
|
+
...(detected.includes(name) ? { hint: 'detected on this machine' } : {}),
|
|
1568
|
+
})),
|
|
1569
|
+
detected,
|
|
1570
|
+
)
|
|
1571
|
+
if (picked === null) return null
|
|
1572
|
+
const unknown = picked.filter((name) => !(HARNESSES as readonly string[]).includes(name))
|
|
1573
|
+
if (unknown.length > 0) {
|
|
1574
|
+
deps.io.err(`Unknown harness "${unknown[0]}". Supported: ${HARNESSES.join(', ')}.`)
|
|
1575
|
+
return null
|
|
1576
|
+
}
|
|
1577
|
+
return picked as Harness[]
|
|
1578
|
+
}
|
|
1579
|
+
if (detected.length === 0) {
|
|
1580
|
+
deps.io.err(
|
|
1581
|
+
`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`,
|
|
1582
|
+
)
|
|
1583
|
+
return null
|
|
1584
|
+
}
|
|
1585
|
+
return detected
|
|
1586
|
+
}
|