iterate-plugin 3.6.0 → 3.7.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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * src/client/session-cache.ts — the newest session the session-scoped slots saw.
3
+ *
4
+ * Slot scopes in dsh are not uniform: `conversation.input.dock` (dashboard,
5
+ * observatory) renders inside a session and receives `props.session`, while
6
+ * `settings.section` renders at ROOT scope on the settings page and gets no
7
+ * session at all. The settings card nevertheless needs session-derived state —
8
+ * specifically the config the model echoed back through `iterate_config`, which
9
+ * is what closes the config write → read-back loop.
10
+ *
11
+ * Rather than fake a session for the root slot, the session-scoped slots
12
+ * publish what the host handed them and root-scoped panels subscribe:
13
+ *
14
+ * session slot renders → noteSession(props.session)
15
+ * settings panel → useSessionSnapshot() (via subscribeSession)
16
+ *
17
+ * Design constraints (mirrors `i18n.ts`):
18
+ * - **Pure**: no React, no DOM — the store is unit-testable on its own, and
19
+ * the React binding lives next to `useLang()` in index.ts.
20
+ * - **Identity-compared**: the same session object never notifies twice, so
21
+ * a slot that re-renders every frame cannot spin its subscribers.
22
+ * - **Never fabricated**: it only ever holds what the host really passed; an
23
+ * empty cache reads as "nothing echoed yet", which is the honest state.
24
+ */
25
+
26
+ /** Anything host-shaped; the plugins only ever pass it through opaque. */
27
+ export type CachedSession = unknown
28
+
29
+ let current: CachedSession = null
30
+
31
+ const listeners = new Set<() => void>()
32
+
33
+ /**
34
+ * Publish the session a session-scoped slot was rendered with.
35
+ * Notifies subscribers only when the identity actually changes.
36
+ */
37
+ export function noteSession(session: CachedSession): void {
38
+ if (Object.is(session, current)) return
39
+ current = session
40
+ for (const listener of [...listeners]) listener()
41
+ }
42
+
43
+ /** Last published session (or `null` before any session slot has rendered). */
44
+ export function readSession(): CachedSession {
45
+ return current
46
+ }
47
+
48
+ /** Subscribe to session changes; returns the unsubscribe function. */
49
+ export function subscribeSession(listener: () => void): () => void {
50
+ listeners.add(listener)
51
+ return () => {
52
+ listeners.delete(listener)
53
+ }
54
+ }
@@ -3,6 +3,7 @@ import { homedir } from 'node:os'
3
3
  import { basename, dirname, join, resolve, sep } from 'node:path'
4
4
  import yaml from 'js-yaml'
5
5
  import type { IterateConfig } from './types.ts'
6
+ import { sessionCwdOf } from './working-directory.ts'
6
7
 
7
8
  /**
8
9
  * Load and parse iterate.config.yaml from the project root.
@@ -748,12 +749,17 @@ export function resolveProjectRoot(input?: string, sessionCwd?: string): Project
748
749
  /**
749
750
  * Thin adapter for tool `execute(args, exec)` bodies: pull the session cwd
750
751
  * from the DSH run context and hand it to {@link resolveProjectRoot}.
752
+ *
753
+ * The cwd source is the EFFECTIVE session directory (dsh 0.2.1-alpha.2's
754
+ * `workingDirectory` service when the host provides one, else the immutable
755
+ * `header.cwd` — see `./working-directory.ts`), so a session whose directory
756
+ * was switched mid-run still resolves the project it is actually editing.
751
757
  */
752
758
  export function resolveProjectRootForExec(
753
759
  exec: { agent?: { session?: { header?: { cwd?: string } } } } | undefined,
754
760
  input?: string,
755
761
  ): ProjectRootResult {
756
- return resolveProjectRoot(input, exec?.agent?.session?.header?.cwd)
762
+ return resolveProjectRoot(input, sessionCwdOf(exec))
757
763
  }
758
764
 
759
765
  /**
package/src/file-lock.ts CHANGED
@@ -151,15 +151,42 @@ export function acquireProjectLock(
151
151
  } catch {
152
152
  // best-effort
153
153
  }
154
- let released = false
155
- return () => {
156
- if (released) return
157
- released = true
158
- try {
159
- unlinkSync(lockPath)
160
- } catch {
161
- // already gone
162
- }
154
+ return makeLockRelease(lockPath)
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Build the release function for a lock file this process just acquired.
160
+ *
161
+ * Advisory locks here are STOLEN when the recorded owner dies or the mtime
162
+ * goes ancient: the steal unlinks our file and creates a new one stamped with
163
+ * the STEALER's pid. An unguarded release would then unlink the new holder's
164
+ * lock — turning our release into a double-release that admits a third process
165
+ * into a "held" critical section. So the release re-reads the stamp and only
166
+ * unlinks a file that is still ours (same pid) or unstamped (the shape left
167
+ * when our own advisory pid write failed, or when the content is not a pid).
168
+ *
169
+ * Always callable, idempotent, and never throws.
170
+ */
171
+ export function makeLockRelease(lockPath: string): () => void {
172
+ let released = false
173
+ return () => {
174
+ if (released) return
175
+ released = true
176
+ let raw = ''
177
+ try {
178
+ raw = readFileSync(lockPath, 'utf-8').trim()
179
+ } catch {
180
+ // Already gone (or unreadable) — nothing of ours left to remove.
181
+ return
182
+ }
183
+ // A stamp naming another pid means the lock was stolen and reassigned:
184
+ // it is no longer ours to delete.
185
+ if (raw !== '' && Number(raw) !== process.pid) return
186
+ try {
187
+ unlinkSync(lockPath)
188
+ } catch {
189
+ // already gone
163
190
  }
164
191
  }
165
192
  }
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ import { registerQualityGateTool } from './tools/quality-gate.ts'
45
45
  import { registerDefenseEventsTool } from './tools/defense-events.ts'
46
46
  import { registerSessionHooks } from './session-hooks.ts'
47
47
  import { registerLiveCapture } from './live.ts'
48
+ import { installWorkingDirectoryContext } from './working-directory.ts'
48
49
  import { ITERATE_SKILL_PROMPT } from './skill-prompt.ts'
49
50
 
50
51
  export const name = 'iterate-plugin'
@@ -58,6 +59,12 @@ export function apply(ctx: Context): void {
58
59
  // running with NO `tools/pre-execute` approval gate — a fail-open hole
59
60
  // created by load order. Registering the gate first means a partial load
60
61
  // can only ever err on the side of MORE gating, never less.
62
+ //
63
+ // The working-directory context is installed first for the same reason:
64
+ // every cwd read below (gate policy, live capture, tool project roots)
65
+ // must see dsh 0.2.1-alpha.2+ directory switches, and installing it last
66
+ // would leave a partially-loaded plugin resolving the wrong workspace.
67
+ installWorkingDirectoryContext(ctx)
61
68
  registerSessionHooks(ctx)
62
69
  registerLiveCapture(ctx)
63
70
 
package/src/live.ts CHANGED
@@ -34,6 +34,7 @@ import { join } from 'node:path'
34
34
  import type { Context } from '@deepseek-ai/cordis'
35
35
  import type { ToolExecution } from '@deepseek-ai/dsh-tools'
36
36
  import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
37
+ import { sessionCwdOf } from './working-directory.ts'
37
38
 
38
39
  /** Keep at most this many live activity entries. */
39
40
  export const LIVE_MAX_ENTRIES = 300
@@ -140,7 +141,7 @@ export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promi
140
141
 
141
142
  /** Resolve the project root a tool execution belongs to, if any. */
142
143
  function projectRootOf(exec: ToolExecution): string | null {
143
- const cwd = exec.agent?.session?.header?.cwd
144
+ const cwd = sessionCwdOf(exec)
144
145
  if (!cwd) return null
145
146
  const resolved = resolveProjectRoot(undefined, cwd)
146
147
  return resolved.ok ? resolved.root : null
@@ -55,6 +55,7 @@
55
55
 
56
56
  import { loadEffectiveConfig, resolveProjectRoot } from './config-loader.ts'
57
57
  import { decideApproval, isDestructiveIterateTool } from './approval-gate.ts'
58
+ import { sessionCwdOf } from './working-directory.ts'
58
59
  import type { Context } from '@deepseek-ai/cordis'
59
60
  import type { ToolErrorInfo, ToolExecution, PreToolDecision } from '@deepseek-ai/dsh-tools'
60
61
 
@@ -114,7 +115,11 @@ export function gateDecision(exec: ToolExecution): PreToolDecision {
114
115
  const p = (args as Record<string, unknown>).path
115
116
  if (typeof p === 'string') argPath = p
116
117
  }
117
- sessionCwd = exec?.agent?.session?.header?.cwd
118
+ // Effective session directory (see ./working-directory.ts): a session whose
119
+ // directory was switched via dsh 0.2.1-alpha.2's `working_directory` tool
120
+ // must have its POLICY read from the workspace it is now in — `header.cwd`
121
+ // alone would read the original project's approval policy.
122
+ sessionCwd = sessionCwdOf(exec)
118
123
  } catch {
119
124
  // hostile/proxied exec — fall through with both undefined (defaults to ask)
120
125
  }
@@ -13,6 +13,7 @@ import { join } from 'node:path'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
16
+ import { makeLockRelease } from '../file-lock.ts'
16
17
  import type { DecisionLogEntry } from '../types.ts'
17
18
 
18
19
  const LOG_DIR = '.iterate'
@@ -124,16 +125,13 @@ export function acquireLogLockChecked(
124
125
  continue
125
126
  }
126
127
  // Owned the lock: stamp the holder pid, then release once on return.
128
+ // The release is ownership-checked (see `makeLockRelease`): a lock stolen
129
+ // from this process after its owner went stale must not be unlinked by us.
127
130
  try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
128
131
  try { closeSync(fd) } catch { /* best-effort */ }
129
- let released = false
130
132
  return {
131
133
  acquired: true,
132
- release: () => {
133
- if (released) return
134
- released = true
135
- try { unlinkSync(lockPath) } catch { /* already gone */ }
136
- },
134
+ release: makeLockRelease(lockPath),
137
135
  }
138
136
  }
139
137
  }
@@ -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
+ }