@jkwd/inbase 0.1.3 → 0.1.5

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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
+ import { spawnSync } from 'node:child_process'
4
5
  import {
5
6
  accumulatePatchAdditions,
6
7
  applyUnifiedPatch,
@@ -11,6 +12,7 @@ import {
11
12
  } from './patch-lib.mjs'
12
13
 
13
14
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
15
+ const CONNECTED_TTL_MS = 15_000
14
16
 
15
17
  export function assertSessionId(value) {
16
18
  if (typeof value !== 'string' || !SESSION_ID.test(value) || value === '.' || value === '..') {
@@ -44,9 +46,67 @@ export function sessionPaths(dataDir, sessionId) {
44
46
  diffs: path.join(root, 'diffs'),
45
47
  manifest: path.join(root, 'manifest.json'),
46
48
  blueprint: path.join(root, 'blueprint.json'),
49
+ baseline: path.join(root, 'baseline.json'),
50
+ baselineFiles: path.join(root, 'baseline'),
51
+ stopped: path.join(dataDir, 'diff-sessions', `${safeId}.stopped`),
47
52
  }
48
53
  }
49
54
 
55
+ export function sessionStoppedError(sessionId) {
56
+ return new Error(
57
+ `VISUAL_CODER_STOPPED Session ${assertSessionId(sessionId)} was stopped. Do not modify project files.`,
58
+ )
59
+ }
60
+
61
+ export function isSessionStopped(dataDir, sessionId) {
62
+ return fs.existsSync(sessionPaths(dataDir, sessionId).stopped)
63
+ }
64
+
65
+ export function isWorkflowStopped(dataDir, sessionId) {
66
+ const safeId = assertSessionId(sessionId)
67
+ const manifest = readManifest(dataDir, safeId)
68
+ if (manifest?.phase === 'stopped') return true
69
+ return !manifest && isSessionStopped(dataDir, safeId)
70
+ }
71
+
72
+ function writeStoppedMarker(dataDir, sessionId) {
73
+ const { stopped } = sessionPaths(dataDir, sessionId)
74
+ atomicWrite(
75
+ stopped,
76
+ `${JSON.stringify({ sessionId: assertSessionId(sessionId), stoppedAt: new Date().toISOString() }, null, 2)}\n`,
77
+ )
78
+ }
79
+
80
+ function clearStoppedMarker(dataDir, sessionId) {
81
+ const { stopped } = sessionPaths(dataDir, sessionId)
82
+ if (fs.existsSync(stopped)) fs.unlinkSync(stopped)
83
+ }
84
+
85
+ function requireManifest(dataDir, sessionId, missingMessage) {
86
+ const safeId = assertSessionId(sessionId)
87
+ const manifest = readManifest(dataDir, safeId)
88
+ if (manifest) return manifest
89
+ if (isSessionStopped(dataDir, safeId)) throw sessionStoppedError(safeId)
90
+ throw new Error(missingMessage ?? `Unknown session ${safeId}`)
91
+ }
92
+
93
+ export function resolveTargetFile(targetRoot, fileId) {
94
+ if (typeof fileId !== 'string' || fileId.trim() === '') {
95
+ throw new Error('fileId is required')
96
+ }
97
+ const normalized = fileId.trim().replaceAll('\\', '/').replace(/^\/+/, '')
98
+ if (!normalized || normalized === '.' || normalized.includes('..')) {
99
+ throw new Error(`Invalid file id ${fileId}`)
100
+ }
101
+ const root = path.resolve(targetRoot)
102
+ const absolute = path.resolve(root, normalized)
103
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
104
+ if (absolute !== root && !absolute.startsWith(prefix)) {
105
+ throw new Error(`Invalid file id ${fileId}`)
106
+ }
107
+ return { id: normalized, absolute }
108
+ }
109
+
50
110
  export function readActiveSession(dataDir) {
51
111
  const value = readJson(path.join(dataDir, 'active-session.json'), null)
52
112
  return value?.sessionId ? assertSessionId(value.sessionId) : null
@@ -59,6 +119,138 @@ export function writeActiveSession(dataDir, sessionId) {
59
119
  )
60
120
  }
61
121
 
122
+ function connectionFile(dataDir, sessionId) {
123
+ return path.join(sessionPaths(dataDir, sessionId).root, 'connected.json')
124
+ }
125
+
126
+ function isFreshTimestamp(value, now = Date.now()) {
127
+ if (typeof value !== 'string') return false
128
+ const at = Date.parse(value)
129
+ return Number.isFinite(at) && now - at >= 0 && now - at < CONNECTED_TTL_MS
130
+ }
131
+
132
+ export function touchSessionConnection(dataDir, sessionId) {
133
+ const safeId = assertSessionId(sessionId)
134
+ if (isSessionStopped(dataDir, safeId)) return
135
+ atomicWrite(
136
+ connectionFile(dataDir, safeId),
137
+ `${JSON.stringify({ sessionId: safeId, connectedAt: new Date().toISOString() }, null, 2)}\n`,
138
+ )
139
+ }
140
+
141
+ function waiterSessionIds() {
142
+ try {
143
+ const result = spawnSync('ps', ['-ax', '-o', 'command='], {
144
+ encoding: 'utf8',
145
+ })
146
+ if (result.status !== 0 || !result.stdout) return new Set()
147
+ const ids = new Set()
148
+ for (const line of result.stdout.split('\n')) {
149
+ if (
150
+ !line.includes('wait-for-blueprint') &&
151
+ !line.includes('wait-for-approval')
152
+ ) {
153
+ continue
154
+ }
155
+ const match = line.match(/--session\s+(\S+)/)
156
+ if (!match) continue
157
+ try {
158
+ ids.add(assertSessionId(match[1]))
159
+ } catch {
160
+ // Ignore process command lines with invalid session ids.
161
+ }
162
+ }
163
+ return ids
164
+ } catch {
165
+ return new Set()
166
+ }
167
+ }
168
+
169
+ function isGeneratingPhase(phase) {
170
+ return phase === 'preparing' || phase === 'working' || phase === 'replanning'
171
+ }
172
+
173
+ export function isSessionConnected(
174
+ dataDir,
175
+ sessionId,
176
+ waiterIds = waiterSessionIds(),
177
+ ) {
178
+ const safeId = assertSessionId(sessionId)
179
+ const manifest = readManifest(dataDir, safeId)
180
+ if (!manifest) return false
181
+ if (
182
+ manifest.phase === 'finished' ||
183
+ manifest.phase === 'stopped' ||
184
+ manifest.status === 'finished' ||
185
+ manifest.status === 'rejected'
186
+ ) {
187
+ return false
188
+ }
189
+ if (isGeneratingPhase(manifest.phase)) return true
190
+ if (waiterIds.has(safeId)) return true
191
+ const connected = readJson(connectionFile(dataDir, safeId), null)
192
+ if (isFreshTimestamp(connected?.connectedAt)) return true
193
+ return isFreshTimestamp(manifest.updatedAt) || isFreshTimestamp(manifest.createdAt)
194
+ }
195
+
196
+ function diffSessionsRoot(dataDir) {
197
+ return path.join(dataDir, 'diff-sessions')
198
+ }
199
+
200
+ export function listStoredSessionIds(dataDir) {
201
+ const root = diffSessionsRoot(dataDir)
202
+ if (!fs.existsSync(root)) return []
203
+ const ids = new Set()
204
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
205
+ if (entry.name === '.gitkeep') continue
206
+ const name =
207
+ entry.isFile() && entry.name.endsWith('.stopped')
208
+ ? entry.name.slice(0, -'.stopped'.length)
209
+ : entry.name
210
+ try {
211
+ ids.add(assertSessionId(name))
212
+ } catch {
213
+ // Skip files that are not valid session ids.
214
+ }
215
+ }
216
+ return [...ids]
217
+ }
218
+
219
+ export function listOpenSessionIds(dataDir) {
220
+ const root = diffSessionsRoot(dataDir)
221
+ if (!fs.existsSync(root)) return []
222
+ const waiters = waiterSessionIds()
223
+ const sessions = []
224
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
225
+ if (!entry.isDirectory()) continue
226
+ try {
227
+ const sessionId = assertSessionId(entry.name)
228
+ if (!isSessionConnected(dataDir, sessionId, waiters)) continue
229
+ const manifest = readManifest(dataDir, sessionId)
230
+ if (!manifest) continue
231
+ sessions.push({
232
+ sessionId,
233
+ createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : '',
234
+ })
235
+ } catch {
236
+ // Skip folders that are not valid session ids.
237
+ }
238
+ }
239
+ sessions.sort((left, right) => {
240
+ if (left.createdAt !== right.createdAt) {
241
+ return left.createdAt.localeCompare(right.createdAt)
242
+ }
243
+ return left.sessionId.localeCompare(right.sessionId)
244
+ })
245
+ return sessions.map((item) => item.sessionId)
246
+ }
247
+
248
+ export function listSessionIntents(dataDir, knownFileIds = []) {
249
+ return listOpenSessionIds(dataDir)
250
+ .map((sessionId) => sessionIntent(dataDir, sessionId, knownFileIds))
251
+ .filter(Boolean)
252
+ }
253
+
62
254
  export function readBlueprintSession(dataDir) {
63
255
  const value = readJson(path.join(dataDir, 'blueprint-session.json'), null)
64
256
  return value?.sessionId ? assertSessionId(value.sessionId) : null
@@ -305,10 +497,147 @@ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDif
305
497
  }
306
498
  }
307
499
 
308
- function withVirtualTarget(targetRoot, patches, action) {
500
+ function emptyBaseline() {
501
+ return { files: {} }
502
+ }
503
+
504
+ function readBaseline(dataDir, sessionId) {
505
+ const { baseline } = sessionPaths(dataDir, sessionId)
506
+ const value = readJson(baseline, emptyBaseline())
507
+ return {
508
+ files:
509
+ value?.files && typeof value.files === 'object' && !Array.isArray(value.files)
510
+ ? value.files
511
+ : {},
512
+ }
513
+ }
514
+
515
+ function writeBaseline(dataDir, sessionId, baseline) {
516
+ const { baseline: file } = sessionPaths(dataDir, sessionId)
517
+ atomicWrite(file, `${JSON.stringify({ files: baseline.files ?? {} }, null, 2)}\n`)
518
+ }
519
+
520
+ function pruneEmptyDirs(targetRoot, filePath) {
521
+ const root = path.resolve(targetRoot)
522
+ let current = path.dirname(filePath)
523
+ while (current.startsWith(`${root}${path.sep}`)) {
524
+ if (!fs.existsSync(current)) {
525
+ current = path.dirname(current)
526
+ continue
527
+ }
528
+ if (fs.readdirSync(current).length > 0) break
529
+ fs.rmdirSync(current)
530
+ current = path.dirname(current)
531
+ }
532
+ }
533
+
534
+ export function captureBaseline(dataDir, sessionId, targetRoot, fileIds = []) {
535
+ const paths = sessionPaths(dataDir, sessionId)
536
+ const baseline = readBaseline(dataDir, sessionId)
537
+ let changed = false
538
+ for (const fileId of fileIds) {
539
+ const { id, absolute } = resolveTargetFile(targetRoot, fileId)
540
+ if (baseline.files[id]) continue
541
+ const existed = fs.existsSync(absolute) && fs.statSync(absolute).isFile()
542
+ baseline.files[id] = { existed }
543
+ if (existed) {
544
+ const stored = resolveTargetFile(paths.baselineFiles, id).absolute
545
+ fs.mkdirSync(path.dirname(stored), { recursive: true })
546
+ fs.copyFileSync(absolute, stored)
547
+ }
548
+ changed = true
549
+ }
550
+ if (changed) writeBaseline(dataDir, sessionId, baseline)
551
+ return baseline
552
+ }
553
+
554
+ export function restoreBaseline(dataDir, sessionId, targetRoot) {
555
+ const paths = sessionPaths(dataDir, sessionId)
556
+ const baseline = readBaseline(dataDir, sessionId)
557
+ for (const [fileId, info] of Object.entries(baseline.files)) {
558
+ const { absolute } = resolveTargetFile(targetRoot, fileId)
559
+ if (!info?.existed) {
560
+ fs.rmSync(absolute, { force: true })
561
+ pruneEmptyDirs(targetRoot, absolute)
562
+ continue
563
+ }
564
+ const stored = resolveTargetFile(paths.baselineFiles, fileId).absolute
565
+ fs.mkdirSync(path.dirname(absolute), { recursive: true })
566
+ fs.copyFileSync(stored, absolute)
567
+ }
568
+ }
569
+
570
+ function replayPatches(dataDir, sessionId, targetRoot, entries) {
571
+ for (const entry of entries) {
572
+ applyUnifiedPatch(readDiff(dataDir, sessionId, entry), targetRoot)
573
+ }
574
+ }
575
+
576
+ function gitTopLevel(fromDir) {
577
+ try {
578
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
579
+ cwd: fromDir,
580
+ encoding: 'utf8',
581
+ })
582
+ if (result.status !== 0) return null
583
+ const root = result.stdout.trim()
584
+ return root ? fs.realpathSync(root) : null
585
+ } catch {
586
+ return null
587
+ }
588
+ }
589
+
590
+ function repoRelativePath(root, absolutePath) {
591
+ const resolved = path.resolve(absolutePath)
592
+ let candidate = resolved
593
+ try {
594
+ if (fs.existsSync(resolved)) candidate = fs.realpathSync(resolved)
595
+ else if (fs.existsSync(path.dirname(resolved))) {
596
+ candidate = path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved))
597
+ }
598
+ } catch {
599
+ candidate = resolved
600
+ }
601
+ const relative = path.relative(root, candidate)
602
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null
603
+ return relative
604
+ }
605
+
606
+ function unstagePaths(fromDir, absolutePaths) {
607
+ if (!absolutePaths.length) return
608
+ const root = gitTopLevel(fromDir)
609
+ if (!root) return
610
+ const relative = [...new Set(absolutePaths)]
611
+ .map((item) => repoRelativePath(root, item))
612
+ .filter((item) => Boolean(item))
613
+ if (!relative.length) return
614
+ for (const item of relative) {
615
+ spawnSync('git', ['restore', '--staged', '--', item], {
616
+ cwd: root,
617
+ encoding: 'utf8',
618
+ stdio: 'ignore',
619
+ })
620
+ }
621
+ }
622
+
623
+ function liveEntries(manifest, diffId) {
624
+ return chainThrough(manifest, diffId).filter((entry) => entry.status !== 'rejected')
625
+ }
626
+
627
+ export function materializeDiff(dataDir, targetRoot, sessionId, diffId) {
628
+ const manifest = requireManifest(dataDir, sessionId)
629
+ const through = diffId || manifest.activeDiffId
630
+ if (!through) return manifest
631
+ restoreBaseline(dataDir, sessionId, targetRoot)
632
+ replayPatches(dataDir, sessionId, targetRoot, liveEntries(manifest, through))
633
+ return manifest
634
+ }
635
+
636
+ function withSessionReplay(dataDir, sessionId, targetRoot, patches, action) {
309
637
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-coder-chain-'))
310
638
  try {
311
639
  fs.cpSync(targetRoot, temporary, { recursive: true })
640
+ restoreBaseline(dataDir, sessionId, temporary)
312
641
  for (const patchText of patches) applyUnifiedPatch(patchText, temporary)
313
642
  return action(temporary)
314
643
  } finally {
@@ -317,10 +646,28 @@ function withVirtualTarget(targetRoot, patches, action) {
317
646
  }
318
647
 
319
648
  export function validateContinuation(dataDir, manifest, targetRoot, patchText) {
320
- const prior = unresolvedEntries(manifest).map((entry) =>
321
- readDiff(dataDir, manifest.sessionId, entry),
649
+ const prior = manifest.diffs
650
+ .filter((entry) => entry.status !== 'rejected')
651
+ .map((entry) => readDiff(dataDir, manifest.sessionId, entry))
652
+ withSessionReplay(dataDir, manifest.sessionId, targetRoot, [...prior, patchText], () =>
653
+ undefined,
322
654
  )
323
- withVirtualTarget(targetRoot, [...prior, patchText], () => undefined)
655
+ }
656
+
657
+ export function inspectTargetFile(
658
+ dataDir,
659
+ targetRoot,
660
+ { sessionId, diffId, fileId } = {},
661
+ ) {
662
+ if (sessionId && readManifest(dataDir, sessionId)) {
663
+ materializeDiff(dataDir, targetRoot, sessionId, diffId)
664
+ }
665
+ if (!fileId) return null
666
+ const { absolute } = resolveTargetFile(targetRoot, fileId)
667
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
668
+ throw new Error(`File ${fileId} is not on disk`)
669
+ }
670
+ return absolute
324
671
  }
325
672
 
326
673
  function planSteps(titles, startAt = 1) {
@@ -341,6 +688,7 @@ function featureName(value) {
341
688
 
342
689
  export function startSession(dataDir, input) {
343
690
  const sessionId = assertSessionId(input.sessionId)
691
+ clearStoppedMarker(dataDir, sessionId)
344
692
  const existing = readManifest(dataDir, sessionId)
345
693
  if (existing) {
346
694
  focusSession(dataDir, sessionId)
@@ -370,8 +718,7 @@ export function startSession(dataDir, input) {
370
718
  }
371
719
 
372
720
  export function answerBlueprint(dataDir, sessionId, enabled) {
373
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
374
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
721
+ const manifest = requireManifest(dataDir, sessionId)
375
722
  if (manifest.phase !== 'blueprint_ask') {
376
723
  throw new Error(`Session ${sessionId} is not asking for a blueprint`)
377
724
  }
@@ -390,8 +737,7 @@ export function answerBlueprint(dataDir, sessionId, enabled) {
390
737
 
391
738
  export function updateBlueprint(dataDir, sessionId, input = {}) {
392
739
  const safeId = assertSessionId(sessionId)
393
- const manifest = readManifest(dataDir, safeId)
394
- if (!manifest) throw new Error(`Unknown session ${safeId}`)
740
+ const manifest = requireManifest(dataDir, safeId)
395
741
  if (manifest.phase !== 'blueprint') {
396
742
  throw new Error(`Session ${safeId} is not in blueprint mode`)
397
743
  }
@@ -412,8 +758,7 @@ export function updateBlueprint(dataDir, sessionId, input = {}) {
412
758
 
413
759
  export function sendBlueprint(dataDir, sessionId, input = {}) {
414
760
  const safeId = assertSessionId(sessionId)
415
- const manifest = readManifest(dataDir, safeId)
416
- if (!manifest) throw new Error(`Unknown session ${safeId}`)
761
+ const manifest = requireManifest(dataDir, safeId)
417
762
  if (manifest.phase !== 'blueprint') {
418
763
  throw new Error(`Session ${safeId} is not in blueprint mode`)
419
764
  }
@@ -438,6 +783,9 @@ export function sendBlueprint(dataDir, sessionId, input = {}) {
438
783
  export function reportPlan(dataDir, input) {
439
784
  const sessionId = assertSessionId(input.sessionId)
440
785
  const existing = readManifest(dataDir, sessionId)
786
+ if (!existing && isSessionStopped(dataDir, sessionId)) {
787
+ throw sessionStoppedError(sessionId)
788
+ }
441
789
  const now = new Date().toISOString()
442
790
 
443
791
  if (existing?.phase === 'blueprint_ask' || existing?.phase === 'blueprint') {
@@ -491,8 +839,7 @@ export function reportPlan(dataDir, input) {
491
839
  }
492
840
 
493
841
  export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
494
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
495
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
842
+ const manifest = requireManifest(dataDir, sessionId)
496
843
 
497
844
  if (manifest.phase === 'review') {
498
845
  if (!targetRoot) throw new Error('A target root is required to apply the current step')
@@ -533,8 +880,11 @@ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
533
880
 
534
881
  export function appendDiff(dataDir, targetRoot, input) {
535
882
  const sessionId = assertSessionId(input.sessionId)
536
- const manifest = readManifest(dataDir, sessionId)
537
- if (!manifest) throw new Error(`Report a plan for session ${sessionId} first`)
883
+ const manifest = requireManifest(
884
+ dataDir,
885
+ sessionId,
886
+ `Report a plan for session ${sessionId} first`,
887
+ )
538
888
  if (manifest.phase !== 'working') {
539
889
  throw new Error(`Step ${manifest.currentStep} has not been invoked`)
540
890
  }
@@ -555,6 +905,12 @@ export function appendDiff(dataDir, targetRoot, input) {
555
905
  }
556
906
 
557
907
  validateContinuation(dataDir, manifest, targetRoot, input.patchText)
908
+ captureBaseline(
909
+ dataDir,
910
+ sessionId,
911
+ targetRoot,
912
+ parseUnifiedPatch(input.patchText).entries.map((entry) => entry.id),
913
+ )
558
914
  if (parent?.status === 'extend') parent.status = 'extended'
559
915
 
560
916
  const id = String(manifest.diffs.length + 1).padStart(4, '0')
@@ -581,6 +937,7 @@ export function appendDiff(dataDir, targetRoot, input) {
581
937
  manifest.workStartedAt = null
582
938
  manifest.diffs.push(entry)
583
939
  writeManifest(dataDir, manifest)
940
+ materializeDiff(dataDir, targetRoot, sessionId, id)
584
941
  focusSession(dataDir, sessionId)
585
942
  return { manifest, entry }
586
943
  }
@@ -601,25 +958,7 @@ function pendingActive(manifest, diffId) {
601
958
 
602
959
  function applyUnresolved(dataDir, targetRoot, manifest, diffId) {
603
960
  const unresolved = unresolvedEntries(manifest, diffId)
604
- const patches = unresolved.map((entry) =>
605
- readDiff(dataDir, manifest.sessionId, entry),
606
- )
607
- const touched = new Set()
608
- for (const patch of patches) {
609
- for (const entry of parseUnifiedPatch(patch).entries) touched.add(entry.id)
610
- }
611
- withVirtualTarget(targetRoot, patches, (virtualRoot) => {
612
- for (const id of touched) {
613
- const source = path.join(virtualRoot, id)
614
- const destination = path.join(targetRoot, id)
615
- if (!fs.existsSync(source)) {
616
- fs.rmSync(destination, { recursive: true, force: true })
617
- continue
618
- }
619
- fs.mkdirSync(path.dirname(destination), { recursive: true })
620
- fs.copyFileSync(source, destination)
621
- }
622
- })
961
+ materializeDiff(dataDir, targetRoot, manifest.sessionId, diffId)
623
962
  for (const entry of unresolved) {
624
963
  entry.status = 'applied'
625
964
  entry.decidedAt = new Date().toISOString()
@@ -627,8 +966,7 @@ function applyUnresolved(dataDir, targetRoot, manifest, diffId) {
627
966
  }
628
967
 
629
968
  export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
630
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
631
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
969
+ const manifest = requireManifest(dataDir, sessionId)
632
970
  const active = pendingActive(manifest, diffId)
633
971
  applyUnresolved(dataDir, targetRoot, manifest, diffId)
634
972
 
@@ -648,8 +986,7 @@ export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
648
986
  }
649
987
 
650
988
  export function requestReplan(dataDir, sessionId, diffId, instruction) {
651
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
652
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
989
+ const manifest = requireManifest(dataDir, sessionId)
653
990
  const active = pendingActive(manifest, diffId)
654
991
  const guidance = typeof instruction === 'string' ? instruction.trim() : ''
655
992
  if (!guidance) throw new Error('An alternative instruction is required')
@@ -663,8 +1000,91 @@ export function requestReplan(dataDir, sessionId, diffId, instruction) {
663
1000
  return manifest
664
1001
  }
665
1002
 
666
- export function stopSession(dataDir, sessionId, _diffId) {
667
- finalizeFinishedSession(dataDir, sessionId)
1003
+ function unstageDiffSessionArtifacts(dataDir, targetRoot, extraPaths = []) {
1004
+ if (!targetRoot) return
1005
+ unstagePaths(targetRoot, [
1006
+ ...extraPaths,
1007
+ diffSessionsRoot(dataDir),
1008
+ path.join(dataDir, 'active-session.json'),
1009
+ path.join(dataDir, 'blueprint-session.json'),
1010
+ ])
1011
+ }
1012
+
1013
+ function discardStoredSession(
1014
+ dataDir,
1015
+ sessionId,
1016
+ targetRoot = null,
1017
+ { restore = true, keepStoppedMarker = false } = {},
1018
+ ) {
1019
+ const safeId = assertSessionId(sessionId)
1020
+ const paths = sessionPaths(dataDir, safeId)
1021
+ const fileIds = Object.keys(readBaseline(dataDir, safeId).files)
1022
+ if (targetRoot && restore) {
1023
+ try {
1024
+ restoreBaseline(dataDir, safeId, targetRoot)
1025
+ } catch {
1026
+ // Incomplete session artifacts should still be deleted.
1027
+ }
1028
+ }
1029
+ if (targetRoot) {
1030
+ unstageDiffSessionArtifacts(
1031
+ dataDir,
1032
+ targetRoot,
1033
+ fileIds.flatMap((id) => {
1034
+ try {
1035
+ return [resolveTargetFile(targetRoot, id).absolute]
1036
+ } catch {
1037
+ return []
1038
+ }
1039
+ }),
1040
+ )
1041
+ }
1042
+ releaseBlueprintSession(dataDir, safeId)
1043
+ if (fs.existsSync(paths.root)) {
1044
+ fs.rmSync(paths.root, { recursive: true, force: true })
1045
+ }
1046
+ if (!keepStoppedMarker) clearStoppedMarker(dataDir, safeId)
1047
+ const active = readActiveSession(dataDir)
1048
+ if (active === safeId) writeActiveSession(dataDir, null)
1049
+ }
1050
+
1051
+ export function discardInactiveDiffSessions(
1052
+ dataDir,
1053
+ targetRoot = null,
1054
+ waiterIds = waiterSessionIds(),
1055
+ ) {
1056
+ const keep = new Set()
1057
+ for (const value of waiterIds) {
1058
+ try {
1059
+ keep.add(assertSessionId(value))
1060
+ } catch {
1061
+ // Ignore process command lines with invalid session ids.
1062
+ }
1063
+ }
1064
+
1065
+ for (const sessionId of listStoredSessionIds(dataDir)) {
1066
+ const live = keep.has(sessionId) && Boolean(readManifest(dataDir, sessionId))
1067
+ const stopping = keep.has(sessionId) && isSessionStopped(dataDir, sessionId)
1068
+ if (live || stopping) continue
1069
+ discardStoredSession(dataDir, sessionId, targetRoot)
1070
+ }
1071
+
1072
+ const liveIds = [...keep].filter((id) => readManifest(dataDir, id))
1073
+ const active = readActiveSession(dataDir)
1074
+ if (active && !liveIds.includes(active)) writeActiveSession(dataDir, null)
1075
+ const locked = readBlueprintSession(dataDir)
1076
+ if (locked && !liveIds.includes(locked)) writeBlueprintSession(dataDir, null)
1077
+ unstageDiffSessionArtifacts(dataDir, targetRoot)
1078
+ return liveIds
1079
+ }
1080
+
1081
+ export function stopSession(dataDir, sessionId, targetRoot = null) {
1082
+ const safeId = assertSessionId(sessionId)
1083
+ writeStoppedMarker(dataDir, safeId)
1084
+ discardStoredSession(dataDir, safeId, targetRoot, { keepStoppedMarker: true })
1085
+ const waiters = waiterSessionIds()
1086
+ waiters.add(safeId)
1087
+ discardInactiveDiffSessions(dataDir, targetRoot, waiters)
668
1088
  return null
669
1089
  }
670
1090
 
@@ -682,7 +1102,7 @@ export function decideDiff(
682
1102
  if (decision === 'extend') {
683
1103
  return requestReplan(dataDir, sessionId, diffId, instruction)
684
1104
  }
685
- return stopSession(dataDir, sessionId, diffId)
1105
+ return stopSession(dataDir, sessionId, targetRoot)
686
1106
  }
687
1107
 
688
1108
  export function closeSession(dataDir, sessionId) {
@@ -692,14 +1112,7 @@ export function closeSession(dataDir, sessionId) {
692
1112
  }
693
1113
 
694
1114
  export function finalizeFinishedSession(dataDir, sessionId) {
695
- const safeId = assertSessionId(sessionId)
696
- const paths = sessionPaths(dataDir, safeId)
697
- releaseBlueprintSession(dataDir, safeId)
698
- if (fs.existsSync(paths.root)) {
699
- fs.rmSync(paths.root, { recursive: true, force: true })
700
- }
701
- const active = readActiveSession(dataDir)
702
- if (active === safeId) writeActiveSession(dataDir, null)
1115
+ discardStoredSession(dataDir, sessionId, null, { restore: false })
703
1116
  }
704
1117
 
705
1118
  export function emptyBlueprint() {