dsh-permission-rules 0.3.0 → 0.4.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.
package/src/runtime.ts CHANGED
@@ -5,11 +5,14 @@
5
5
  * deny/ask decision (and NEVER short-circuits on allow or passthrough),
6
6
  * the `permissionRules/decision` audit event, the `/rules` session command
7
7
  * (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
8
- * Chokidar-driven reloads. Every registration is an effect.
8
+ * Chokidar-driven reloads (effective rule files plus candidate watches on
9
+ * expected-but-absent files, so mid-session creation is adopted). Every
10
+ * registration is an effect.
9
11
  * @module dsh-permission-rules/runtime
10
12
  */
11
13
 
12
14
  import { existsSync, readFileSync } from 'node:fs'
15
+ import { createRequire } from 'node:module'
13
16
  import { dirname, isAbsolute, join, resolve } from 'node:path'
14
17
  import chokidar from 'chokidar'
15
18
  import type { Context } from '@deepseek-ai/cordis'
@@ -17,10 +20,11 @@ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands
17
20
  import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
18
21
  import { resolveConfig } from './config.ts'
19
22
  import type { Config, ResolvedConfig } from './config.ts'
20
- import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, parseRulesDocument, RuleError } from './rules.ts'
23
+ import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
21
24
  import type { CompileOptions, CompiledRuleset, MatchContext, RuleHit } from './rules.ts'
22
25
  import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
23
26
  import type { UiProse } from './prose.ts'
27
+ import { isMarkedAuditEvent } from './events.ts'
24
28
  import type { AuditAppend, AuditDecision, DecisionOutcome } from './events.ts'
25
29
 
26
30
  export const name = 'permission-rules'
@@ -41,6 +45,13 @@ interface LoadedRules {
41
45
  readonly failed?: true
42
46
  }
43
47
 
48
+ /** One live watcher: rule-file watchers key by the file path; candidate watchers key by an ancestor directory and map each cwd to the absent file they are waiting for. */
49
+ interface WatcherEntry {
50
+ readonly cwds: Set<string>
51
+ readonly close: () => void
52
+ readonly candidates?: Map<string, string>
53
+ }
54
+
44
55
  /**
45
56
  * State and behavior. One instance per plugin mount; disposals are owned by
46
57
  * the watcher/timer effects registered in {@link apply}.
@@ -49,12 +60,15 @@ export class PermissionRulesRuntime {
49
60
  /** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
50
61
  private readonly byCwd = new Map<string, LoadedRules>()
51
62
 
52
- /** Live watchers per rule-file path, with the cwds each serves. */
53
- private readonly watchers = new Map<string, { readonly cwds: Set<string>; readonly close: () => void }>()
63
+ /** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
64
+ private readonly watchers = new Map<string, WatcherEntry>()
54
65
 
55
66
  /** Debounce timers per rule-file path. */
56
67
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
57
68
 
69
+ /** Whether the host honors the audit envelope's `ignorable` marker: unknown until the first decision (or peer-version check). */
70
+ private auditSupport: 'unknown' | 'supported' | 'unsupported' = 'unknown'
71
+
58
72
  constructor(
59
73
  private readonly ctx: Context,
60
74
  readonly config: ResolvedConfig,
@@ -132,6 +146,18 @@ export class PermissionRulesRuntime {
132
146
  }
133
147
  }
134
148
 
149
+ /**
150
+ * Canonical per-workspace cache key: the resolved root, case-folded on
151
+ * Windows so differently-spelled paths to the same workspace share one
152
+ * cache entry and one watcher set instead of doubling both.
153
+ * @param cwd - the session's absolute workspace root.
154
+ * @returns the canonical key.
155
+ */
156
+ private cacheKey(cwd: string): string {
157
+ const resolved = resolve(cwd)
158
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
159
+ }
160
+
135
161
  /**
136
162
  * The rules in effect for one cwd, loading on first use. Under
137
163
  * `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
@@ -143,26 +169,27 @@ export class PermissionRulesRuntime {
143
169
  * @returns the loaded rules.
144
170
  */
145
171
  rulesFor(cwd: string): LoadedRules {
146
- const existing = this.byCwd.get(cwd)
172
+ const key = this.cacheKey(cwd)
173
+ const existing = this.byCwd.get(key)
147
174
  if (existing !== undefined) {
148
175
  if (existing.failed === true) {
149
176
  throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`)
150
177
  }
151
- this.byCwd.delete(cwd)
152
- this.byCwd.set(cwd, existing)
178
+ this.byCwd.delete(key)
179
+ this.byCwd.set(key, existing)
153
180
  return existing
154
181
  }
155
- this.evictIfFull(cwd)
182
+ this.evictIfFull(key)
156
183
  try {
157
184
  const loaded = this.load(cwd)
158
- this.byCwd.set(cwd, loaded)
159
- this.reconcileWatch(cwd, loaded.sources)
185
+ this.byCwd.set(key, loaded)
186
+ this.reconcileWatch(key, loaded.sources)
160
187
  return loaded
161
188
  } catch (error: unknown) {
162
189
  const message = error instanceof Error ? error.message : String(error)
163
190
  const sources = this.resolveSources(cwd)
164
- this.byCwd.set(cwd, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
165
- this.reconcileWatch(cwd, sources)
191
+ this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
192
+ this.reconcileWatch(key, sources)
166
193
  throw error
167
194
  }
168
195
  }
@@ -175,17 +202,22 @@ export class PermissionRulesRuntime {
175
202
  * @param cwd - the workspace root.
176
203
  */
177
204
  reload(cwd: string): void {
178
- const previous = this.byCwd.get(cwd)
205
+ const key = this.cacheKey(cwd)
206
+ const previous = this.byCwd.get(key)
179
207
  try {
180
208
  const loaded = this.load(cwd)
181
- this.byCwd.set(cwd, loaded)
182
- this.reconcileWatch(cwd, loaded.sources)
209
+ this.byCwd.set(key, loaded)
210
+ this.reconcileWatch(key, loaded.sources)
183
211
  this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`)
184
212
  } catch (error: unknown) {
185
213
  const message = error instanceof Error ? error.message : String(error)
186
214
  this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`)
187
- if (previous !== undefined) this.byCwd.set(cwd, { ...previous, lastError: message })
188
- else this.byCwd.set(cwd, { cwd, sources: this.resolveSources(cwd), compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
215
+ if (previous !== undefined) this.byCwd.set(key, { ...previous, lastError: message })
216
+ else {
217
+ const sources = this.resolveSources(cwd)
218
+ this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
219
+ this.reconcileWatch(key, sources)
220
+ }
189
221
  }
190
222
  }
191
223
 
@@ -238,13 +270,17 @@ export class PermissionRulesRuntime {
238
270
  * Append the log-only `permissionRules/decision` audit event for every
239
271
  * decision (passthrough included unless `audit: 'hits'`), requesting the
240
272
  * envelope's `ignorable: true` marker so any harness build can load the
241
- * log (readers that do not know the type skip the audit record instead of
242
- * refusing the session). `source` names the matched rule's file, or the
243
- * nearest effective file on a passthrough; `cwd` names the workspace the
244
- * rules were resolved for; `dryRun` marks would-be deny/ask hits under
245
- * `enforce: false`. Agentless calls have no session to audit; append
246
- * failures are contained so an audit hiccup can never change a
247
- * permission decision.
273
+ * log. Hosts whose `Session.append` predates the marker (the rc.6 line)
274
+ * silently drop it, leaving sessions unresumable on stricter hosts — the
275
+ * runtime therefore detects such hosts BEFORE the first append (peer
276
+ * version) and re-checks after the first append (returned envelope), then
277
+ * degrades: session-log audit is disabled with a one-time warning unless
278
+ * `allowUnmarkedAudit: true` opts back in. `source` names the matched
279
+ * rule's file, or the nearest effective file on a passthrough; `cwd`
280
+ * names the workspace the rules were resolved for; `dryRun` marks
281
+ * would-be deny/ask hits under `enforce: false`. Agentless calls have no
282
+ * session to audit; append failures are contained so an audit hiccup can
283
+ * never change a permission decision.
248
284
  * @param exec - the pending call.
249
285
  * @param loaded - the rules in effect.
250
286
  * @param hit - the first matching rule, or undefined for passthrough.
@@ -255,8 +291,17 @@ export class PermissionRulesRuntime {
255
291
  if (this.config.audit === 'hits' && hit === undefined) return
256
292
  const agent = exec.agent
257
293
  if (agent === undefined) return
294
+ if (this.auditSupport === 'unsupported') return
295
+ if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
296
+ const version = this.peerVersion()
297
+ if (version !== null && isUnmarkedHostVersion(version)) {
298
+ this.auditSupport = 'unsupported'
299
+ this.warnUnmarkedAuditHost()
300
+ return
301
+ }
302
+ }
258
303
  try {
259
- ;(agent.session.append as unknown as AuditAppend)('permissionRules/decision', {
304
+ const result = this.appendAudit(agent, {
260
305
  toolName: exec.name,
261
306
  callId: exec.callId,
262
307
  source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
@@ -265,12 +310,42 @@ export class PermissionRulesRuntime {
265
310
  cwd: loaded.cwd,
266
311
  ...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
267
312
  ...dryRun ? { dryRun: true as const } : {},
268
- }, { ignorable: true })
313
+ })
314
+ if (this.auditSupport === 'unknown' && !this.config.allowUnmarkedAudit) {
315
+ if (isMarkedAuditEvent(result)) {
316
+ this.auditSupport = 'supported'
317
+ } else {
318
+ this.auditSupport = 'unsupported'
319
+ this.warnUnmarkedAuditHost()
320
+ }
321
+ }
269
322
  } catch (error: unknown) {
270
323
  this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`)
271
324
  }
272
325
  }
273
326
 
327
+ /** Append one audit event through the session surface; the probe seam for host-capability detection. */
328
+ private appendAudit(agent: NonNullable<ToolExecution['agent']>, data: AuditDecision): unknown {
329
+ return (agent.session.append as unknown as AuditAppend)('permissionRules/decision', data, { ignorable: true })
330
+ }
331
+
332
+ /** The installed `@deepseek-ai/dsh-session` version, or `null` when unresolvable (falls back to the append probe). */
333
+ private peerVersion(): string | null {
334
+ try {
335
+ const pkg = createRequire(import.meta.url)('@deepseek-ai/dsh-session/package.json') as { version?: unknown }
336
+ return typeof pkg.version === 'string' ? pkg.version : null
337
+ } catch {
338
+ return null
339
+ }
340
+ }
341
+
342
+ /** One-time warning that session-log audit was disabled to keep session logs loadable. */
343
+ private warnUnmarkedAuditHost(): void {
344
+ this.ctx.logger.warn(
345
+ 'permission-rules: this host drops the ignorable marker on audit events (Session.append predates it), which would make sessions unresumable on stricter harness builds — session-log audit is disabled; set allowUnmarkedAudit: true to opt back in, and repair existing logs with scripts/repair-session-logs.mjs (see https://github.com/PerryLink/dsh-permission-rules/issues/2)',
346
+ )
347
+ }
348
+
274
349
  /**
275
350
  * Execute the `/rules` command: bare `/rules` lists the active rules and
276
351
  * their sources; `/rules reload` re-reads the chain; `/rules decisions
@@ -290,12 +365,13 @@ export class PermissionRulesRuntime {
290
365
  if (verb === 'reload') {
291
366
  if (rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
292
367
  this.reload(cwd)
293
- const reloaded = this.byCwd.get(cwd)
368
+ const reloaded = this.byCwd.get(this.cacheKey(cwd))
294
369
  if (reloaded?.lastError !== undefined) return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) }
295
370
  const rules = reloaded?.compiled.rules ?? []
296
371
  const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ')
297
372
  return { kind: 'success', text: prose.reloaded(rules.length, source) }
298
373
  }
374
+ if (verb === 'list' && rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
299
375
  if (verb === 'decisions') {
300
376
  if (rest.length > 1) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
301
377
  let count = 10
@@ -309,7 +385,7 @@ export class PermissionRulesRuntime {
309
385
  if (verb === 'test') {
310
386
  return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose)
311
387
  }
312
- if (verb !== '') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
388
+ if (verb !== '' && verb !== 'list') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
313
389
  let loaded: LoadedRules
314
390
  try {
315
391
  loaded = this.rulesFor(cwd)
@@ -321,7 +397,8 @@ export class PermissionRulesRuntime {
321
397
  lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''))
322
398
  } else {
323
399
  lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd))
324
- lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language])))
400
+ const multiSource = loaded.sources.length > 1
401
+ lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)))
325
402
  const unreachable = findUnreachableRules(loaded.compiled)
326
403
  if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)))
327
404
  }
@@ -339,9 +416,11 @@ export class PermissionRulesRuntime {
339
416
  * Execute `/rules test` with optional leading flags: `--cwd <dir>`
340
417
  * evaluates against that workspace (rule discovery AND path
341
418
  * normalization), `--env KEY=VALUE` (repeatable) overrides host env for
342
- * `when.env` matching, and `--agent <selector>` (repeatable) supplies
343
- * agent-identity candidates for the `agents` dimension. The JSON
344
- * argument tail is kept verbatim, so quoted JSON survives unchanged.
419
+ * `when.env` matching, `--agent <selector>` (repeatable) supplies
420
+ * agent-identity candidates for the `agents` dimension, and
421
+ * `--platform <name>` overrides the host platform for `when.platform`.
422
+ * The JSON argument tail is kept verbatim, so quoted JSON survives
423
+ * unchanged.
345
424
  * @param raw - the full raw command input.
346
425
  * @param verbRaw - the verb as typed.
347
426
  * @param invocation - the received command invocation (session agent).
@@ -354,6 +433,7 @@ export class PermissionRulesRuntime {
354
433
  const envOverrides: Record<string, string> = {}
355
434
  const agentSelectors: string[] = []
356
435
  let testCwd: string | undefined
436
+ let testPlatform: string | undefined
357
437
  let parsed = nextToken(rest)
358
438
  while (parsed !== undefined && parsed.token.startsWith('--')) {
359
439
  const flag = parsed.token
@@ -369,6 +449,9 @@ export class PermissionRulesRuntime {
369
449
  envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1)
370
450
  } else if (flag === '--agent') {
371
451
  agentSelectors.push(value.token)
452
+ } else if (flag === '--platform') {
453
+ if (!PLATFORMS.includes(value.token)) return { kind: 'error', text: prose.testBadPlatform(value.token) }
454
+ testPlatform = value.token
372
455
  } else {
373
456
  return { kind: 'error', text: prose.testUnknownFlag(flag) }
374
457
  }
@@ -392,7 +475,7 @@ export class PermissionRulesRuntime {
392
475
  return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
393
476
  }
394
477
  const context: MatchContext = {
395
- platform: process.platform,
478
+ platform: testPlatform ?? process.platform,
396
479
  env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
397
480
  agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
398
481
  }
@@ -403,16 +486,28 @@ export class PermissionRulesRuntime {
403
486
  }
404
487
  }
405
488
 
489
+ /** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
490
+ private displaySource(source: string, cwd: string): string {
491
+ if (source.length === 0) return ''
492
+ const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths)
493
+ return relative.length > 0 ? relative : source
494
+ }
495
+
406
496
  /** Render the session's `permissionRules/decision` audit trail, newest last. */
407
497
  private decisionsCommand(invocation: CommandInvocation, count: number, prose: UiProse): CommandResult {
408
498
  const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision')
409
- if (decisions.length === 0) return { kind: 'success', text: prose.noDecisions }
410
- const shown = decisions.slice(-count)
411
- const lines = [prose.decisionsHeader(shown.length, decisions.length)]
412
- for (const event of shown) {
413
- const data = event.data as AuditDecision
414
- lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome))
499
+ const lines: string[] = []
500
+ if (decisions.length === 0) {
501
+ lines.push(prose.noDecisions)
502
+ } else {
503
+ const shown = decisions.slice(-count)
504
+ lines.push(prose.decisionsHeader(shown.length, decisions.length))
505
+ for (const event of shown) {
506
+ const data = event.data as AuditDecision
507
+ lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome))
508
+ }
415
509
  }
510
+ if (this.auditSupport === 'unsupported') lines.push(prose.auditDisabledNotice)
416
511
  return { kind: 'success', text: lines.join('\n') }
417
512
  }
418
513
 
@@ -487,18 +582,99 @@ export class PermissionRulesRuntime {
487
582
  * Rebind `cwd` to its current rule-source chain. When a concrete source
488
583
  * left the chain (deleted file, fallback switch), detach the workspace
489
584
  * from watchers serving other sources and close the ones left empty, so
490
- * long-running hosts cannot accumulate stale watchers. An empty chain (no
491
- * rule file in effect) keeps the previous watchers alive: they own the
492
- * "file recreated → adopted" signal, and the workspace-cache bound keeps
493
- * the set of such watchers finite.
585
+ * long-running hosts cannot accumulate stale watchers. Expected-but-absent
586
+ * rule files (the project file when it is not effective, and a configured
587
+ * fallback that does not exist) are covered by candidate watchers on
588
+ * their deepest existing ancestor directory, so a file created
589
+ * mid-session is adopted without a manual `/rules reload`. With
590
+ * `searchUp`, only the immediate cwd-level candidate is watched —
591
+ * deeper ancestors are discovered on the next load.
494
592
  */
495
593
  private reconcileWatch(cwd: string, sources: readonly string[]): void {
496
- if (sources.length === 0) return
497
594
  for (const [watchedSource, entry] of this.watchers) {
498
- if (!sources.includes(watchedSource)) entry.cwds.delete(cwd)
595
+ if (!sources.includes(watchedSource)) {
596
+ entry.cwds.delete(cwd)
597
+ entry.candidates?.delete(cwd)
598
+ }
499
599
  }
500
600
  this.pruneWatchers()
501
601
  for (const source of sources) this.attachWatch(cwd, source)
602
+ for (const candidate of this.candidateSources(cwd)) {
603
+ if (sources.includes(candidate) || existsSync(candidate)) continue
604
+ this.attachCandidateWatch(cwd, candidate)
605
+ }
606
+ }
607
+
608
+ /**
609
+ * The rule-file paths a workspace COULD be served by: the project file
610
+ * (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
611
+ * only under `searchUp`) plus the configured fallback.
612
+ */
613
+ private candidateSources(cwd: string): string[] {
614
+ const candidates: string[] = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)]
615
+ const fallback = this.config.fallbackPath
616
+ if (fallback !== undefined) candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback))
617
+ return candidates
618
+ }
619
+
620
+ /**
621
+ * Watch one expected-but-absent rule file through its deepest existing
622
+ * ancestor directory. Chokidar cannot reliably watch a missing path when
623
+ * its parent is also missing, but directory watching is dependable; every
624
+ * relevant event re-checks existence and only triggers a reload once the
625
+ * candidate actually appeared, so unrelated workspace activity while the
626
+ * file is absent costs a stat, not a reload.
627
+ */
628
+ private attachCandidateWatch(cwd: string, candidate: string): void {
629
+ if (!this.config.watch) return
630
+ const dir = this.deepestExistingDir(dirname(candidate))
631
+ const existing = this.watchers.get(dir)
632
+ if (existing !== undefined) {
633
+ existing.cwds.add(cwd)
634
+ existing.candidates?.set(cwd, candidate)
635
+ return
636
+ }
637
+ const cwds = new Set([cwd])
638
+ const candidates = new Map([[cwd, candidate]])
639
+ const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true })
640
+ const onEvent = (): void => this.scheduleReload(dir)
641
+ watcher.on('add', onEvent)
642
+ watcher.on('addDir', onEvent)
643
+ watcher.on('change', onEvent)
644
+ watcher.on('unlink', onEvent)
645
+ watcher.on('unlinkDir', onEvent)
646
+ watcher.on('error', (error: unknown) => {
647
+ this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`)
648
+ })
649
+ this.watchers.set(dir, {
650
+ cwds,
651
+ candidates,
652
+ close: () => {
653
+ void watcher.close().catch((error: unknown) => {
654
+ this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`)
655
+ })
656
+ },
657
+ })
658
+ this.ctx.effect(() => () => {
659
+ this.watchers.get(dir)?.close()
660
+ this.watchers.delete(dir)
661
+ const timer = this.timers.get(dir)
662
+ if (timer !== undefined) {
663
+ clearTimeout(timer)
664
+ this.timers.delete(dir)
665
+ }
666
+ })
667
+ }
668
+
669
+ /** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
670
+ private deepestExistingDir(dir: string): string {
671
+ let current = dir
672
+ for (;;) {
673
+ if (existsSync(current)) return current
674
+ const parent = dirname(current)
675
+ if (parent === current) return current
676
+ current = parent
677
+ }
502
678
  }
503
679
 
504
680
  /** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
@@ -527,7 +703,10 @@ export class PermissionRulesRuntime {
527
703
  const oldest = this.byCwd.keys().next().value
528
704
  if (oldest === undefined) return
529
705
  this.byCwd.delete(oldest)
530
- for (const entry of this.watchers.values()) entry.cwds.delete(oldest)
706
+ for (const entry of this.watchers.values()) {
707
+ entry.cwds.delete(oldest)
708
+ entry.candidates?.delete(oldest)
709
+ }
531
710
  this.pruneWatchers()
532
711
  }
533
712
 
@@ -541,13 +720,24 @@ export class PermissionRulesRuntime {
541
720
  return this.timers.size
542
721
  }
543
722
 
544
- /** Debounce watch events into one reload per stability window. */
723
+ /**
724
+ * Debounce watch events into one reload per stability window. A
725
+ * candidate (directory) watcher only reloads a cwd once its expected
726
+ * file actually exists — unrelated events while the file is absent are
727
+ * dropped before any re-read.
728
+ */
545
729
  private scheduleReload(source: string): void {
546
730
  const existing = this.timers.get(source)
547
731
  if (existing !== undefined) clearTimeout(existing)
548
732
  const timer = setTimeout(() => {
549
733
  this.timers.delete(source)
550
- for (const cwd of this.watchers.get(source)?.cwds ?? []) this.reload(cwd)
734
+ const entry = this.watchers.get(source)
735
+ if (entry === undefined) return
736
+ for (const cwd of entry.cwds) {
737
+ const candidate = entry.candidates?.get(cwd)
738
+ if (candidate !== undefined && !existsSync(candidate)) continue
739
+ this.reload(cwd)
740
+ }
551
741
  }, this.config.watchStabilityThresholdMs)
552
742
  this.timers.set(source, timer)
553
743
  }
@@ -568,11 +758,27 @@ export function apply(ctx: Context, config: Config): void {
568
758
  ctx.commands.register({
569
759
  name: 'rules',
570
760
  description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
571
- input: { hint: '[reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] <tool> <json-args>]' },
761
+ input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
572
762
  handler: invocation => runtime.command(invocation),
573
763
  })
574
764
  }
575
765
 
766
+ /**
767
+ * Whether a `@deepseek-ai/dsh-session` version line predates the
768
+ * `ignorable` envelope-marker surface: the `0.1.0-rc.6` line and earlier
769
+ * silently drop the marker from `Session.append` options, so audit events
770
+ * written by those builds land unmarked and break resume on stricter
771
+ * hosts. Non-matching (post-rc.6, stable, or unresolvable) versions are
772
+ * treated as possibly-marker-aware and verified by the append probe.
773
+ * @param version - the installed peer version string.
774
+ * @returns true for the known-unmarked rc.1–rc.6 lines.
775
+ */
776
+ export function isUnmarkedHostVersion(version: string): boolean {
777
+ const match = /^0\.1\.0-rc\.(\d+)$/.exec(version.trim())
778
+ if (match === null) return false
779
+ return Number(match[1]) <= 6
780
+ }
781
+
576
782
  /**
577
783
  * Identity candidates for the `agents` match dimension, derived from the
578
784
  * caller agent's session header: `main` for top-level sessions, `subagent`