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.
- package/README.md +37 -6
- package/README.zh-CN.md +29 -5
- package/dist/config-loader.js +7 -1
- package/dist/file-lock.js +40 -12
- package/dist/index.js +7 -0
- package/dist/live.js +2 -1
- package/dist/session-hooks.js +6 -1
- package/dist/tools/decision-log.js +4 -10
- package/dist/working-directory.js +114 -0
- package/lib/client.js +1097 -461
- package/lib/parse.js +253 -58
- package/package.json +16 -11
- package/src/client/i18n.ts +517 -0
- package/src/client/index.ts +531 -340
- package/src/client/session-cache.ts +54 -0
- package/src/config-loader.ts +7 -1
- package/src/file-lock.ts +36 -9
- package/src/index.ts +7 -0
- package/src/live.ts +2 -1
- package/src/session-hooks.ts +6 -1
- package/src/tools/decision-log.ts +4 -6
- package/src/working-directory.ts +126 -0
|
@@ -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
|
+
}
|
package/src/config-loader.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
package/src/session-hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|