@jkwd/inbase 0.1.4 → 0.1.6

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,9 +1,10 @@
1
1
  import fs from 'node:fs'
2
- import os from 'node:os'
3
2
  import path from 'node:path'
3
+ import { spawnSync } from 'node:child_process'
4
4
  import {
5
5
  accumulatePatchAdditions,
6
6
  applyUnifiedPatch,
7
+ applyUnifiedPatchToContents,
7
8
  collectCreateFolders,
8
9
  extractPatchImports,
9
10
  foldersFromFileIds,
@@ -11,6 +12,8 @@ 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
16
+ const STALLED_WAIT_MS = 2_000
14
17
 
15
18
  export function assertSessionId(value) {
16
19
  if (typeof value !== 'string' || !SESSION_ID.test(value) || value === '.' || value === '..') {
@@ -46,9 +49,48 @@ export function sessionPaths(dataDir, sessionId) {
46
49
  blueprint: path.join(root, 'blueprint.json'),
47
50
  baseline: path.join(root, 'baseline.json'),
48
51
  baselineFiles: path.join(root, 'baseline'),
52
+ stopped: path.join(dataDir, 'diff-sessions', `${safeId}.stopped`),
49
53
  }
50
54
  }
51
55
 
56
+ export function sessionStoppedError(sessionId) {
57
+ return new Error(
58
+ `VISUAL_CODER_STOPPED Session ${assertSessionId(sessionId)} was stopped. Do not modify project files.`,
59
+ )
60
+ }
61
+
62
+ export function isSessionStopped(dataDir, sessionId) {
63
+ return fs.existsSync(sessionPaths(dataDir, sessionId).stopped)
64
+ }
65
+
66
+ export function isWorkflowStopped(dataDir, sessionId) {
67
+ const safeId = assertSessionId(sessionId)
68
+ const manifest = readManifest(dataDir, safeId)
69
+ if (manifest?.phase === 'stopped') return true
70
+ return !manifest && isSessionStopped(dataDir, safeId)
71
+ }
72
+
73
+ function writeStoppedMarker(dataDir, sessionId) {
74
+ const { stopped } = sessionPaths(dataDir, sessionId)
75
+ atomicWrite(
76
+ stopped,
77
+ `${JSON.stringify({ sessionId: assertSessionId(sessionId), stoppedAt: new Date().toISOString() }, null, 2)}\n`,
78
+ )
79
+ }
80
+
81
+ function clearStoppedMarker(dataDir, sessionId) {
82
+ const { stopped } = sessionPaths(dataDir, sessionId)
83
+ if (fs.existsSync(stopped)) fs.unlinkSync(stopped)
84
+ }
85
+
86
+ function requireManifest(dataDir, sessionId, missingMessage) {
87
+ const safeId = assertSessionId(sessionId)
88
+ const manifest = readManifest(dataDir, safeId)
89
+ if (manifest) return manifest
90
+ if (isSessionStopped(dataDir, safeId)) throw sessionStoppedError(safeId)
91
+ throw new Error(missingMessage ?? `Unknown session ${safeId}`)
92
+ }
93
+
52
94
  export function resolveTargetFile(targetRoot, fileId) {
53
95
  if (typeof fileId !== 'string' || fileId.trim() === '') {
54
96
  throw new Error('fileId is required')
@@ -78,6 +120,147 @@ export function writeActiveSession(dataDir, sessionId) {
78
120
  )
79
121
  }
80
122
 
123
+ function connectionFile(dataDir, sessionId) {
124
+ return path.join(sessionPaths(dataDir, sessionId).root, 'connected.json')
125
+ }
126
+
127
+ function isFreshTimestamp(value, now = Date.now()) {
128
+ if (typeof value !== 'string') return false
129
+ const at = Date.parse(value)
130
+ return Number.isFinite(at) && now - at >= 0 && now - at < CONNECTED_TTL_MS
131
+ }
132
+
133
+ export function touchSessionConnection(dataDir, sessionId) {
134
+ const safeId = assertSessionId(sessionId)
135
+ if (isSessionStopped(dataDir, safeId)) return
136
+ atomicWrite(
137
+ connectionFile(dataDir, safeId),
138
+ `${JSON.stringify({ sessionId: safeId, connectedAt: new Date().toISOString() }, null, 2)}\n`,
139
+ )
140
+ }
141
+
142
+ function waiterSessionIds() {
143
+ try {
144
+ const result = spawnSync('ps', ['-ax', '-o', 'command='], {
145
+ encoding: 'utf8',
146
+ })
147
+ if (result.status !== 0 || !result.stdout) return new Set()
148
+ const ids = new Set()
149
+ for (const line of result.stdout.split('\n')) {
150
+ if (
151
+ !line.includes('wait-for-blueprint') &&
152
+ !line.includes('wait-for-approval')
153
+ ) {
154
+ continue
155
+ }
156
+ const match = line.match(/--session\s+(\S+)/)
157
+ if (!match) continue
158
+ try {
159
+ ids.add(assertSessionId(match[1]))
160
+ } catch {
161
+ // Ignore process command lines with invalid session ids.
162
+ }
163
+ }
164
+ return ids
165
+ } catch {
166
+ return new Set()
167
+ }
168
+ }
169
+
170
+ function isGeneratingPhase(phase) {
171
+ return phase === 'preparing' || phase === 'working' || phase === 'replanning'
172
+ }
173
+
174
+ function isStalledWorking(manifest, waiterIds, sessionId, now = Date.now()) {
175
+ if (manifest.phase !== 'working') return false
176
+ if (!waiterIds.has(sessionId)) return false
177
+ const started = Date.parse(manifest.workStartedAt)
178
+ return Number.isFinite(started) && now - started >= STALLED_WAIT_MS
179
+ }
180
+
181
+ export function isSessionConnected(
182
+ dataDir,
183
+ sessionId,
184
+ waiterIds = waiterSessionIds(),
185
+ ) {
186
+ const safeId = assertSessionId(sessionId)
187
+ const manifest = readManifest(dataDir, safeId)
188
+ if (!manifest) return false
189
+ if (
190
+ manifest.phase === 'finished' ||
191
+ manifest.phase === 'stopped' ||
192
+ manifest.status === 'finished' ||
193
+ manifest.status === 'rejected'
194
+ ) {
195
+ return false
196
+ }
197
+ if (isGeneratingPhase(manifest.phase)) return true
198
+ if (waiterIds.has(safeId)) return true
199
+ const connected = readJson(connectionFile(dataDir, safeId), null)
200
+ if (isFreshTimestamp(connected?.connectedAt)) return true
201
+ return isFreshTimestamp(manifest.updatedAt) || isFreshTimestamp(manifest.createdAt)
202
+ }
203
+
204
+ function diffSessionsRoot(dataDir) {
205
+ return path.join(dataDir, 'diff-sessions')
206
+ }
207
+
208
+ export function listStoredSessionIds(dataDir) {
209
+ const root = diffSessionsRoot(dataDir)
210
+ if (!fs.existsSync(root)) return []
211
+ const ids = new Set()
212
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
213
+ if (entry.name === '.gitkeep') continue
214
+ const name =
215
+ entry.isFile() && entry.name.endsWith('.stopped')
216
+ ? entry.name.slice(0, -'.stopped'.length)
217
+ : entry.name
218
+ try {
219
+ ids.add(assertSessionId(name))
220
+ } catch {
221
+ // Skip files that are not valid session ids.
222
+ }
223
+ }
224
+ return [...ids]
225
+ }
226
+
227
+ export function listOpenSessionIds(dataDir, waiterIds = waiterSessionIds()) {
228
+ const root = diffSessionsRoot(dataDir)
229
+ if (!fs.existsSync(root)) return []
230
+ const sessions = []
231
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
232
+ if (!entry.isDirectory()) continue
233
+ try {
234
+ const sessionId = assertSessionId(entry.name)
235
+ if (!isSessionConnected(dataDir, sessionId, waiterIds)) continue
236
+ const manifest = readManifest(dataDir, sessionId)
237
+ if (!manifest) continue
238
+ sessions.push({
239
+ sessionId,
240
+ createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : '',
241
+ })
242
+ } catch {
243
+ // Skip folders that are not valid session ids.
244
+ }
245
+ }
246
+ sessions.sort((left, right) => {
247
+ if (left.createdAt !== right.createdAt) {
248
+ return left.createdAt.localeCompare(right.createdAt)
249
+ }
250
+ return left.sessionId.localeCompare(right.sessionId)
251
+ })
252
+ return sessions.map((item) => item.sessionId)
253
+ }
254
+
255
+ export function listSessionIntents(dataDir, knownFileIds = []) {
256
+ const waiters = waiterSessionIds()
257
+ return listOpenSessionIds(dataDir, waiters)
258
+ .map((sessionId) =>
259
+ sessionIntent(dataDir, sessionId, knownFileIds, undefined, waiters),
260
+ )
261
+ .filter(Boolean)
262
+ }
263
+
81
264
  export function readBlueprintSession(dataDir) {
82
265
  const value = readJson(path.join(dataDir, 'blueprint-session.json'), null)
83
266
  return value?.sessionId ? assertSessionId(value.sessionId) : null
@@ -140,6 +323,7 @@ export function readManifest(dataDir, sessionId) {
140
323
  value.pendingInstruction ??= null
141
324
  value.workStartedAt ??= null
142
325
  }
326
+ if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
143
327
  return value
144
328
  }
145
329
 
@@ -244,7 +428,13 @@ export function previewPatchChain(patches, knownFileIds = []) {
244
428
  }
245
429
  }
246
430
 
247
- export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDiffId) {
431
+ export function sessionIntent(
432
+ dataDir,
433
+ sessionId,
434
+ knownFileIds = [],
435
+ selectedDiffId,
436
+ waiterIds = waiterSessionIds(),
437
+ ) {
248
438
  const manifest = readManifest(dataDir, sessionId)
249
439
  if (!manifest) return null
250
440
  const selectedId = selectedDiffId || manifest.activeDiffId
@@ -294,6 +484,7 @@ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDif
294
484
  feature: manifest.feature,
295
485
  steps: manifest.steps,
296
486
  step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
487
+ stepByStep: isStepByStep(manifest),
297
488
  reason: activeView ? currentPlanStep?.title ?? null : selected?.title ?? null,
298
489
  sessionId,
299
490
  diffId: selected?.id ?? null,
@@ -312,6 +503,7 @@ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDif
312
503
  manifest.phase === 'preparing' ||
313
504
  manifest.phase === 'working' ||
314
505
  manifest.phase === 'replanning',
506
+ stalledWait: isStalledWorking(manifest, waiterIds, sessionId),
315
507
  creationMode: manifest.phase === 'blueprint' && ownsBlueprintLock,
316
508
  canEnterBlueprint,
317
509
  blueprintSessionId,
@@ -400,8 +592,51 @@ function replayPatches(dataDir, sessionId, targetRoot, entries) {
400
592
  }
401
593
  }
402
594
 
403
- function acceptedEntries(manifest) {
404
- return manifest.diffs.filter((entry) => entry.status === 'applied')
595
+ function gitTopLevel(fromDir) {
596
+ try {
597
+ const result = spawnSync('git', ['rev-parse', '--show-toplevel'], {
598
+ cwd: fromDir,
599
+ encoding: 'utf8',
600
+ })
601
+ if (result.status !== 0) return null
602
+ const root = result.stdout.trim()
603
+ return root ? fs.realpathSync(root) : null
604
+ } catch {
605
+ return null
606
+ }
607
+ }
608
+
609
+ function repoRelativePath(root, absolutePath) {
610
+ const resolved = path.resolve(absolutePath)
611
+ let candidate = resolved
612
+ try {
613
+ if (fs.existsSync(resolved)) candidate = fs.realpathSync(resolved)
614
+ else if (fs.existsSync(path.dirname(resolved))) {
615
+ candidate = path.join(fs.realpathSync(path.dirname(resolved)), path.basename(resolved))
616
+ }
617
+ } catch {
618
+ candidate = resolved
619
+ }
620
+ const relative = path.relative(root, candidate)
621
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null
622
+ return relative
623
+ }
624
+
625
+ function unstagePaths(fromDir, absolutePaths) {
626
+ if (!absolutePaths.length) return
627
+ const root = gitTopLevel(fromDir)
628
+ if (!root) return
629
+ const relative = [...new Set(absolutePaths)]
630
+ .map((item) => repoRelativePath(root, item))
631
+ .filter((item) => Boolean(item))
632
+ if (!relative.length) return
633
+ for (const item of relative) {
634
+ spawnSync('git', ['restore', '--staged', '--', item], {
635
+ cwd: root,
636
+ encoding: 'utf8',
637
+ stdio: 'ignore',
638
+ })
639
+ }
405
640
  }
406
641
 
407
642
  function liveEntries(manifest, diffId) {
@@ -409,8 +644,7 @@ function liveEntries(manifest, diffId) {
409
644
  }
410
645
 
411
646
  export function materializeDiff(dataDir, targetRoot, sessionId, diffId) {
412
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
413
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
647
+ const manifest = requireManifest(dataDir, sessionId)
414
648
  const through = diffId || manifest.activeDiffId
415
649
  if (!through) return manifest
416
650
  restoreBaseline(dataDir, sessionId, targetRoot)
@@ -418,25 +652,47 @@ export function materializeDiff(dataDir, targetRoot, sessionId, diffId) {
418
652
  return manifest
419
653
  }
420
654
 
421
- function withSessionReplay(dataDir, sessionId, targetRoot, patches, action) {
422
- const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-coder-chain-'))
423
- try {
424
- fs.cpSync(targetRoot, temporary, { recursive: true })
425
- restoreBaseline(dataDir, sessionId, temporary)
426
- for (const patchText of patches) applyUnifiedPatch(patchText, temporary)
427
- return action(temporary)
428
- } finally {
429
- fs.rmSync(temporary, { recursive: true, force: true })
655
+ function loadReplayContents(dataDir, sessionId, targetRoot, patches) {
656
+ const baseline = readBaseline(dataDir, sessionId)
657
+ const paths = sessionPaths(dataDir, sessionId)
658
+ const fileIds = new Set(Object.keys(baseline.files))
659
+ for (const patchText of patches) {
660
+ for (const entry of parseUnifiedPatch(patchText).entries) {
661
+ fileIds.add(entry.id)
662
+ }
430
663
  }
664
+
665
+ const files = new Map()
666
+ for (const fileId of fileIds) {
667
+ const info = baseline.files[fileId]
668
+ if (info) {
669
+ if (!info.existed) continue
670
+ const stored = resolveTargetFile(paths.baselineFiles, fileId).absolute
671
+ if (fs.existsSync(stored) && fs.statSync(stored).isFile()) {
672
+ files.set(fileId, fs.readFileSync(stored, 'utf8'))
673
+ }
674
+ continue
675
+ }
676
+ const { absolute } = resolveTargetFile(targetRoot, fileId)
677
+ if (fs.existsSync(absolute) && fs.statSync(absolute).isFile()) {
678
+ files.set(fileId, fs.readFileSync(absolute, 'utf8'))
679
+ }
680
+ }
681
+ return files
431
682
  }
432
683
 
433
684
  export function validateContinuation(dataDir, manifest, targetRoot, patchText) {
434
685
  const prior = manifest.diffs
435
686
  .filter((entry) => entry.status !== 'rejected')
436
687
  .map((entry) => readDiff(dataDir, manifest.sessionId, entry))
437
- withSessionReplay(dataDir, manifest.sessionId, targetRoot, [...prior, patchText], () =>
438
- undefined,
688
+ const patches = [...prior, patchText]
689
+ const files = loadReplayContents(
690
+ dataDir,
691
+ manifest.sessionId,
692
+ targetRoot,
693
+ patches,
439
694
  )
695
+ for (const next of patches) applyUnifiedPatchToContents(files, next)
440
696
  }
441
697
 
442
698
  export function inspectTargetFile(
@@ -471,8 +727,36 @@ function featureName(value) {
471
727
  return trimmed
472
728
  }
473
729
 
730
+ export function isStepByStep(manifest) {
731
+ return manifest?.stepByStep !== false
732
+ }
733
+
734
+ export function autoAdvance(dataDir, sessionId, targetRoot = null) {
735
+ const manifest = readManifest(dataDir, sessionId)
736
+ if (!manifest || isStepByStep(manifest)) return manifest
737
+ if (manifest.phase === 'plan_ready') {
738
+ return invokeStep(dataDir, sessionId, manifest.currentStep, targetRoot)
739
+ }
740
+ if (manifest.phase === 'review') {
741
+ const active = manifest.diffs.at(-1)
742
+ if (!active || active.status !== 'pending') return manifest
743
+ if (active.step >= manifest.steps.length) return manifest
744
+ return invokeStep(dataDir, sessionId, active.step + 1, targetRoot)
745
+ }
746
+ return manifest
747
+ }
748
+
749
+ export function setStepByStep(dataDir, sessionId, enabled, targetRoot = null) {
750
+ const manifest = requireManifest(dataDir, sessionId)
751
+ manifest.stepByStep = Boolean(enabled)
752
+ writeManifest(dataDir, manifest)
753
+ if (isStepByStep(manifest)) return manifest
754
+ return autoAdvance(dataDir, sessionId, targetRoot)
755
+ }
756
+
474
757
  export function startSession(dataDir, input) {
475
758
  const sessionId = assertSessionId(input.sessionId)
759
+ clearStoppedMarker(dataDir, sessionId)
476
760
  const existing = readManifest(dataDir, sessionId)
477
761
  if (existing) {
478
762
  focusSession(dataDir, sessionId)
@@ -487,6 +771,7 @@ export function startSession(dataDir, input) {
487
771
  steps: [],
488
772
  status: 'active',
489
773
  phase: 'blueprint_ask',
774
+ stepByStep: true,
490
775
  currentStep: 1,
491
776
  activeDiffId: null,
492
777
  pendingInstruction: null,
@@ -502,8 +787,7 @@ export function startSession(dataDir, input) {
502
787
  }
503
788
 
504
789
  export function answerBlueprint(dataDir, sessionId, enabled) {
505
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
506
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
790
+ const manifest = requireManifest(dataDir, sessionId)
507
791
  if (manifest.phase !== 'blueprint_ask') {
508
792
  throw new Error(`Session ${sessionId} is not asking for a blueprint`)
509
793
  }
@@ -522,8 +806,7 @@ export function answerBlueprint(dataDir, sessionId, enabled) {
522
806
 
523
807
  export function updateBlueprint(dataDir, sessionId, input = {}) {
524
808
  const safeId = assertSessionId(sessionId)
525
- const manifest = readManifest(dataDir, safeId)
526
- if (!manifest) throw new Error(`Unknown session ${safeId}`)
809
+ const manifest = requireManifest(dataDir, safeId)
527
810
  if (manifest.phase !== 'blueprint') {
528
811
  throw new Error(`Session ${safeId} is not in blueprint mode`)
529
812
  }
@@ -544,8 +827,7 @@ export function updateBlueprint(dataDir, sessionId, input = {}) {
544
827
 
545
828
  export function sendBlueprint(dataDir, sessionId, input = {}) {
546
829
  const safeId = assertSessionId(sessionId)
547
- const manifest = readManifest(dataDir, safeId)
548
- if (!manifest) throw new Error(`Unknown session ${safeId}`)
830
+ const manifest = requireManifest(dataDir, safeId)
549
831
  if (manifest.phase !== 'blueprint') {
550
832
  throw new Error(`Session ${safeId} is not in blueprint mode`)
551
833
  }
@@ -570,6 +852,9 @@ export function sendBlueprint(dataDir, sessionId, input = {}) {
570
852
  export function reportPlan(dataDir, input) {
571
853
  const sessionId = assertSessionId(input.sessionId)
572
854
  const existing = readManifest(dataDir, sessionId)
855
+ if (!existing && isSessionStopped(dataDir, sessionId)) {
856
+ throw sessionStoppedError(sessionId)
857
+ }
573
858
  const now = new Date().toISOString()
574
859
 
575
860
  if (existing?.phase === 'blueprint_ask' || existing?.phase === 'blueprint') {
@@ -586,6 +871,7 @@ export function reportPlan(dataDir, input) {
586
871
  steps: [],
587
872
  status: 'active',
588
873
  phase: 'preparing',
874
+ stepByStep: true,
589
875
  currentStep: 1,
590
876
  activeDiffId: null,
591
877
  pendingInstruction: null,
@@ -602,7 +888,7 @@ export function reportPlan(dataDir, input) {
602
888
  manifest.workStartedAt = null
603
889
  writeManifest(dataDir, manifest)
604
890
  focusSession(dataDir, sessionId)
605
- return manifest
891
+ return autoAdvance(dataDir, sessionId)
606
892
  }
607
893
 
608
894
  if (existing.phase !== 'replanning') {
@@ -619,12 +905,11 @@ export function reportPlan(dataDir, input) {
619
905
  existing.workStartedAt = null
620
906
  writeManifest(dataDir, existing)
621
907
  focusSession(dataDir, sessionId)
622
- return existing
908
+ return autoAdvance(dataDir, sessionId)
623
909
  }
624
910
 
625
911
  export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
626
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
627
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
912
+ const manifest = requireManifest(dataDir, sessionId)
628
913
 
629
914
  if (manifest.phase === 'review') {
630
915
  if (!targetRoot) throw new Error('A target root is required to apply the current step')
@@ -665,8 +950,11 @@ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
665
950
 
666
951
  export function appendDiff(dataDir, targetRoot, input) {
667
952
  const sessionId = assertSessionId(input.sessionId)
668
- const manifest = readManifest(dataDir, sessionId)
669
- if (!manifest) throw new Error(`Report a plan for session ${sessionId} first`)
953
+ const manifest = requireManifest(
954
+ dataDir,
955
+ sessionId,
956
+ `Report a plan for session ${sessionId} first`,
957
+ )
670
958
  if (manifest.phase !== 'working') {
671
959
  throw new Error(`Step ${manifest.currentStep} has not been invoked`)
672
960
  }
@@ -721,7 +1009,14 @@ export function appendDiff(dataDir, targetRoot, input) {
721
1009
  writeManifest(dataDir, manifest)
722
1010
  materializeDiff(dataDir, targetRoot, sessionId, id)
723
1011
  focusSession(dataDir, sessionId)
724
- return { manifest, entry }
1012
+ const advanced = autoAdvance(dataDir, sessionId, targetRoot)
1013
+ if (!advanced) {
1014
+ throw new Error(`Session ${sessionId} disappeared after publishing a diff`)
1015
+ }
1016
+ return {
1017
+ manifest: advanced,
1018
+ entry: advanced.diffs.find((item) => item.id === id) ?? entry,
1019
+ }
725
1020
  }
726
1021
 
727
1022
  function pendingActive(manifest, diffId) {
@@ -748,8 +1043,7 @@ function applyUnresolved(dataDir, targetRoot, manifest, diffId) {
748
1043
  }
749
1044
 
750
1045
  export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
751
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
752
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
1046
+ const manifest = requireManifest(dataDir, sessionId)
753
1047
  const active = pendingActive(manifest, diffId)
754
1048
  applyUnresolved(dataDir, targetRoot, manifest, diffId)
755
1049
 
@@ -759,18 +1053,16 @@ export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
759
1053
  writeManifest(dataDir, manifest)
760
1054
  finalizeFinishedSession(dataDir, sessionId)
761
1055
  return manifest
762
- } else {
763
- manifest.currentStep = active.step + 1
764
- manifest.phase = 'plan_ready'
765
- manifest.workStartedAt = null
766
1056
  }
1057
+ manifest.currentStep = active.step + 1
1058
+ manifest.phase = 'plan_ready'
1059
+ manifest.workStartedAt = null
767
1060
  writeManifest(dataDir, manifest)
768
- return manifest
1061
+ return autoAdvance(dataDir, sessionId, targetRoot)
769
1062
  }
770
1063
 
771
1064
  export function requestReplan(dataDir, sessionId, diffId, instruction) {
772
- const manifest = readManifest(dataDir, assertSessionId(sessionId))
773
- if (!manifest) throw new Error(`Unknown session ${sessionId}`)
1065
+ const manifest = requireManifest(dataDir, sessionId)
774
1066
  const active = pendingActive(manifest, diffId)
775
1067
  const guidance = typeof instruction === 'string' ? instruction.trim() : ''
776
1068
  if (!guidance) throw new Error('An alternative instruction is required')
@@ -784,14 +1076,91 @@ export function requestReplan(dataDir, sessionId, diffId, instruction) {
784
1076
  return manifest
785
1077
  }
786
1078
 
787
- export function stopSession(dataDir, sessionId, targetRoot = null) {
1079
+ function unstageDiffSessionArtifacts(dataDir, targetRoot, extraPaths = []) {
1080
+ if (!targetRoot) return
1081
+ unstagePaths(targetRoot, [
1082
+ ...extraPaths,
1083
+ diffSessionsRoot(dataDir),
1084
+ path.join(dataDir, 'active-session.json'),
1085
+ path.join(dataDir, 'blueprint-session.json'),
1086
+ ])
1087
+ }
1088
+
1089
+ function discardStoredSession(
1090
+ dataDir,
1091
+ sessionId,
1092
+ targetRoot = null,
1093
+ { restore = true, keepStoppedMarker = false } = {},
1094
+ ) {
788
1095
  const safeId = assertSessionId(sessionId)
789
- const manifest = readManifest(dataDir, safeId)
790
- if (manifest && targetRoot) {
791
- restoreBaseline(dataDir, safeId, targetRoot)
792
- replayPatches(dataDir, safeId, targetRoot, acceptedEntries(manifest))
1096
+ const paths = sessionPaths(dataDir, safeId)
1097
+ const fileIds = Object.keys(readBaseline(dataDir, safeId).files)
1098
+ if (targetRoot && restore) {
1099
+ try {
1100
+ restoreBaseline(dataDir, safeId, targetRoot)
1101
+ } catch {
1102
+ // Incomplete session artifacts should still be deleted.
1103
+ }
793
1104
  }
794
- finalizeFinishedSession(dataDir, safeId)
1105
+ if (targetRoot) {
1106
+ unstageDiffSessionArtifacts(
1107
+ dataDir,
1108
+ targetRoot,
1109
+ fileIds.flatMap((id) => {
1110
+ try {
1111
+ return [resolveTargetFile(targetRoot, id).absolute]
1112
+ } catch {
1113
+ return []
1114
+ }
1115
+ }),
1116
+ )
1117
+ }
1118
+ releaseBlueprintSession(dataDir, safeId)
1119
+ if (fs.existsSync(paths.root)) {
1120
+ fs.rmSync(paths.root, { recursive: true, force: true })
1121
+ }
1122
+ if (!keepStoppedMarker) clearStoppedMarker(dataDir, safeId)
1123
+ const active = readActiveSession(dataDir)
1124
+ if (active === safeId) writeActiveSession(dataDir, null)
1125
+ }
1126
+
1127
+ export function discardInactiveDiffSessions(
1128
+ dataDir,
1129
+ targetRoot = null,
1130
+ waiterIds = waiterSessionIds(),
1131
+ ) {
1132
+ const keep = new Set()
1133
+ for (const value of waiterIds) {
1134
+ try {
1135
+ keep.add(assertSessionId(value))
1136
+ } catch {
1137
+ // Ignore process command lines with invalid session ids.
1138
+ }
1139
+ }
1140
+
1141
+ for (const sessionId of listStoredSessionIds(dataDir)) {
1142
+ const live = keep.has(sessionId) && Boolean(readManifest(dataDir, sessionId))
1143
+ const stopping = keep.has(sessionId) && isSessionStopped(dataDir, sessionId)
1144
+ if (live || stopping) continue
1145
+ discardStoredSession(dataDir, sessionId, targetRoot)
1146
+ }
1147
+
1148
+ const liveIds = [...keep].filter((id) => readManifest(dataDir, id))
1149
+ const active = readActiveSession(dataDir)
1150
+ if (active && !liveIds.includes(active)) writeActiveSession(dataDir, null)
1151
+ const locked = readBlueprintSession(dataDir)
1152
+ if (locked && !liveIds.includes(locked)) writeBlueprintSession(dataDir, null)
1153
+ unstageDiffSessionArtifacts(dataDir, targetRoot)
1154
+ return liveIds
1155
+ }
1156
+
1157
+ export function stopSession(dataDir, sessionId, targetRoot = null) {
1158
+ const safeId = assertSessionId(sessionId)
1159
+ writeStoppedMarker(dataDir, safeId)
1160
+ discardStoredSession(dataDir, safeId, targetRoot, { keepStoppedMarker: true })
1161
+ const waiters = waiterSessionIds()
1162
+ waiters.add(safeId)
1163
+ discardInactiveDiffSessions(dataDir, targetRoot, waiters)
795
1164
  return null
796
1165
  }
797
1166
 
@@ -819,14 +1188,7 @@ export function closeSession(dataDir, sessionId) {
819
1188
  }
820
1189
 
821
1190
  export function finalizeFinishedSession(dataDir, sessionId) {
822
- const safeId = assertSessionId(sessionId)
823
- const paths = sessionPaths(dataDir, safeId)
824
- releaseBlueprintSession(dataDir, safeId)
825
- if (fs.existsSync(paths.root)) {
826
- fs.rmSync(paths.root, { recursive: true, force: true })
827
- }
828
- const active = readActiveSession(dataDir)
829
- if (active === safeId) writeActiveSession(dataDir, null)
1191
+ discardStoredSession(dataDir, sessionId, null, { restore: false })
830
1192
  }
831
1193
 
832
1194
  export function emptyBlueprint() {