@raidiant/notifai 10.1.1 → 10.1.3

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.
Files changed (168) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/commands-core.d.ts +4 -1
  3. package/dist/commands-core.js.map +1 -1
  4. package/dist/commands-doctor.js +2 -7
  5. package/dist/commands-doctor.js.map +1 -1
  6. package/dist/commands-hooks.d.ts +12 -0
  7. package/dist/commands-hooks.js +15 -1
  8. package/dist/commands-hooks.js.map +1 -1
  9. package/dist/commands-init.js +9 -6
  10. package/dist/commands-init.js.map +1 -1
  11. package/dist/commands-send-support.js +10 -11
  12. package/dist/commands-send-support.js.map +1 -1
  13. package/dist/commands-skill.js +72 -54
  14. package/dist/commands-skill.js.map +1 -1
  15. package/dist/harness-session-title.d.ts +22 -0
  16. package/dist/harness-session-title.js +74 -0
  17. package/dist/harness-session-title.js.map +1 -0
  18. package/dist/invocation-context.d.ts +1 -0
  19. package/dist/invocation-context.js +15 -1
  20. package/dist/invocation-context.js.map +1 -1
  21. package/dist/native-skills.d.ts +13 -3
  22. package/dist/native-skills.js +51 -12
  23. package/dist/native-skills.js.map +1 -1
  24. package/dist/node-floor.d.ts +2 -1
  25. package/dist/node-floor.js +9 -5
  26. package/dist/node-floor.js.map +1 -1
  27. package/dist/orca-session-title.d.ts +5 -4
  28. package/dist/orca-session-title.js +32 -19
  29. package/dist/orca-session-title.js.map +1 -1
  30. package/dist/platform.d.ts +11 -17
  31. package/dist/platform.js +20 -23
  32. package/dist/platform.js.map +1 -1
  33. package/dist/release.d.ts +5 -5
  34. package/dist/release.js +5 -5
  35. package/dist/session-labels.d.ts +2 -0
  36. package/dist/session-labels.js +20 -9
  37. package/dist/session-labels.js.map +1 -1
  38. package/dist/setup-destinations.js +1 -1
  39. package/dist/setup-destinations.js.map +1 -1
  40. package/dist/skill-integrity.d.ts +49 -0
  41. package/dist/skill-integrity.js +154 -0
  42. package/dist/skill-integrity.js.map +1 -0
  43. package/dist/skill-source/manifest.json +16 -0
  44. package/dist/skill-source/notifai/SKILL.md +390 -0
  45. package/dist/skill-source/notifai/references/harness-setup.md +220 -0
  46. package/package.json +11 -14
  47. package/src/atomic-file.ts +0 -198
  48. package/src/claude-wake.test.ts +0 -321
  49. package/src/claude-wake.ts +0 -499
  50. package/src/cli-bin.test.ts +0 -176
  51. package/src/cli-bin.ts +0 -233
  52. package/src/cli-release.test.ts +0 -46
  53. package/src/cli-release.ts +0 -78
  54. package/src/cli-update-contract.ts +0 -9
  55. package/src/cli-update.test.ts +0 -125
  56. package/src/client.test.ts +0 -390
  57. package/src/client.ts +0 -352
  58. package/src/codex-wake.test.ts +0 -254
  59. package/src/codex-wake.ts +0 -301
  60. package/src/command-session.ts +0 -38
  61. package/src/commands-agent-sessions.ts +0 -72
  62. package/src/commands-auth.ts +0 -238
  63. package/src/commands-config.ts +0 -336
  64. package/src/commands-core.ts +0 -312
  65. package/src/commands-devices.ts +0 -180
  66. package/src/commands-doctor.ts +0 -1501
  67. package/src/commands-guidance.ts +0 -181
  68. package/src/commands-harness-context.ts +0 -207
  69. package/src/commands-hook-shape.ts +0 -69
  70. package/src/commands-hooks.ts +0 -2318
  71. package/src/commands-init.ts +0 -1055
  72. package/src/commands-io.ts +0 -108
  73. package/src/commands-logs.ts +0 -243
  74. package/src/commands-project.ts +0 -47
  75. package/src/commands-send-support.test.ts +0 -156
  76. package/src/commands-send-support.ts +0 -311
  77. package/src/commands-send.ts +0 -875
  78. package/src/commands-setup-proof.test.ts +0 -72
  79. package/src/commands-setup-proof.ts +0 -124
  80. package/src/commands-skill.test.ts +0 -44
  81. package/src/commands-skill.ts +0 -204
  82. package/src/commands-sounds.test.ts +0 -106
  83. package/src/commands-sounds.ts +0 -49
  84. package/src/commands-update.ts +0 -180
  85. package/src/commands.test.ts +0 -8297
  86. package/src/commands.ts +0 -77
  87. package/src/config-schema.test.ts +0 -153
  88. package/src/config-schema.ts +0 -349
  89. package/src/config.test.ts +0 -581
  90. package/src/config.ts +0 -475
  91. package/src/credentials.test.ts +0 -403
  92. package/src/credentials.ts +0 -403
  93. package/src/file-lock-process-worker.test.ts +0 -64
  94. package/src/file-lock.test.ts +0 -320
  95. package/src/file-lock.ts +0 -393
  96. package/src/fixtures/hermes-0.20.6-classic-cli-local.json +0 -50
  97. package/src/fixtures/openclaw-2026.7.1-2-agent-local.json +0 -57
  98. package/src/generated-session-label.ts +0 -155
  99. package/src/guidance-content.ts +0 -278
  100. package/src/guidance-render.ts +0 -56
  101. package/src/guidance.test.ts +0 -327
  102. package/src/guidance.ts +0 -233
  103. package/src/harness-integration.test.ts +0 -294
  104. package/src/harnesses.test.ts +0 -149
  105. package/src/harnesses.ts +0 -164
  106. package/src/hook-adapter.test.ts +0 -496
  107. package/src/hook-adapter.ts +0 -424
  108. package/src/hook-input.test.ts +0 -39
  109. package/src/hook-input.ts +0 -72
  110. package/src/hooks.test.ts +0 -5824
  111. package/src/hooks.ts +0 -4011
  112. package/src/install-hooks.test.ts +0 -1614
  113. package/src/install-hooks.ts +0 -1501
  114. package/src/interactive.test.ts +0 -387
  115. package/src/interactive.ts +0 -858
  116. package/src/invocation-context.test.ts +0 -352
  117. package/src/invocation-context.ts +0 -197
  118. package/src/logging-process-worker.test.ts +0 -45
  119. package/src/logging.test.ts +0 -544
  120. package/src/logging.ts +0 -698
  121. package/src/main-run.ts +0 -45
  122. package/src/main.ts +0 -12
  123. package/src/native-skills.test.ts +0 -115
  124. package/src/native-skills.ts +0 -162
  125. package/src/node-floor.test.ts +0 -27
  126. package/src/node-floor.ts +0 -37
  127. package/src/onboarding-docs.test.ts +0 -25
  128. package/src/openclaw-plugin.ts +0 -409
  129. package/src/opencode-plugin.ts +0 -298
  130. package/src/orca-session-title.test.ts +0 -54
  131. package/src/orca-session-title.ts +0 -103
  132. package/src/pack-feedback-slice.test.ts +0 -212
  133. package/src/pack-feedback-slice.ts +0 -187
  134. package/src/platform.test.ts +0 -152
  135. package/src/platform.ts +0 -173
  136. package/src/program.test.ts +0 -317
  137. package/src/program.ts +0 -842
  138. package/src/project-enablement.test.ts +0 -55
  139. package/src/project-enablement.ts +0 -99
  140. package/src/question-caps.test.ts +0 -49
  141. package/src/question-settlement-process.ts +0 -47
  142. package/src/question-timing.ts +0 -56
  143. package/src/readiness.test.ts +0 -25
  144. package/src/readiness.ts +0 -212
  145. package/src/release.ts +0 -51
  146. package/src/send-attempts.test.ts +0 -143
  147. package/src/send-attempts.ts +0 -188
  148. package/src/send.test.ts +0 -28
  149. package/src/send.ts +0 -436
  150. package/src/session-activation.ts +0 -61
  151. package/src/session-end-log-lock-worker.test.ts +0 -35
  152. package/src/session-labels.test.ts +0 -490
  153. package/src/session-labels.ts +0 -474
  154. package/src/setup-destinations.test.ts +0 -41
  155. package/src/setup-destinations.ts +0 -50
  156. package/src/skill.test.ts +0 -426
  157. package/src/sound-ref.test.ts +0 -31
  158. package/src/sound-ref.ts +0 -34
  159. package/src/trust-policy.test.ts +0 -701
  160. package/src/ui/banner.ts +0 -95
  161. package/src/ui/config-view.test.ts +0 -90
  162. package/src/ui/config-view.ts +0 -138
  163. package/src/ui/help.test.ts +0 -16
  164. package/src/ui/help.ts +0 -128
  165. package/src/ui/theme.test.ts +0 -92
  166. package/src/ui/theme.ts +0 -265
  167. package/src/url-policy.ts +0 -399
  168. package/tsconfig.json +0 -10
@@ -1,298 +0,0 @@
1
- import path from 'node:path'
2
- import { hookHostPlatform, type HookHostPlatform } from './hook-adapter.js'
3
- import { configHome } from './install-hooks.js'
4
- import {
5
- MISSING_LIFECYCLE_GUIDANCE_CONTEXT,
6
- WORKER_ACTIVATION_CONTEXT,
7
- } from './session-activation.js'
8
-
9
- /**
10
- * The OpenCode adapter.
11
- *
12
- * OpenCode's extension point is a TypeScript/JavaScript plugin module, not a
13
- * command hook, so it needs a different adapter rather than a different path
14
- * through the existing one. What it is *not* is different logic: the generated
15
- * plugin shells out to the same `notifai hook <event>` commands Claude Code and
16
- * Codex invoke, and every decision — presence, the grace window, the reply
17
- * wait, retirement — stays in one place.
18
- *
19
- * The three lifecycle joints map onto the same three events. OpenCode exposes
20
- * `chat.message` as a direct hook, while session lifecycle notifications arrive
21
- * through its generic `event` hook:
22
- *
23
- * chat.message -> user-prompt-submit (the user is at the keyboard)
24
- * session.idle -> stop (the turn ended; escalate)
25
- * session.deleted -> session-end (retire local session state)
26
- *
27
- * Permission prompts deliberately stay in OpenCode. The CLI question flow
28
- * only escalates questions explicitly registered with `notifai ask`.
29
- */
30
-
31
- /** Marker line; also how an install from another checkout is recognised. */
32
- export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
33
-
34
- /** Filename we own inside whichever plugins directory is being written. */
35
- export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
36
-
37
- /** Bump when an installed generated file must be rewritten to remain functional. */
38
- const OPENCODE_ADAPTER_VERSION = 12
39
-
40
- export function opencodePluginDir(
41
- global: boolean,
42
- cwd: string,
43
- env: NodeJS.ProcessEnv = process.env,
44
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
45
- ): string {
46
- return global
47
- ? path.join(configHome(env, 'OPENCODE_CONFIG_DIR', '.config/opencode', platform), 'plugins')
48
- : path.join(cwd, '.opencode', 'plugins')
49
- }
50
-
51
- export function opencodePluginPath(
52
- global: boolean,
53
- cwd: string,
54
- env: NodeJS.ProcessEnv = process.env,
55
- platform: NodeJS.Platform | HookHostPlatform = process.platform,
56
- ): string {
57
- return path.join(opencodePluginDir(global, cwd, env, platform), OPENCODE_PLUGIN_FILENAME)
58
- }
59
-
60
- export interface OpencodePluginOptions {
61
- /** The same stable user-level adapter every command-hook harness invokes. */
62
- adapterPath: string
63
- /** Child-process lifetime; covers grace, reply wait, and teardown headroom. */
64
- timeoutSeconds: number
65
- platform?: NodeJS.Platform | HookHostPlatform
66
- /** Registered Node used to interpret the Windows adapter. Ignored on POSIX. */
67
- nodePath?: string
68
- }
69
-
70
- /**
71
- * The generated module.
72
- *
73
- * Written as a string rather than compiled from a source file because OpenCode
74
- * loads a plugin module rather than a command definition. It embeds only the
75
- * stable user-level adapter path; mutable runtime and CLI targets live behind
76
- * that adapter exactly as they do for every other harness.
77
- *
78
- * Everything it does is build the hook envelope OpenCode's event gives it and
79
- * hand that to the CLI on stdin. It deliberately fails open: an adapter error
80
- * never changes OpenCode's own behavior.
81
- */
82
- /**
83
- * The plugin body, which is the same on every platform except for how it
84
- * reaches the adapter.
85
- *
86
- * It was two near-identical copies — about a hundred and twenty lines each,
87
- * differing only in the spawn call — so every fix to the plugin had to be made
88
- * twice or silently apply to one platform.
89
- */
90
- export function opencodePluginSource(options: OpencodePluginOptions): string {
91
- const { adapterPath, timeoutSeconds } = options
92
- // Windows cannot execute the adapter script directly, so it goes through the
93
- // registered Node executable and the console window is suppressed.
94
- const win32 = hookHostPlatform(options.platform) === 'win32'
95
- const nodeConstant = win32
96
- ? `const NODE = ${JSON.stringify(options.nodePath ?? process.execPath)}\n`
97
- : ''
98
- const spawnArguments = win32
99
- ? 'NODE, [ADAPTER, "hook", event, "--owner", "notifai", "--harness", "opencode"]'
100
- : 'ADAPTER, ["hook", event, "--owner", "notifai", "--harness", "opencode"]'
101
- const windowsHide = win32 ? '\n windowsHide: true,' : ''
102
- return `${OPENCODE_PLUGIN_MARKER}
103
- // Generated by \`notifai hooks install --harness opencode\`. Edits are lost on
104
- // the next install; change the CLI instead, which is where the logic lives.
105
- import { spawn } from "node:child_process"
106
-
107
- ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
108
- const TIMEOUT_MS = ${timeoutSeconds * 1000}
109
- const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
110
- const MISSING_LIFECYCLE_GUIDANCE_CONTEXT = ${JSON.stringify(MISSING_LIFECYCLE_GUIDANCE_CONTEXT)}
111
- const WORKER_ACTIVATION_CONTEXT = ${JSON.stringify(WORKER_ACTIVATION_CONTEXT)}
112
- const PENDING_SESSION_TITLE =
113
- /^New session(?: *- *[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:[.][0-9]+)?Z)?$/i
114
-
115
- /**
116
- * Runs \`notifai hook <event>\`, feeding it the same JSON envelope the other
117
- * harnesses send. Resolves to stdout, or null if anything at all went wrong.
118
- */
119
- function runHook(event, envelope) {
120
- return new Promise((resolve) => {
121
- let child
122
- try {
123
- child = spawn(${spawnArguments}, {
124
- env: process.env,
125
- shell: false,${windowsHide}
126
- stdio: ["pipe", "pipe", "pipe"],
127
- })
128
- } catch {
129
- resolve(null)
130
- return
131
- }
132
-
133
- let stdout = ""
134
- let settled = false
135
- const finish = (value) => {
136
- if (settled) return
137
- settled = true
138
- clearTimeout(timer)
139
- resolve(value)
140
- }
141
- const timer = setTimeout(() => {
142
- child.kill("SIGTERM")
143
- finish(null)
144
- }, TIMEOUT_MS)
145
-
146
- child.stdout?.setEncoding("utf-8")
147
- child.stdout?.on("data", (chunk) => {
148
- stdout += chunk
149
- })
150
- // Hook stderr is diagnostics, not plugin output; discard it here.
151
- child.stderr?.resume()
152
- child.on("error", () => finish(null))
153
- child.on("close", () => finish(stdout))
154
-
155
- try {
156
- child.stdin?.end(JSON.stringify(envelope))
157
- } catch {
158
- finish(null)
159
- }
160
- })
161
- }
162
-
163
- export const NotifAIPlugin = async ({ directory, client }) => {
164
- const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
165
- const activated = new Set()
166
-
167
- return {
168
- /** Model-visible activation, independent of CLI setup and delivery state. */
169
- "experimental.chat.system.transform": async (input, output) => {
170
- const sessionID = input?.sessionID
171
- if (typeof sessionID !== "string" || sessionID.length === 0) return
172
- if (activated.has(sessionID)) return
173
- // OpenCode represents delegated work as child sessions. Missing or
174
- // malformed relationship data must fail closed as worker context: it is
175
- // safe to suppress a notification, never safe to let a child duplicate
176
- // its parent's User-visible request.
177
- let worker = true
178
- try {
179
- const response = await client.session.get({ path: { id: sessionID } })
180
- const session = response?.data
181
- if (session !== null && typeof session === "object") {
182
- worker = typeof session.parentID === "string" && session.parentID.length > 0
183
- }
184
- } catch {
185
- // Keep the safe worker classification.
186
- }
187
- const hookEventName = worker ? "SubagentStart" : "SessionStart"
188
- const resolved = await runHook(worker ? "subagent-start" : "session-start", {
189
- session_id: sessionID,
190
- cwd,
191
- hook_event_name: hookEventName,
192
- })
193
- const context = typeof resolved === "string" && resolved.trim().length > 0
194
- ? resolved.trim()
195
- : worker ? WORKER_ACTIVATION_CONTEXT : MISSING_LIFECYCLE_GUIDANCE_CONTEXT
196
- if (output.system.length === 0) {
197
- output.system.push(context)
198
- } else {
199
- output.system[0] = output.system[0] + "\\n\\n" + context
200
- }
201
- activated.add(sessionID)
202
- },
203
-
204
- /** Exact active-harness identity and first-party title for agent shell commands. */
205
- "shell.env": async (input, output) => {
206
- output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
207
- const sessionID = input?.sessionID
208
- if (typeof sessionID === "string" && sessionID.length > 0) {
209
- output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
210
- try {
211
- const response = await client.session.get({ path: { id: sessionID } })
212
- const title = response?.data?.title
213
- if (typeof title === "string" && title.trim().length > 0) {
214
- if (PENDING_SESSION_TITLE.test(title.trim())) {
215
- output.env.NOTIFAI_ACTIVE_SESSION_LABEL_PENDING = "1"
216
- } else {
217
- output.env.NOTIFAI_ACTIVE_SESSION_LABEL = title
218
- }
219
- }
220
- } catch {
221
- // Identity remains useful when this OpenCode build cannot expose a title.
222
- }
223
- }
224
- },
225
-
226
- /**
227
- * Presence. The user typing is the signal every harness shares, and it is
228
- * also what publishes the project -> session pointer \`notifai ask\` reads.
229
- */
230
- /** Publishes the session pointer; the CLI returns no content to inject. */
231
- "chat.message": async (input) => {
232
- await runHook("user-prompt-submit", {
233
- session_id: input?.sessionID ?? input?.info?.sessionID,
234
- cwd,
235
- hook_event_name: "UserPromptSubmit",
236
- })
237
- },
238
-
239
- /** Session lifecycle events are delivered through OpenCode's event bus. */
240
- event: async ({ event }) => {
241
- if (event?.type === "session.idle") {
242
- await runHook("stop", {
243
- session_id: event?.properties?.sessionID,
244
- cwd,
245
- hook_event_name: "Stop",
246
- })
247
- } else if (event?.type === "session.deleted") {
248
- await runHook("session-end", {
249
- session_id: event?.properties?.info?.id,
250
- cwd,
251
- hook_event_name: "SessionEnd",
252
- })
253
- }
254
- },
255
- }
256
- }
257
- `
258
- }
259
-
260
- /** True when a plugin file on disk is one of ours, whoever installed it. */
261
- export function isOurOpencodePlugin(contents: string): boolean {
262
- return contents.includes(OPENCODE_PLUGIN_MARKER)
263
- }
264
-
265
- /**
266
- * The interpreter and script a generated plugin will actually run.
267
- *
268
- * A plugin is a module rather than a command line, so which *build* it invokes
269
- * is not visible in the way it is for a settings-file handler — it is baked
270
- * into the two constants at the top. `doctor` needs it to answer the question
271
- * it exists to answer ("are two checkouts both installed?"), and without it
272
- * the plugin's own path stands in for the build and every OpenCode install
273
- * looks like a second one.
274
- *
275
- * Returns null for a plugin we did not generate, or one whose constants have
276
- * been edited out of recognisable shape.
277
- */
278
- export function opencodePluginTarget(
279
- contents: string,
280
- ): { adapter: string; current: boolean; timeoutSeconds?: number; nodePath?: string } | null {
281
- if (!isOurOpencodePlugin(contents)) return null
282
- const adapter = /^const ADAPTER = (".*")$/m.exec(contents)?.[1]
283
- const legacyScript = /^const SCRIPT = (".*")$/m.exec(contents)?.[1]
284
- const node = /^const NODE = (".*")$/m.exec(contents)?.[1]
285
- const version = Number(/^const ADAPTER_VERSION = (\d+)$/m.exec(contents)?.[1] ?? 1)
286
- const timeoutMs = Number(/^const TIMEOUT_MS = (\d+)$/m.exec(contents)?.[1] ?? Number.NaN)
287
- if (adapter === undefined && legacyScript === undefined) return null
288
- try {
289
- return {
290
- adapter: JSON.parse(adapter ?? legacyScript!) as string,
291
- current: version === OPENCODE_ADAPTER_VERSION,
292
- ...(Number.isFinite(timeoutMs) ? { timeoutSeconds: timeoutMs / 1000 } : {}),
293
- ...(node === undefined ? {} : { nodePath: JSON.parse(node) as string }),
294
- }
295
- } catch {
296
- return null
297
- }
298
- }
@@ -1,54 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { readOrcaSessionTitle, type OrcaCommand } from './orca-session-title.js'
3
-
4
- const worktreeId = 'repo-123::/worktrees/agent-session-labels'
5
-
6
- function response(overrides: Record<string, unknown> = {}): string {
7
- return JSON.stringify({
8
- ok: true,
9
- result: {
10
- worktree: {
11
- id: worktreeId,
12
- path: '/worktrees/agent-session-labels',
13
- displayName: 'Agent Session context labels',
14
- ...overrides,
15
- },
16
- },
17
- })
18
- }
19
-
20
- describe('Orca Agent Session title context', () => {
21
- it('accepts only the User-facing title returned for the exact managed worktree', () => {
22
- const calls: { executable: string; args: readonly string[] }[] = []
23
- const command: OrcaCommand = (executable, args) => {
24
- calls.push({ executable, args })
25
- return response()
26
- }
27
-
28
- expect(
29
- readOrcaSessionTitle({ TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId }, command),
30
- ).toBe('Agent Session context labels')
31
- expect(calls).toEqual([
32
- {
33
- executable: 'orca',
34
- args: ['worktree', 'show', '--worktree', `id:${worktreeId}`, '--json'],
35
- },
36
- ])
37
- })
38
-
39
- it.each([
40
- ['command failure', () => null],
41
- ['malformed JSON', () => '{not json}'],
42
- ['failed response', () => JSON.stringify({ ok: false })],
43
- ['different worktree id', () => response({ id: 'repo-123::/worktrees/other' })],
44
- ['different worktree path', () => response({ path: '/private/untrusted' })],
45
- ['blank display name', () => response({ displayName: ' ' })],
46
- ])('leaves the generated fallback exceptional when context is unavailable: %s', (_name, command) => {
47
- expect(
48
- readOrcaSessionTitle(
49
- { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
50
- command,
51
- ),
52
- ).toBeUndefined()
53
- })
54
- })
@@ -1,103 +0,0 @@
1
- import { execFileSync } from 'node:child_process'
2
- import path from 'node:path'
3
-
4
- export type OrcaSessionTitleLookup = (env: NodeJS.ProcessEnv) => string | undefined
5
- export type OrcaCommand = (executable: string, args: readonly string[]) => string | null
6
-
7
- const runOrcaCommand: OrcaCommand = (executable, args) => {
8
- try {
9
- return execFileSync(executable, args, {
10
- encoding: 'utf8',
11
- stdio: ['ignore', 'pipe', 'ignore'],
12
- timeout: 2_000,
13
- maxBuffer: 512 * 1024,
14
- windowsHide: true,
15
- }).trim()
16
- } catch {
17
- return null
18
- }
19
- }
20
-
21
- function containsControlCharacter(value: string): boolean {
22
- return Array.from(value).some((character) => {
23
- const codePoint = character.codePointAt(0) ?? 0
24
- return codePoint < 32 || codePoint === 127
25
- })
26
- }
27
-
28
- function worktreePathFromId(value: string): string | null {
29
- if (value.length === 0 || value.length > 4_096 || containsControlCharacter(value)) return null
30
- const separator = value.indexOf('::')
31
- if (separator <= 0) return null
32
- const repoId = value.slice(0, separator)
33
- const worktreePath = value.slice(separator + 2)
34
- if (!/^[A-Za-z0-9._-]+$/u.test(repoId) || worktreePath.length === 0) return null
35
- return path.posix.isAbsolute(worktreePath) || path.win32.isAbsolute(worktreePath)
36
- ? worktreePath
37
- : null
38
- }
39
-
40
- function orcaExecutable(env: NodeJS.ProcessEnv): string | null {
41
- const configured = env['ORCA_CLI_COMMAND']?.trim()
42
- if (configured !== undefined && configured.length > 0) {
43
- return containsControlCharacter(configured) ? null : configured
44
- }
45
- return env['ORCA_DEV_REPO_ROOT'] === undefined ? 'orca' : 'orca-dev'
46
- }
47
-
48
- /**
49
- * Read the User-facing task name assigned to this exact Orca worktree.
50
- *
51
- * The environment contributes only an opaque selector. Orca must return that
52
- * exact id and its matching path before its display name becomes trusted; no
53
- * path component or identifier is ever promoted into an Agent Session label.
54
- */
55
- export function readOrcaSessionTitle(
56
- env: NodeJS.ProcessEnv,
57
- command: OrcaCommand = runOrcaCommand,
58
- ): string | undefined {
59
- if (env['TERM_PROGRAM'] !== 'Orca') return undefined
60
- const worktreeId = env['ORCA_WORKTREE_ID']
61
- if (worktreeId === undefined) return undefined
62
- const expectedPath = worktreePathFromId(worktreeId)
63
- if (expectedPath === null) return undefined
64
- const executable = orcaExecutable(env)
65
- if (executable === null) return undefined
66
-
67
- let output: string | null
68
- try {
69
- output = command(executable, [
70
- 'worktree',
71
- 'show',
72
- '--worktree',
73
- `id:${worktreeId}`,
74
- '--json',
75
- ])
76
- } catch {
77
- return undefined
78
- }
79
- if (output === null || output === '') return undefined
80
-
81
- let parsed: unknown
82
- try {
83
- parsed = JSON.parse(output)
84
- } catch {
85
- return undefined
86
- }
87
- if (typeof parsed !== 'object' || parsed === null) return undefined
88
- const response = parsed as {
89
- ok?: unknown
90
- result?: { worktree?: { id?: unknown; path?: unknown; displayName?: unknown } }
91
- }
92
- const worktree = response.result?.worktree
93
- if (
94
- response.ok !== true ||
95
- worktree?.id !== worktreeId ||
96
- worktree.path !== expectedPath ||
97
- typeof worktree.displayName !== 'string' ||
98
- worktree.displayName.trim().length === 0
99
- ) {
100
- return undefined
101
- }
102
- return worktree.displayName
103
- }
@@ -1,212 +0,0 @@
1
- import { randomBytes } from 'node:crypto'
2
- import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
3
- import os from 'node:os'
4
- import path from 'node:path'
5
- import { gunzipSync } from 'node:zlib'
6
- import { describe, expect, it } from 'vitest'
7
- import { LOG_SCHEMA_VERSION, activeLogPath, createLogger, type LogRecord } from './logging.js'
8
- import { encodeFeedbackSlice, packFeedbackSlice } from './pack-feedback-slice.js'
9
-
10
- function sandbox(): NodeJS.ProcessEnv {
11
- return { XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-feedback-')) }
12
- }
13
-
14
- function writeLog(env: NodeJS.ProcessEnv, records: LogRecord[]): void {
15
- const file = activeLogPath(env)
16
- mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
17
- writeFileSync(file, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, {
18
- mode: 0o600,
19
- })
20
- }
21
-
22
- function record(overrides: Partial<LogRecord> & Pick<LogRecord, 'ts'>): LogRecord {
23
- return {
24
- v: 1,
25
- level: 'info',
26
- event: 'cli.end',
27
- run: 'r_1',
28
- cmd: 'send',
29
- pid: 1,
30
- ...overrides,
31
- }
32
- }
33
-
34
- describe('packFeedbackSlice', () => {
35
- it('returns an empty result with no gzip when there is no log', () => {
36
- const result = packFeedbackSlice(sandbox())
37
-
38
- expect(result.record_count).toBe(0)
39
- expect(result.records).toEqual([])
40
- expect(result.jsonl).toBe('')
41
- expect(result.gzip).toBeUndefined()
42
- expect(result.uncompressed_bytes).toBe(0)
43
- expect(result.compressed_bytes).toBe(0)
44
- expect(result.truncated).toBe(false)
45
- expect(result.consentSummary.toLowerCase()).toMatch(/credential/)
46
- expect(result.consentSummary.toLowerCase()).toMatch(/title/)
47
- expect(result.consentSummary.toLowerCase()).toMatch(/answer/)
48
- })
49
-
50
- it('packs records inside the default 24-hour window and drops older ones', () => {
51
- const env = sandbox()
52
- const now = Date.parse('2026-08-13T12:00:00.000Z')
53
- writeLog(env, [
54
- record({ ts: '2026-08-12T11:00:00.000Z', data: { which: 'too-old' } }),
55
- record({ ts: '2026-08-12T13:00:00.000Z', data: { which: 'kept' } }),
56
- ])
57
-
58
- const result = packFeedbackSlice(env, { now })
59
-
60
- expect(result.record_count).toBe(1)
61
- expect(result.records[0]?.data).toMatchObject({ which: 'kept' })
62
- expect(result.since).toBe(Date.parse('2026-08-12T12:00:00.000Z'))
63
- expect(result.until).toBe(now)
64
- expect(result.jsonl).toContain('kept')
65
- expect(result.jsonl).not.toContain('too-old')
66
- expect(result.gzip).toBeDefined()
67
- expect(result.uncompressed_bytes).toBe(Buffer.byteLength(result.jsonl))
68
- expect(result.compressed_bytes).toBe(result.gzip!.length)
69
- expect(result.truncated).toBe(false)
70
- })
71
-
72
- it('gives consent copy that names redacted credentials, titles, answers, window, and size', () => {
73
- const env = sandbox()
74
- const now = Date.parse('2026-08-13T12:00:00.000Z')
75
- writeLog(env, [record({ ts: '2026-08-13T11:00:00.000Z' })])
76
-
77
- const result = packFeedbackSlice(env, { now })
78
- const copy = result.consentSummary.toLowerCase()
79
-
80
- expect(copy).toMatch(/credential/)
81
- expect(copy).toMatch(/already removed|already redacted|redacted/)
82
- expect(copy).toMatch(/title/)
83
- expect(copy).toMatch(/answer/)
84
- expect(result.consentSummary).toContain('2026-08-12T12:00:00.000Z')
85
- expect(result.consentSummary).toContain('2026-08-13T12:00:00.000Z')
86
- expect(result.consentSummary).toContain(String(result.uncompressed_bytes))
87
- expect(result.consentSummary).toContain(String(result.compressed_bytes))
88
- expect(result.consentSummary).toContain(String(result.record_count))
89
- })
90
-
91
- it('applies extra log query filters on top of the window', () => {
92
- const env = sandbox()
93
- const now = Date.parse('2026-08-13T12:00:00.000Z')
94
- writeLog(env, [
95
- record({ ts: '2026-08-13T11:00:00.000Z', event: 'cli.end', data: { which: 'end' } }),
96
- record({ ts: '2026-08-13T11:01:00.000Z', event: 'cli.error', data: { which: 'error' } }),
97
- ])
98
-
99
- const result = packFeedbackSlice(env, { now, event: ['cli.error'] })
100
-
101
- expect(result.record_count).toBe(1)
102
- expect(result.records[0]?.event).toBe('cli.error')
103
- expect(result.records[0]?.data).toMatchObject({ which: 'error' })
104
- })
105
-
106
- it('drops oldest records until the uncompressed JSONL cap holds', () => {
107
- const env = sandbox()
108
- const now = Date.parse('2026-08-13T12:00:00.000Z')
109
- const start = Date.parse('2026-08-13T10:00:00.000Z')
110
- const pad = 'x'.repeat(2000)
111
- writeLog(
112
- env,
113
- Array.from({ length: 200 }, (_, sequence) =>
114
- record({
115
- ts: new Date(start + sequence * 1000).toISOString(),
116
- data: { sequence, pad },
117
- }),
118
- ),
119
- )
120
-
121
- const result = packFeedbackSlice(env, { now })
122
- const sequences = result.records.map((entry) => entry.data?.['sequence'])
123
-
124
- expect(result.uncompressed_bytes).toBeLessThanOrEqual(256 * 1024)
125
- expect(result.compressed_bytes).toBeLessThanOrEqual(128 * 1024)
126
- expect(result.truncated).toBe(true)
127
- expect(result.record_count).toBeGreaterThan(0)
128
- expect(sequences[0]).toBeGreaterThan(0)
129
- expect(sequences.at(-1)).toBe(199)
130
- })
131
-
132
- it('drops oldest records until the gzip cap holds', () => {
133
- const env = sandbox()
134
- const now = Date.parse('2026-08-13T12:00:00.000Z')
135
- const start = Date.parse('2026-08-13T10:00:00.000Z')
136
- const seeded = Array.from({ length: 80 }, (_, sequence) =>
137
- record({
138
- ts: new Date(start + sequence * 1000).toISOString(),
139
- data: { sequence, pad: randomBytes(1800).toString('base64') },
140
- }),
141
- )
142
- writeLog(env, seeded)
143
-
144
- const result = packFeedbackSlice(env, { now })
145
- const sequences = result.records.map((entry) => entry.data?.['sequence'])
146
-
147
- expect(result.uncompressed_bytes).toBeLessThanOrEqual(256 * 1024)
148
- expect(result.compressed_bytes).toBeLessThanOrEqual(128 * 1024)
149
- expect(result.truncated).toBe(true)
150
- expect(result.record_count).toBeGreaterThan(0)
151
- expect(result.record_count).toBeLessThan(seeded.length)
152
- expect(sequences[0]).toBeGreaterThan(0)
153
- expect(sequences.at(-1)).toBe(79)
154
- })
155
-
156
- it('encodes the gzip slice as a gzip+base64 wire payload', () => {
157
- const env = sandbox()
158
- const now = Date.parse('2026-08-13T12:00:00.000Z')
159
- writeLog(env, [record({ ts: '2026-08-13T11:00:00.000Z', data: { which: 'wire' } })])
160
-
161
- const packed = packFeedbackSlice(env, { now })
162
- const wire = encodeFeedbackSlice(packed)
163
-
164
- expect(wire).toEqual({
165
- encoding: 'gzip+base64',
166
- bytes: packed.gzip!.toString('base64'),
167
- uncompressed_bytes: packed.uncompressed_bytes,
168
- compressed_bytes: packed.compressed_bytes,
169
- record_count: packed.record_count,
170
- truncated: false,
171
- since: '2026-08-12T12:00:00.000Z',
172
- until: '2026-08-13T12:00:00.000Z',
173
- schema_version: packed.schema_version,
174
- })
175
- expect(gunzipSync(Buffer.from(wire!.bytes, 'base64')).toString('utf8')).toBe(packed.jsonl)
176
- expect(encodeFeedbackSlice(packFeedbackSlice(sandbox()))).toBeUndefined()
177
- })
178
-
179
- it('honours an explicit since instead of the 24-hour default', () => {
180
- const env = sandbox()
181
- const now = Date.parse('2026-08-13T12:00:00.000Z')
182
- writeLog(env, [
183
- record({ ts: '2026-08-10T12:00:00.000Z', data: { which: 'old' } }),
184
- record({ ts: '2026-08-13T11:00:00.000Z', data: { which: 'new' } }),
185
- ])
186
-
187
- const result = packFeedbackSlice(env, {
188
- now,
189
- since: Date.parse('2026-08-10T00:00:00.000Z'),
190
- })
191
-
192
- expect(result.records.map((entry) => entry.data?.['which'])).toEqual(['old', 'new'])
193
- expect(result.since).toBe(Date.parse('2026-08-10T00:00:00.000Z'))
194
- })
195
-
196
- it('packs records written by the logger and skips a damaged line', () => {
197
- const env = sandbox()
198
- const logger = createLogger({ env, now: () => Date.parse('2026-08-13T11:30:00.000Z') })
199
- logger.info('cli.end', { which: 'first' })
200
- appendFileSync(activeLogPath(env), 'not json at all\n')
201
- createLogger({ env, now: () => Date.parse('2026-08-13T11:31:00.000Z') }).info('cli.end', {
202
- which: 'second',
203
- })
204
-
205
- const result = packFeedbackSlice(env, { now: Date.parse('2026-08-13T12:00:00.000Z') })
206
-
207
- expect(result.schema_version).toBe(LOG_SCHEMA_VERSION)
208
- expect(result.record_count).toBe(2)
209
- expect(result.records.map((entry) => entry.data?.['which'])).toEqual(['first', 'second'])
210
- expect(result.truncated).toBe(false)
211
- })
212
- })