@raidiant/notifai 6.0.0 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/atomic-file.js +13 -0
  3. package/dist/atomic-file.js.map +1 -1
  4. package/dist/commands-core.d.ts +3 -0
  5. package/dist/commands-core.js +23 -4
  6. package/dist/commands-core.js.map +1 -1
  7. package/dist/commands-doctor.js +7 -5
  8. package/dist/commands-doctor.js.map +1 -1
  9. package/dist/commands-harness-context.d.ts +4 -0
  10. package/dist/commands-harness-context.js +4 -0
  11. package/dist/commands-harness-context.js.map +1 -1
  12. package/dist/commands-init.js +0 -1
  13. package/dist/commands-init.js.map +1 -1
  14. package/dist/commands-send-support.js +22 -0
  15. package/dist/commands-send-support.js.map +1 -1
  16. package/dist/config.d.ts +3 -3
  17. package/dist/config.js +3 -3
  18. package/dist/generated-session-label.d.ts +2 -0
  19. package/dist/generated-session-label.js +152 -0
  20. package/dist/generated-session-label.js.map +1 -0
  21. package/dist/invocation-context.d.ts +3 -0
  22. package/dist/invocation-context.js +23 -6
  23. package/dist/invocation-context.js.map +1 -1
  24. package/dist/main.js +2 -2
  25. package/dist/main.js.map +1 -1
  26. package/dist/opencode-plugin.js +18 -3
  27. package/dist/opencode-plugin.js.map +1 -1
  28. package/dist/orca-session-title.d.ts +10 -0
  29. package/dist/orca-session-title.js +99 -0
  30. package/dist/orca-session-title.js.map +1 -0
  31. package/dist/session-labels.d.ts +30 -0
  32. package/dist/session-labels.js +278 -0
  33. package/dist/session-labels.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/atomic-file.ts +12 -0
  36. package/src/commands-core.ts +24 -4
  37. package/src/commands-doctor.ts +7 -6
  38. package/src/commands-harness-context.ts +8 -0
  39. package/src/commands-init.ts +0 -1
  40. package/src/commands-send-support.ts +27 -0
  41. package/src/commands.test.ts +203 -6
  42. package/src/config.ts +3 -3
  43. package/src/generated-session-label.ts +155 -0
  44. package/src/hooks.test.ts +4 -5
  45. package/src/install-hooks.test.ts +84 -1
  46. package/src/invocation-context.test.ts +115 -16
  47. package/src/invocation-context.ts +32 -11
  48. package/src/main.ts +8 -2
  49. package/src/opencode-plugin.ts +18 -3
  50. package/src/orca-session-title.test.ts +95 -0
  51. package/src/orca-session-title.ts +105 -0
  52. package/src/session-labels.test.ts +324 -0
  53. package/src/session-labels.ts +360 -0
  54. package/src/skill.test.ts +15 -0
@@ -1,5 +1,7 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
1
4
  import { describe, expect, it } from 'vitest'
2
- import { sessionLabelFromId } from '@raidiant/notifai-protocol'
3
5
  import {
4
6
  buildSourceContext,
5
7
  inferInvocationContext,
@@ -13,6 +15,13 @@ function fixtureGit(values: Record<string, string | null>): GitCommand {
13
15
  return (_cwd, args) => values[args.join(' ')] ?? null
14
16
  }
15
17
 
18
+ function stateEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
19
+ return {
20
+ XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-source-context-')),
21
+ ...overrides,
22
+ }
23
+ }
24
+
16
25
  describe('projectSlugFrom', () => {
17
26
  it.each([
18
27
  ['My Project', 'my-project'],
@@ -155,17 +164,19 @@ describe('truncateContext', () => {
155
164
 
156
165
  describe('buildSourceContext', () => {
157
166
  const invocation = { project: 'repo', branch: 'main', worktree: 'topic' }
167
+ const now = new Date(2026, 7, 20, 14, 5).getTime()
158
168
 
159
169
  it('applies flag, environment, then inferred precedence per session field', () => {
160
170
  const built = buildSourceContext({
161
- env: {
171
+ env: stateEnv({
162
172
  NOTIFAI_SESSION_ID: 'env-id',
163
173
  NOTIFAI_SESSION_LABEL: 'Environment Label',
164
- },
174
+ }),
165
175
  invocation,
166
176
  sessionId: 'flag-id',
167
177
  sessionLabel: 'Flag Label',
168
- activeHarness: { harness: 'claude-code', sessionId: 'inferred-id' },
178
+ activeHarness: { harness: 'claude-code', sessionId: 'flag-id' },
179
+ now,
169
180
  })
170
181
  expect(built).toEqual({
171
182
  ok: true,
@@ -182,12 +193,13 @@ describe('buildSourceContext', () => {
182
193
  it('uses environment values ahead of an inferred exact session', () => {
183
194
  expect(
184
195
  buildSourceContext({
185
- env: {
196
+ env: stateEnv({
186
197
  NOTIFAI_SESSION_ID: 'env-id',
187
198
  NOTIFAI_SESSION_LABEL: 'Environment Label',
188
- },
199
+ }),
189
200
  invocation: { project: 'repo' },
190
- activeHarness: { harness: 'codex', sessionId: 'inferred-id' },
201
+ activeHarness: { harness: 'codex', sessionId: 'env-id' },
202
+ now,
191
203
  }),
192
204
  ).toEqual({
193
205
  ok: true,
@@ -199,32 +211,117 @@ describe('buildSourceContext', () => {
199
211
  })
200
212
  })
201
213
 
202
- it('generates a stable non-identifying label for an inferred exact session', () => {
203
- const sessionId = 'opaque-thread-1234567890'
214
+ it('uses and freezes a trusted harness-native title', () => {
215
+ const env = stateEnv()
216
+ const sessionId = 'opencode-thread'
204
217
  expect(
205
218
  buildSourceContext({
206
- env: {},
219
+ env,
207
220
  invocation: { project: 'repo', branch: 'main' },
208
- activeHarness: { harness: 'opencode', sessionId },
221
+ activeHarness: {
222
+ harness: 'opencode',
223
+ sessionId,
224
+ sessionLabel: 'Semantic session names',
225
+ },
226
+ now,
209
227
  }),
210
228
  ).toEqual({
211
229
  ok: true,
212
230
  source: {
213
231
  session_id: sessionId,
214
- session_label: sessionLabelFromId(sessionId),
232
+ session_label: 'Semantic session names',
215
233
  harness: 'opencode',
216
234
  branch: 'main',
217
235
  },
218
236
  })
219
- expect(sessionLabelFromId(sessionId)).not.toContain('1234567890')
237
+ expect(
238
+ buildSourceContext({
239
+ env,
240
+ invocation: { project: 'repo' },
241
+ activeHarness: { harness: 'opencode', sessionId, sessionLabel: 'Changed title' },
242
+ now: now + 1_000,
243
+ }),
244
+ ).toEqual({
245
+ ok: true,
246
+ source: {
247
+ session_id: sessionId,
248
+ session_label: 'Semantic session names',
249
+ harness: 'opencode',
250
+ },
251
+ })
252
+ })
253
+
254
+ it('does not freeze an OpenCode placeholder while its semantic title is pending', () => {
255
+ expect(
256
+ buildSourceContext({
257
+ env: stateEnv(),
258
+ invocation: { project: 'repo' },
259
+ activeHarness: {
260
+ harness: 'opencode',
261
+ sessionId: 'pending-session',
262
+ sessionLabelPending: true,
263
+ },
264
+ now,
265
+ }),
266
+ ).toEqual({
267
+ ok: false,
268
+ error:
269
+ "OpenCode is still generating this session's title; retry shortly or pass --session-label.",
270
+ })
271
+ })
272
+
273
+ it('uses a stable generated fallback when no semantic name exists', () => {
274
+ const env = stateEnv()
275
+ const sessionId = 'opaque-thread-1234567890'
276
+ const expected = {
277
+ ok: true,
278
+ source: {
279
+ session_id: sessionId,
280
+ session_label: 'Ivory Koala',
281
+ harness: 'opencode' as const,
282
+ branch: 'main',
283
+ },
284
+ }
285
+ expect(
286
+ buildSourceContext({
287
+ env,
288
+ invocation: { project: 'repo', branch: 'main' },
289
+ activeHarness: { harness: 'opencode', sessionId },
290
+ now,
291
+ }),
292
+ ).toEqual(expected)
293
+ expect(expected.source.session_label).not.toContain('1234567890')
294
+ })
295
+
296
+ it('does not borrow harness identity or title for an explicit different session id', () => {
297
+ expect(
298
+ buildSourceContext({
299
+ env: stateEnv(),
300
+ invocation: { project: 'repo' },
301
+ sessionId: 'other-session',
302
+ activeHarness: {
303
+ harness: 'claude-code',
304
+ sessionId: 'current-session',
305
+ sessionLabel: 'Current work',
306
+ },
307
+ now,
308
+ }),
309
+ ).toEqual({
310
+ ok: true,
311
+ source: {
312
+ session_id: 'other-session',
313
+ session_label: 'Golden Lynx',
314
+ },
315
+ })
220
316
  })
221
317
 
222
318
  it('does not emit a harness or label when no exact session id is available', () => {
223
319
  expect(
224
320
  buildSourceContext({
225
- env: {},
321
+ env: stateEnv(),
226
322
  invocation: { project: 'repo', branch: 'main' },
227
323
  activeHarness: { harness: 'cursor' },
324
+ now,
228
325
  }),
229
326
  ).toEqual({ ok: true, source: { branch: 'main' } })
230
327
  })
@@ -232,8 +329,9 @@ describe('buildSourceContext', () => {
232
329
  it('ignores the deleted NOTIFAI_SESSION variable', () => {
233
330
  expect(
234
331
  buildSourceContext({
235
- env: { NOTIFAI_SESSION: 'legacy-id' },
332
+ env: stateEnv({ NOTIFAI_SESSION: 'legacy-id' }),
236
333
  invocation: { project: 'repo' },
334
+ now,
237
335
  }),
238
336
  ).toEqual({ ok: true })
239
337
  })
@@ -241,9 +339,10 @@ describe('buildSourceContext', () => {
241
339
  it('rejects a display label without an exact session id', () => {
242
340
  expect(
243
341
  buildSourceContext({
244
- env: {},
342
+ env: stateEnv(),
245
343
  invocation: { project: 'repo' },
246
344
  sessionLabel: 'Unbound Label',
345
+ now,
247
346
  }),
248
347
  ).toEqual({
249
348
  ok: false,
@@ -1,10 +1,8 @@
1
1
  import { execFileSync } from 'node:child_process'
2
2
  import path from 'node:path'
3
- import {
4
- sessionLabelFromId,
5
- type SourceContextT,
6
- } from '@raidiant/notifai-protocol'
3
+ import { type SourceContextT } from '@raidiant/notifai-protocol'
7
4
  import type { Harness } from './harnesses.js'
5
+ import { resolveSessionLabel } from './session-labels.js'
8
6
 
9
7
  export type GitCommand = (cwd: string, args: readonly string[]) => string | null
10
8
 
@@ -139,7 +137,13 @@ export interface SourceContextInput {
139
137
  invocation: InvocationContext
140
138
  sessionId?: string
141
139
  sessionLabel?: string
142
- activeHarness?: { harness: Harness; sessionId?: string }
140
+ activeHarness?: {
141
+ harness: Harness
142
+ sessionId?: string
143
+ sessionLabel?: string
144
+ sessionLabelPending?: boolean
145
+ }
146
+ now?: number
143
147
  }
144
148
 
145
149
  export type SourceContextBuild =
@@ -161,14 +165,31 @@ export function buildSourceContext(input: SourceContextInput): SourceContextBuil
161
165
  return { ok: false, error: '--session-id must not be empty.' }
162
166
  }
163
167
 
164
- const sessionLabel =
165
- sessionId === undefined ? undefined : (explicitLabel ?? sessionLabelFromId(sessionId))
168
+ const activeOwnsSession =
169
+ sessionId !== undefined && input.activeHarness?.sessionId === sessionId
170
+ const harness = activeOwnsSession ? input.activeHarness?.harness : undefined
171
+ const label =
172
+ sessionId === undefined
173
+ ? undefined
174
+ : resolveSessionLabel({
175
+ env: input.env,
176
+ sessionId,
177
+ ...(harness === undefined ? {} : { harness }),
178
+ ...(explicitLabel === undefined ? {} : { explicitLabel }),
179
+ ...(activeOwnsSession && input.activeHarness?.sessionLabel !== undefined
180
+ ? { harnessLabel: input.activeHarness.sessionLabel }
181
+ : {}),
182
+ ...(activeOwnsSession && input.activeHarness?.sessionLabelPending === true
183
+ ? { harnessLabelPending: true }
184
+ : {}),
185
+ ...(input.now === undefined ? {} : { now: input.now }),
186
+ })
187
+ if (label !== undefined && !label.ok) return label
188
+
166
189
  const source: SourceContextT = {
167
190
  ...(sessionId !== undefined ? { session_id: sessionId } : {}),
168
- ...(sessionLabel !== undefined ? { session_label: sessionLabel } : {}),
169
- ...(sessionId !== undefined && input.activeHarness !== undefined
170
- ? { harness: input.activeHarness.harness }
171
- : {}),
191
+ ...(label !== undefined ? { session_label: label.label } : {}),
192
+ ...(harness !== undefined ? { harness } : {}),
172
193
  ...(input.invocation.branch !== undefined ? { branch: input.invocation.branch } : {}),
173
194
  ...(input.invocation.worktree !== undefined ? { worktree: input.invocation.worktree } : {}),
174
195
  }
package/src/main.ts CHANGED
@@ -313,7 +313,10 @@ const send = program
313
313
  .option('--device <id>', 'target a device id (repeatable)', (v: string, all: string[] = []) => [...all, v])
314
314
  .option('--all', 'target all routable devices (overrides configured devices)')
315
315
  .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
316
- .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
316
+ .option(
317
+ '--session-label <text>',
318
+ 'first human session name, frozen locally (env: NOTIFAI_SESSION_LABEL)',
319
+ )
317
320
  .option('--event <event>', 'agent event name, e.g. tests_passed')
318
321
  .optionsGroup(SEND_GROUP.presentation)
319
322
  .option('--collapse-key <key>', 'replace earlier notifications with the same key')
@@ -445,7 +448,10 @@ program
445
448
  )
446
449
  .option('--project <id>', 'project identifier override (otherwise configured or inferred)')
447
450
  .option('--session-id <id>', 'opaque exact-session override (env: NOTIFAI_SESSION_ID)')
448
- .option('--session-label <text>', 'human session label override (env: NOTIFAI_SESSION_LABEL)')
451
+ .option(
452
+ '--session-label <text>',
453
+ 'first human session name, frozen locally (env: NOTIFAI_SESSION_LABEL)',
454
+ )
449
455
  .action(async (question: string | undefined, opts: {
450
456
  choice?: string[]
451
457
  multi?: boolean
@@ -31,7 +31,7 @@ export const OPENCODE_PLUGIN_MARKER = '// notifai managed opencode plugin'
31
31
  export const OPENCODE_PLUGIN_FILENAME = 'notifai.js'
32
32
 
33
33
  /** Bump when an installed generated file must be rewritten to remain functional. */
34
- const OPENCODE_ADAPTER_VERSION = 6
34
+ const OPENCODE_ADAPTER_VERSION = 8
35
35
 
36
36
  export function opencodePluginDir(
37
37
  global: boolean,
@@ -103,6 +103,8 @@ import { spawn } from "node:child_process"
103
103
  ${nodeConstant}const ADAPTER = ${JSON.stringify(adapterPath)}
104
104
  const TIMEOUT_MS = ${timeoutSeconds * 1000}
105
105
  const ADAPTER_VERSION = ${OPENCODE_ADAPTER_VERSION}
106
+ const PENDING_SESSION_TITLE =
107
+ /^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
106
108
 
107
109
  /**
108
110
  * Runs \`notifai hook <event>\`, feeding it the same JSON envelope the other
@@ -152,16 +154,29 @@ function runHook(event, envelope) {
152
154
  })
153
155
  }
154
156
 
155
- export const NotifAIPlugin = async ({ directory }) => {
157
+ export const NotifAIPlugin = async ({ directory, client }) => {
156
158
  const cwd = typeof directory === "string" && directory.length > 0 ? directory : process.cwd()
157
159
 
158
160
  return {
159
- /** Exact active-harness identity for shell commands such as \`notifai ask\`. */
161
+ /** Exact active-harness identity and first-party title for agent shell commands. */
160
162
  "shell.env": async (input, output) => {
161
163
  output.env.NOTIFAI_ACTIVE_HARNESS = "opencode"
162
164
  const sessionID = input?.sessionID
163
165
  if (typeof sessionID === "string" && sessionID.length > 0) {
164
166
  output.env.NOTIFAI_ACTIVE_SESSION_ID = sessionID
167
+ try {
168
+ const response = await client.session.get({ path: { id: sessionID } })
169
+ const title = response?.data?.title
170
+ if (typeof title === "string" && title.trim().length > 0) {
171
+ if (PENDING_SESSION_TITLE.test(title.trim())) {
172
+ output.env.NOTIFAI_ACTIVE_SESSION_LABEL_PENDING = "1"
173
+ } else {
174
+ output.env.NOTIFAI_ACTIVE_SESSION_LABEL = title
175
+ }
176
+ }
177
+ } catch {
178
+ // Identity remains useful when this OpenCode build cannot expose a title.
179
+ }
165
180
  }
166
181
  },
167
182
 
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readOrcaSessionTitle, type OrcaCommand } from './orca-session-title.js'
3
+
4
+ const worktreeId = 'repo-123::/worktrees/semantic-session-implementation'
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/semantic-session-implementation',
13
+ displayName: 'Worker - semantic session implementation',
14
+ ...overrides,
15
+ },
16
+ },
17
+ })
18
+ }
19
+
20
+ describe('Orca semantic session title', () => {
21
+ it('accepts only the display name 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(
30
+ { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
31
+ command,
32
+ ),
33
+ ).toBe('Worker - semantic session implementation')
34
+ expect(calls).toEqual([
35
+ {
36
+ executable: 'orca',
37
+ args: ['worktree', 'show', '--worktree', `id:${worktreeId}`, '--json'],
38
+ },
39
+ ])
40
+ })
41
+
42
+ it('uses Orca-provided executable selection without invoking a shell', () => {
43
+ const calls: string[] = []
44
+ expect(
45
+ readOrcaSessionTitle(
46
+ {
47
+ TERM_PROGRAM: 'Orca',
48
+ ORCA_WORKTREE_ID: worktreeId,
49
+ ORCA_CLI_COMMAND: '/mnt/c/Program Files/Orca/orca.exe',
50
+ },
51
+ (executable) => {
52
+ calls.push(executable)
53
+ return response()
54
+ },
55
+ ),
56
+ ).toBe('Worker - semantic session implementation')
57
+ expect(calls).toEqual(['/mnt/c/Program Files/Orca/orca.exe'])
58
+ })
59
+
60
+ it('ignores non-Orca and malformed selectors without running a command', () => {
61
+ const command: OrcaCommand = () => {
62
+ throw new Error('must not run')
63
+ }
64
+ expect(readOrcaSessionTitle({ ORCA_WORKTREE_ID: worktreeId }, command)).toBeUndefined()
65
+ expect(
66
+ readOrcaSessionTitle(
67
+ { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: '/private/untrusted' },
68
+ command,
69
+ ),
70
+ ).toBeUndefined()
71
+ expect(
72
+ readOrcaSessionTitle(
73
+ { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: 'repo-123::relative/path' },
74
+ command,
75
+ ),
76
+ ).toBeUndefined()
77
+ })
78
+
79
+ it.each([
80
+ ['command failure', () => null],
81
+ ['thrown command failure', () => { throw new Error('offline') }],
82
+ ['malformed JSON', () => '{not json}'],
83
+ ['failed response', () => JSON.stringify({ ok: false })],
84
+ ['different worktree id', () => response({ id: 'repo-123::/worktrees/other' })],
85
+ ['different worktree path', () => response({ path: '/private/untrusted' })],
86
+ ['blank display name', () => response({ displayName: ' ' })],
87
+ ])('fails closed for %s', (_name, command) => {
88
+ expect(
89
+ readOrcaSessionTitle(
90
+ { TERM_PROGRAM: 'Orca', ORCA_WORKTREE_ID: worktreeId },
91
+ command,
92
+ ),
93
+ ).toBeUndefined()
94
+ })
95
+ })
@@ -0,0 +1,105 @@
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)) {
30
+ return null
31
+ }
32
+ const separator = value.indexOf('::')
33
+ if (separator <= 0) return null
34
+ const repoId = value.slice(0, separator)
35
+ const worktreePath = value.slice(separator + 2)
36
+ if (!/^[A-Za-z0-9._-]+$/u.test(repoId) || worktreePath.length === 0) return null
37
+ return path.posix.isAbsolute(worktreePath) || path.win32.isAbsolute(worktreePath)
38
+ ? worktreePath
39
+ : null
40
+ }
41
+
42
+ function orcaExecutable(env: NodeJS.ProcessEnv): string | null {
43
+ const configured = env['ORCA_CLI_COMMAND']?.trim()
44
+ if (configured !== undefined && configured.length > 0) {
45
+ return containsControlCharacter(configured) ? null : configured
46
+ }
47
+ return env['ORCA_DEV_REPO_ROOT'] === undefined ? 'orca' : 'orca-dev'
48
+ }
49
+
50
+ /**
51
+ * Read the semantic display name assigned to this exact Orca worktree.
52
+ *
53
+ * The environment contributes only an opaque selector. Orca must return that
54
+ * exact id and its matching path before its display name becomes trusted; no
55
+ * path component or identifier is ever promoted into the User-visible label.
56
+ */
57
+ export function readOrcaSessionTitle(
58
+ env: NodeJS.ProcessEnv,
59
+ command: OrcaCommand = runOrcaCommand,
60
+ ): string | undefined {
61
+ if (env['TERM_PROGRAM'] !== 'Orca') return undefined
62
+ const worktreeId = env['ORCA_WORKTREE_ID']
63
+ if (worktreeId === undefined) return undefined
64
+ const expectedPath = worktreePathFromId(worktreeId)
65
+ if (expectedPath === null) return undefined
66
+ const executable = orcaExecutable(env)
67
+ if (executable === null) return undefined
68
+
69
+ let output: string | null
70
+ try {
71
+ output = command(executable, [
72
+ 'worktree',
73
+ 'show',
74
+ '--worktree',
75
+ `id:${worktreeId}`,
76
+ '--json',
77
+ ])
78
+ } catch {
79
+ return undefined
80
+ }
81
+ if (output === null || output === '') return undefined
82
+
83
+ let parsed: unknown
84
+ try {
85
+ parsed = JSON.parse(output)
86
+ } catch {
87
+ return undefined
88
+ }
89
+ if (typeof parsed !== 'object' || parsed === null) return undefined
90
+ const response = parsed as {
91
+ ok?: unknown
92
+ result?: { worktree?: { id?: unknown; path?: unknown; displayName?: unknown } }
93
+ }
94
+ const worktree = response.result?.worktree
95
+ if (
96
+ response.ok !== true ||
97
+ worktree?.id !== worktreeId ||
98
+ worktree.path !== expectedPath ||
99
+ typeof worktree.displayName !== 'string' ||
100
+ worktree.displayName.trim().length === 0
101
+ ) {
102
+ return undefined
103
+ }
104
+ return worktree.displayName
105
+ }