iterate-plugin 3.6.0 → 3.8.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.
@@ -3,16 +3,13 @@ import {
3
3
  readFileSync,
4
4
  mkdirSync,
5
5
  existsSync,
6
- openSync,
7
- writeSync,
8
- closeSync,
9
- unlinkSync,
10
6
  statSync,
11
7
  } from 'node:fs'
12
8
  import { join } from 'node:path'
13
9
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
10
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
11
  import { resolveProjectRootForExec } from '../config-loader.ts'
12
+ import { acquireProjectLockChecked } from '../file-lock.ts'
16
13
  import type { DecisionLogEntry } from '../types.ts'
17
14
 
18
15
  const LOG_DIR = '.iterate'
@@ -33,35 +30,16 @@ const LOG_FILE = 'decision-log.jsonl'
33
30
  // opposite — it renames the log and therefore refuses to run when the lock
34
31
  // cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
35
32
 
36
- const LOCK_FILE = '.decision-log.lock'
37
- const LOCK_WAIT_MS = 5000
38
- const LOCK_STALE_MS = 10000
39
- const LOCK_POLL_MS = 25
40
-
41
- /** True when a pid refers to a live process (or we lack permission to tell). */
42
- function processAlive(pid: number): boolean {
43
- if (!Number.isInteger(pid) || pid <= 0) return false
44
- try {
45
- process.kill(pid, 0)
46
- return true
47
- } catch (err) {
48
- return (err as NodeJS.ErrnoException).code === 'EPERM'
49
- }
50
- }
51
-
52
- /** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
53
- function sleepSync(ms: number): void {
54
- const sab = new Int32Array(new SharedArrayBuffer(4))
55
- Atomics.wait(sab, 0, 0, ms)
56
- }
33
+ /** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
34
+ const LOG_LOCK_NAME = 'decision-log'
57
35
 
58
36
  /**
59
37
  * Acquire the decision-log lock for `projectRoot`.
60
38
  * Returns a release function (always callable; a no-op when the lock could
61
39
  * not be taken — never wedge an append behind a vanished holder).
62
40
  *
63
- * `options.waitMs` / `options.staleMs` override the defaults (tests use short
64
- * waits so a live-holder timeout is exercised in milliseconds, not 5s).
41
+ * `options.waitMs` / `options.staleMs` override the shared defaults (tests use
42
+ * short waits so a live-holder timeout is exercised in milliseconds, not 5s).
65
43
  */
66
44
  export function acquireLogLock(
67
45
  projectRoot: string,
@@ -76,66 +54,18 @@ export function acquireLogLock(
76
54
  * audit line must not be lost to a wedged append), but the rewrite renames the
77
55
  * log under any unlocked concurrent appender — it must refuse to run without
78
56
  * the mutex rather than silently dropping an audit line.
57
+ *
58
+ * Delegates to the shared `acquireProjectLockChecked` with lock name
59
+ * `decision-log`: the decision-log mutex IS that generic project lock. This
60
+ * module used to hand-roll an identical copy of the acquire/steal/wait logic,
61
+ * and the two stale/wait parameter sets could drift apart — one implementation,
62
+ * one set of defaults, one ownership-checked release.
79
63
  */
80
64
  export function acquireLogLockChecked(
81
65
  projectRoot: string,
82
66
  options: { waitMs?: number; staleMs?: number } = {},
83
67
  ): { acquired: boolean; release: () => void } {
84
- const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
85
- ? (options.waitMs as number)
86
- : LOCK_WAIT_MS
87
- const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
88
- ? (options.staleMs as number)
89
- : LOCK_STALE_MS
90
- const dir = join(projectRoot, LOG_DIR)
91
- if (!existsSync(dir)) {
92
- try {
93
- mkdirSync(dir, { recursive: true })
94
- } catch {
95
- return { acquired: false, release: () => {} }
96
- }
97
- }
98
- const lockPath = join(dir, LOCK_FILE)
99
- const start = Date.now()
100
- for (;;) {
101
- let fd: number | null = null
102
- try {
103
- fd = openSync(lockPath, 'wx')
104
- } catch (err) {
105
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
106
- // Lock exists — steal it when the owner is gone or the lock is ancient.
107
- let stale = false
108
- try {
109
- const st = statSync(lockPath)
110
- if (Date.now() - st.mtimeMs > staleMs) stale = true
111
- else {
112
- const pid = Number(readFileSync(lockPath, 'utf-8'))
113
- if (!processAlive(pid)) stale = true
114
- }
115
- } catch {
116
- stale = true // unreadable/vanishing lock → retry as stale
117
- }
118
- if (stale) {
119
- try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
120
- continue
121
- }
122
- if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
123
- sleepSync(LOCK_POLL_MS)
124
- continue
125
- }
126
- // Owned the lock: stamp the holder pid, then release once on return.
127
- try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
128
- try { closeSync(fd) } catch { /* best-effort */ }
129
- let released = false
130
- return {
131
- acquired: true,
132
- release: () => {
133
- if (released) return
134
- released = true
135
- try { unlinkSync(lockPath) } catch { /* already gone */ }
136
- },
137
- }
138
- }
68
+ return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options)
139
69
  }
140
70
 
141
71
  // ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
@@ -24,6 +24,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
24
24
  import { mkdir, readFile } from 'node:fs/promises'
25
25
  import { existsSync } from 'node:fs'
26
26
  import { writeTextAtomicAsync } from '../atomic-fs.ts'
27
+ import { withProjectLock } from '../file-lock.ts'
27
28
  import { dirname } from 'node:path'
28
29
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
29
30
  import {
@@ -81,7 +82,7 @@ function normalizeCheckpoint(input: unknown): TranscriptManifest['checkpoint'] {
81
82
  const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
82
83
  if (round <= 0) return null
83
84
  return {
84
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
85
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
85
86
  round,
86
87
  maxRounds: typeof c.maxRounds === 'number' && Number.isFinite(c.maxRounds) ? c.maxRounds : 0,
87
88
  fixedCount: typeof c.fixedCount === 'number' && Number.isFinite(c.fixedCount) ? c.fixedCount : 0,
@@ -215,7 +216,22 @@ export function registerTranscriptTool(ctx: {
215
216
  'the run console can show which commands ran and whether they passed.',
216
217
  },
217
218
  refReadFiles: { type: 'json', description: 'For `capture`: flat array of all read files across rounds (optional).' },
218
- stoppedReason: { type: 'string', description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
219
+ stoppedReason: {
220
+ type: 'string',
221
+ // Enum guard: the label renderer keys off this value, so a stray or
222
+ // prototype-chain string (e.g. "__proto__") must not enter the tool.
223
+ // The render path additionally uses Object.hasOwn (defense for
224
+ // hand-edited manifests that bypass this schema).
225
+ enum: [
226
+ 'converged',
227
+ 'max_rounds_reached',
228
+ 'aborted_by_validation',
229
+ 'aborted_by_config',
230
+ 'schema_invalid',
231
+ 'no_usable_reviewer_output',
232
+ 'inconclusive',
233
+ ],
234
+ description: 'For `capture`: why the run ended — "converged" | "max_rounds_reached" | "aborted_by_validation" | "aborted_by_config" | "schema_invalid" | "no_usable_reviewer_output" | "inconclusive". When omitted, derived from the convergence trend (trailing 0 = converged, otherwise = max_rounds_reached once a round ran).' },
219
235
  // `string | null` (NOT plain `type: 'string'`): dsh validates arguments
220
236
  // against the compiled schema BEFORE execute, and the docs — plus the
221
237
  // client's clear button — advertise `{"operation":"nudge","text":null}`.
@@ -311,49 +327,62 @@ export function registerTranscriptTool(ctx: {
311
327
  }
312
328
 
313
329
  if (args.operation === 'nudge') {
314
- let manifest: TranscriptManifest | null = null
315
- if (existsSync(file)) {
316
- try {
317
- const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
318
- manifest = parsed
319
- } catch {
320
- manifest = null
330
+ // Read-modify-write across processes: a concurrent nudge in a sibling
331
+ // process would otherwise read the same manifest and write back over
332
+ // each other's steering text. Serialize on the shared transcript lock
333
+ // (same pattern as the config/triage writers). The body still awaits,
334
+ // so the lock is held across the read and write; a same-process
335
+ // re-entry degrades to unlocked (the advisory lock is per-pid), which
336
+ // is the documented best-effort fallback for a low-value projection.
337
+ return withProjectLock(projectRoot, 'transcript', async () => {
338
+ let manifest: TranscriptManifest | null = null
339
+ if (existsSync(file)) {
340
+ try {
341
+ const parsed = JSON.parse(await readFile(file, 'utf-8')) as unknown as TranscriptManifest
342
+ manifest = parsed
343
+ } catch {
344
+ manifest = null
345
+ }
321
346
  }
322
- }
323
- // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
324
- // or a non-object) must never crash the nudge — degrade to a fresh
325
- // builder so the steering text still lands. The fallback must keep
326
- // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
327
- // stray malformed field can never silently rewrite a dry-run run
328
- // into a `normal` one.
329
- const fallbackOpts = {
330
- project: projectRoot,
331
- mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
332
- taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
333
- approval,
334
- goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
335
- maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
336
- }
337
- let builder: ReviewTranscriptBuilder
338
- if (manifest) {
339
- try {
340
- builder = rehydrateBuilder(manifest, approval)
341
- } catch {
342
- builder = new ReviewTranscriptBuilder(fallbackOpts)
347
+ // Rehydrating a parsed-but-malformed manifest (e.g. `rounds: [null]`
348
+ // or a non-object) must never crash the nudge — degrade to a fresh
349
+ // builder so the steering text still lands. The fallback must keep
350
+ // the ORIGINAL run identity (mode/taskMode/goal/maxRounds) so a
351
+ // stray malformed field can never silently rewrite a dry-run run
352
+ // into a `normal` one.
353
+ const fallbackOpts = {
354
+ project: projectRoot,
355
+ mode: (manifest?.mode === 'dry-run' || manifest?.mode === 'normal' ? manifest.mode : null) as 'dry-run' | 'normal' | null,
356
+ taskMode: (manifest?.taskMode === 'code' || manifest?.taskMode === 'iterate' ? manifest.taskMode : null) as 'code' | 'iterate' | null,
357
+ approval,
358
+ goal: typeof manifest?.goal === 'string' ? manifest.goal : '',
359
+ maxRounds: typeof manifest?.maxRounds === 'number' ? manifest.maxRounds : 0,
343
360
  }
344
- } else {
345
- builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: 'normal', approval })
346
- }
347
- builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
348
- const persisted = await persistChecked(file, builder.serialize())
349
- if (!persisted.ok) {
350
- return { operation: 'nudge', updated: false, error: persisted.error }
351
- }
352
- return {
353
- operation: 'nudge',
354
- updated: true,
355
- transcript: builder.serialize() as unknown as JsonValue,
356
- }
361
+ let builder: ReviewTranscriptBuilder
362
+ if (manifest) {
363
+ try {
364
+ builder = rehydrateBuilder(manifest, approval)
365
+ } catch {
366
+ builder = new ReviewTranscriptBuilder(fallbackOpts)
367
+ }
368
+ } else {
369
+ // No manifest yet: the run's mode is genuinely unknown at nudge
370
+ // time (capture sets it). Recording `mode: 'normal'` here would
371
+ // rewrite a dry-run run into a `normal` one in the status/badge
372
+ // until capture lands — `null` honestly says "not captured yet".
373
+ builder = new ReviewTranscriptBuilder({ project: projectRoot, mode: null, approval })
374
+ }
375
+ builder.setNudge(typeof args.text === 'string' && args.text.trim() ? args.text : null)
376
+ const persisted = await persistChecked(file, builder.serialize())
377
+ if (!persisted.ok) {
378
+ return { operation: 'nudge', updated: false, error: persisted.error }
379
+ }
380
+ return {
381
+ operation: 'nudge',
382
+ updated: true,
383
+ transcript: builder.serialize() as unknown as JsonValue,
384
+ }
385
+ })
357
386
  }
358
387
 
359
388
  // capture
@@ -6,6 +6,7 @@ import yaml from 'js-yaml'
6
6
  import { resolveProjectRootForExec } from '../config-loader.ts'
7
7
  import { writeTextAtomic } from '../atomic-fs.ts'
8
8
  import { configBackupSuffix } from '../config-write.ts'
9
+ import { withProjectLock } from '../file-lock.ts'
9
10
  import { WHOLE_FILE_LINE } from '../evidence.ts'
10
11
  import type { KnownIntentional } from '../types.ts'
11
12
 
@@ -268,56 +269,62 @@ function applyEntries(
268
269
  error: string
269
270
  } {
270
271
  const configPath = join(projectRoot, CONFIG_FILE)
271
- let config: Record<string, unknown>
272
- try {
273
- config = readConfigFile(configPath)
274
- } catch (err) {
275
- // The file exists but is malformed — refuse to overwrite user data.
276
- return { ok: false, error: `Failed to read config: ${String(err)}` }
277
- }
278
- const existing = readKnownIntentional(config)
279
- const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
280
- const nextConfig = buildConfigWithKnownIntentional(config, merged)
272
+ // Serialize against `iterate_config write` (SAME lock name) so a triage apply
273
+ // and a config write on one project cannot interleave their read-modify-write
274
+ // and silently drop each other's updates. The lock spans read → merge →
275
+ // backup → write → backup-prune so the whole cycle is atomic.
276
+ return withProjectLock(projectRoot, 'config-write', () => {
277
+ let config: Record<string, unknown>
278
+ try {
279
+ config = readConfigFile(configPath)
280
+ } catch (err) {
281
+ // The file exists but is malformed — refuse to overwrite user data.
282
+ return { ok: false, error: `Failed to read config: ${String(err)}` }
283
+ }
284
+ const existing = readKnownIntentional(config)
285
+ const { merged, added, skipped, dropped } = mergeKnownIntentional(existing, incoming)
286
+ const nextConfig = buildConfigWithKnownIntentional(config, merged)
281
287
 
282
- const hadFile = existsSync(configPath)
283
- const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
288
+ const hadFile = existsSync(configPath)
289
+ const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
290
+
291
+ if (backupPath) {
292
+ try {
293
+ copyFileSync(configPath, backupPath)
294
+ } catch (err) {
295
+ return {
296
+ ok: false,
297
+ error: `Failed to create backup: ${String(err)}`,
298
+ }
299
+ }
300
+ }
284
301
 
285
- if (backupPath) {
302
+ const yamlText = yaml.dump(nextConfig, { noRefs: true })
286
303
  try {
287
- copyFileSync(configPath, backupPath)
304
+ writeTextAtomic(configPath, yamlText)
288
305
  } catch (err) {
306
+ // Rollback: restore the backup, or REMOVE the file we just created when
307
+ // there was no prior config — an empty file left behind would poison all
308
+ // future config reads (empty YAML is not a valid mapping).
309
+ let rollbackError = ''
310
+ try {
311
+ if (backupPath) copyFileSync(backupPath, configPath)
312
+ else if (existsSync(configPath)) rmSync(configPath, { force: true })
313
+ } catch (rbErr) {
314
+ rollbackError = `; rollback also failed: ${String(rbErr)}`
315
+ }
289
316
  return {
290
317
  ok: false,
291
- error: `Failed to create backup: ${String(err)}`,
318
+ error: `Failed to write config: ${String(err)}${rollbackError}`,
292
319
  }
293
320
  }
294
- }
295
-
296
- const yamlText = yaml.dump(nextConfig, { noRefs: true })
297
- try {
298
- writeTextAtomic(configPath, yamlText)
299
- } catch (err) {
300
- // Rollback: restore the backup, or REMOVE the file we just created when
301
- // there was no prior config — an empty file left behind would poison all
302
- // future config reads (empty YAML is not a valid mapping).
303
- let rollbackError = ''
304
- try {
305
- if (backupPath) copyFileSync(backupPath, configPath)
306
- else if (existsSync(configPath)) rmSync(configPath, { force: true })
307
- } catch (rbErr) {
308
- rollbackError = `; rollback also failed: ${String(rbErr)}`
309
- }
310
- return {
311
- ok: false,
312
- error: `Failed to write config: ${String(err)}${rollbackError}`,
313
- }
314
- }
315
321
 
316
- // Success: bound the accumulation of timestamped backups so a long-lived
317
- // project never collects an unbounded pile of config snapshots.
318
- if (backupPath) pruneOldConfigBackups(configPath)
322
+ // Success: bound the accumulation of timestamped backups so a long-lived
323
+ // project never collects an unbounded pile of config snapshots.
324
+ if (backupPath) pruneOldConfigBackups(configPath)
319
325
 
320
- return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
326
+ return { ok: true, added, skipped, dropped, count: merged.length, configPath, backupPath }
327
+ })
321
328
  }
322
329
 
323
330
  /**
package/src/transcript.ts CHANGED
@@ -565,7 +565,7 @@ export class ReviewTranscriptBuilder {
565
565
  return
566
566
  }
567
567
  this.checkpoint = {
568
- mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : 'normal',
568
+ mode: state.mode === 'dry-run' || state.mode === 'normal' ? state.mode : null,
569
569
  round: typeof state.round === 'number' ? state.round : 0,
570
570
  maxRounds: typeof state.maxRounds === 'number' ? state.maxRounds : 0,
571
571
  fixedCount: typeof state.fixedCount === 'number' ? state.fixedCount : 0,
@@ -804,7 +804,7 @@ function normalizeCheckpointRow(input: unknown): TranscriptCheckpoint | null {
804
804
  const round = typeof c.round === 'number' && Number.isFinite(c.round) ? c.round : 0
805
805
  if (round <= 0) return null
806
806
  return {
807
- mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : 'normal',
807
+ mode: c.mode === 'dry-run' || c.mode === 'normal' ? c.mode : null,
808
808
  round: clampRoundValue(c.round),
809
809
  maxRounds: clampCountValue(c.maxRounds),
810
810
  fixedCount: clampCountValue(c.fixedCount),
package/src/types.ts CHANGED
@@ -320,7 +320,7 @@ export interface TranscriptEntry {
320
320
 
321
321
  /** Checkpoint summary surfaced for resume actions (F5). */
322
322
  export interface TranscriptCheckpoint {
323
- mode: 'dry-run' | 'normal'
323
+ mode: 'dry-run' | 'normal' | null
324
324
  round: number
325
325
  maxRounds: number
326
326
  fixedCount: number
@@ -0,0 +1,126 @@
1
+ /**
2
+ * src/working-directory.ts — follow dsh's mutable session working directory.
3
+ *
4
+ * Upstream history:
5
+ * - through dsh 0.2.1-alpha.1 the session's working directory WAS the
6
+ * immutable `Session.header.cwd`, so reading `exec.agent.session.header.cwd`
7
+ * was authoritative;
8
+ * - dsh 0.2.1-alpha.2 added the `working_directory` tool and the
9
+ * `WorkingDirectoryService` (`@deepseek-ai/dsh-working-directory`), whose
10
+ * contract explicitly states "the immutable Session header retains the
11
+ * ORIGINAL project" while `working-directory/change` events record the
12
+ * current one. After a model/user switches directories mid-session,
13
+ * `header.cwd` is therefore stale BY DESIGN.
14
+ *
15
+ * The iterate plugin resolves the project root (where `.iterate/` lives, where
16
+ * fixes are written, and — critically — which workspace the F8 approval policy
17
+ * is read from) from the session cwd. Sticking to `header.cwd` on a host that
18
+ * supports directory switches would silently operate on the wrong project:
19
+ * reviews would anchor to the original checkout while the model edits files
20
+ * elsewhere, and a `deny` policy in the active workspace would be read from
21
+ * the original one.
22
+ *
23
+ * So the effective directory is resolved through the host's service when it
24
+ * exists, and degrades to `header.cwd` otherwise:
25
+ * - feature-detected (`ctx.get('workingDirectory')`) — hosts older than
26
+ * 0.2.1-alpha.2 simply have no such service and keep the old behavior;
27
+ * - `service.get(session)` is the service's synchronous, I/O-free read of
28
+ * the committed directory, so this stays safe on the approval path;
29
+ * - every failure mode (no service, service throws, hostile getters, a
30
+ * non-string result) falls back to `header.cwd`, never to `undefined`
31
+ * where a header cwd exists.
32
+ *
33
+ * The service object is fetched lazily on every call, not once at plugin
34
+ * apply: dsh may register the service after this plugin loads, and a disposed
35
+ * context must degrade rather than cache a dead handle.
36
+ */
37
+
38
+ /** Minimal structural view of dsh's `WorkingDirectoryService` (0.2.1-alpha.2+). */
39
+ interface WorkingDirectoryServiceLike {
40
+ get(session: unknown): string
41
+ }
42
+
43
+ /** Minimal structural view of the cordis context surface this module uses. */
44
+ interface ContextLike {
45
+ get?(name: string, strict?: boolean): unknown
46
+ }
47
+
48
+ /** The plugin context, installed once by `apply()`; null on hosts without it. */
49
+ let installedContext: ContextLike | null = null
50
+
51
+ /** True when `value` looks like a cordis context carrying `ctx.get`. */
52
+ function isContextLike(value: unknown): value is ContextLike {
53
+ return (
54
+ typeof value === 'object' &&
55
+ value !== null &&
56
+ typeof (value as ContextLike).get === 'function'
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Install the plugin context used to look up the `workingDirectory` service.
62
+ * Passing anything without a callable `get` clears the installation (tests and
63
+ * hosts that load the plugin into a degraded context).
64
+ */
65
+ export function installWorkingDirectoryContext(ctx: unknown): void {
66
+ installedContext = isContextLike(ctx) ? ctx : null
67
+ }
68
+
69
+ /** Forget the installed context (test isolation / unload). */
70
+ export function resetWorkingDirectoryContext(): void {
71
+ installedContext = null
72
+ }
73
+
74
+ /**
75
+ * Read the host's committed working directory for `session`, if the host
76
+ * provides the service and it answers with a usable absolute path.
77
+ *
78
+ * Returns `undefined` for every degradation path so callers keep their
79
+ * previous `header.cwd` behavior.
80
+ */
81
+ function serviceWorkingDirectory(session: unknown): string | undefined {
82
+ if (session === undefined || session === null) return undefined
83
+ const ctx = installedContext
84
+ if (!ctx) return undefined
85
+ try {
86
+ const service = ctx.get?.('workingDirectory')
87
+ if (!service || typeof (service as WorkingDirectoryServiceLike).get !== 'function') return undefined
88
+ const dir = (service as WorkingDirectoryServiceLike).get(session)
89
+ // A relative or empty directory would move the project root somewhere
90
+ // unspecified — refuse it and keep the header value instead.
91
+ if (typeof dir !== 'string' || dir.length === 0) return undefined
92
+ return dir
93
+ } catch {
94
+ // Missing/disposed service, unpublished session, or a throwing getter —
95
+ // degrade to the historical source rather than failing the tool call.
96
+ return undefined
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The effective working directory of a session: the host's committed
102
+ * directory when available, else the immutable session header cwd.
103
+ *
104
+ * Returns `undefined` only when neither source yields a usable path.
105
+ */
106
+ export function effectiveSessionCwd(session: unknown, headerCwd?: string): string | undefined {
107
+ const fromService = serviceWorkingDirectory(session)
108
+ if (fromService !== undefined) return fromService
109
+ return typeof headerCwd === 'string' && headerCwd.length > 0 ? headerCwd : undefined
110
+ }
111
+
112
+ /**
113
+ * Extract the effective cwd from a tool execution (`exec.agent.session`),
114
+ * reading every property defensively: a hostile/proxied exec must degrade to
115
+ * the fallback path, never throw out of an approval or capture hook.
116
+ */
117
+ export function sessionCwdOf(exec: unknown): string | undefined {
118
+ try {
119
+ const agent = (exec as { agent?: { session?: { header?: { cwd?: unknown } } } } | null | undefined)?.agent
120
+ const session = agent?.session
121
+ const headerCwd = typeof agent?.session?.header?.cwd === 'string' ? agent.session.header.cwd : undefined
122
+ return effectiveSessionCwd(session, headerCwd)
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }