@jkwd/inbase 0.1.9 → 0.1.11

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,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import crypto from 'node:crypto'
3
4
  import { spawnSync } from 'node:child_process'
4
5
  import {
5
6
  accumulatePatchAdditions,
@@ -10,6 +11,7 @@ import {
10
11
  foldersFromFileIds,
11
12
  parseUnifiedPatch,
12
13
  } from './patch-lib.mjs'
14
+ import { diffSourceTrees, snapshotSourceTree } from './tree-diff.mjs'
13
15
 
14
16
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
15
17
  const CONNECTED_TTL_MS = 15_000
@@ -39,6 +41,19 @@ function atomicWrite(file, contents) {
39
41
  fs.renameSync(temporary, file)
40
42
  }
41
43
 
44
+ function featureName(value) {
45
+ const trimmed = typeof value === 'string' ? value.trim() : ''
46
+ return trimmed
47
+ }
48
+
49
+ function sessionName(value) {
50
+ return featureName(value)
51
+ }
52
+
53
+ function resolvedSessionName(manifest) {
54
+ return sessionName(manifest?.name) || sessionName(manifest?.feature)
55
+ }
56
+
42
57
  export function sessionPaths(dataDir, sessionId) {
43
58
  const safeId = assertSessionId(sessionId)
44
59
  const root = path.join(dataDir, 'diff-sessions', safeId)
@@ -49,6 +64,7 @@ export function sessionPaths(dataDir, sessionId) {
49
64
  blueprint: path.join(root, 'blueprint.json'),
50
65
  baseline: path.join(root, 'baseline.json'),
51
66
  baselineFiles: path.join(root, 'baseline'),
67
+ preStep: path.join(root, 'pre-step'),
52
68
  stopped: path.join(dataDir, 'diff-sessions', `${safeId}.stopped`),
53
69
  }
54
70
  }
@@ -124,6 +140,38 @@ function connectionFile(dataDir, sessionId) {
124
140
  return path.join(sessionPaths(dataDir, sessionId).root, 'connected.json')
125
141
  }
126
142
 
143
+ function ackFile(dataDir, sessionId) {
144
+ return path.join(sessionPaths(dataDir, sessionId).root, 'ack.json')
145
+ }
146
+
147
+ export function recordSessionAck(dataDir, sessionId, kind, detail = '') {
148
+ const safeId = assertSessionId(sessionId)
149
+ if (isSessionStopped(dataDir, safeId) && kind !== 'stopped' && kind !== 'finished') {
150
+ return null
151
+ }
152
+ const payload = {
153
+ kind: String(kind),
154
+ detail: String(detail ?? ''),
155
+ at: new Date().toISOString(),
156
+ }
157
+ try {
158
+ atomicWrite(ackFile(dataDir, safeId), `${JSON.stringify(payload, null, 2)}\n`)
159
+ } catch {
160
+ return null
161
+ }
162
+ return payload
163
+ }
164
+
165
+ function readSessionAck(dataDir, sessionId) {
166
+ const value = readJson(ackFile(dataDir, sessionId), null)
167
+ if (!value || typeof value.kind !== 'string' || value.kind.trim() === '') return null
168
+ return {
169
+ kind: value.kind,
170
+ detail: typeof value.detail === 'string' ? value.detail : '',
171
+ at: typeof value.at === 'string' ? value.at : null,
172
+ }
173
+ }
174
+
127
175
  function isFreshTimestamp(value, now = Date.now()) {
128
176
  if (typeof value !== 'string') return false
129
177
  const at = Date.parse(value)
@@ -137,6 +185,11 @@ export function touchSessionConnection(dataDir, sessionId) {
137
185
  connectionFile(dataDir, safeId),
138
186
  `${JSON.stringify({ sessionId: safeId, connectedAt: new Date().toISOString() }, null, 2)}\n`,
139
187
  )
188
+ const manifest = readManifest(dataDir, safeId)
189
+ if (manifest?.awaitingAttach) {
190
+ manifest.awaitingAttach = false
191
+ writeManifest(dataDir, manifest)
192
+ }
140
193
  }
141
194
 
142
195
  function waiterSessionIds() {
@@ -171,6 +224,16 @@ function isGeneratingPhase(phase) {
171
224
  return phase === 'preparing' || phase === 'working' || phase === 'replanning'
172
225
  }
173
226
 
227
+ function isTerminalSession(manifest) {
228
+ return (
229
+ !manifest ||
230
+ manifest.phase === 'finished' ||
231
+ manifest.phase === 'stopped' ||
232
+ manifest.status === 'finished' ||
233
+ manifest.status === 'rejected'
234
+ )
235
+ }
236
+
174
237
  function isStalledWorking(manifest, waiterIds, sessionId, now = Date.now()) {
175
238
  if (manifest.phase !== 'working') return false
176
239
  if (!waiterIds.has(sessionId)) return false
@@ -185,19 +248,13 @@ export function isSessionConnected(
185
248
  ) {
186
249
  const safeId = assertSessionId(sessionId)
187
250
  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
251
+ if (isTerminalSession(manifest)) return false
199
252
  const connected = readJson(connectionFile(dataDir, safeId), null)
200
- if (isFreshTimestamp(connected?.connectedAt)) return true
253
+ const heartbeat =
254
+ waiterIds.has(safeId) || isFreshTimestamp(connected?.connectedAt)
255
+ if (manifest.awaitingAttach) return heartbeat
256
+ if (isGeneratingPhase(manifest.phase)) return true
257
+ if (heartbeat) return true
201
258
  return isFreshTimestamp(manifest.updatedAt) || isFreshTimestamp(manifest.createdAt)
202
259
  }
203
260
 
@@ -232,9 +289,8 @@ export function listOpenSessionIds(dataDir, waiterIds = waiterSessionIds()) {
232
289
  if (!entry.isDirectory()) continue
233
290
  try {
234
291
  const sessionId = assertSessionId(entry.name)
235
- if (!isSessionConnected(dataDir, sessionId, waiterIds)) continue
236
292
  const manifest = readManifest(dataDir, sessionId)
237
- if (!manifest) continue
293
+ if (isTerminalSession(manifest)) continue
238
294
  sessions.push({
239
295
  sessionId,
240
296
  createdAt: typeof manifest.createdAt === 'string' ? manifest.createdAt : '',
@@ -273,32 +329,34 @@ export function writeBlueprintSession(dataDir, sessionId) {
273
329
  )
274
330
  }
275
331
 
276
- function assertBlueprintSessionAvailable(dataDir, sessionId) {
332
+ function releaseBlueprintSession(dataDir, sessionId) {
277
333
  const safeId = assertSessionId(sessionId)
278
- const locked = readBlueprintSession(dataDir)
279
- if (locked && locked !== safeId) {
280
- throw new Error(
281
- `Blueprint edit mode is active in session ${locked}. Finish or stop it before starting another.`,
282
- )
283
- }
284
- return safeId
334
+ if (readBlueprintSession(dataDir) === safeId) writeBlueprintSession(dataDir, null)
285
335
  }
286
336
 
287
- function claimBlueprintSession(dataDir, sessionId) {
288
- const safeId = assertBlueprintSessionAvailable(dataDir, sessionId)
289
- writeBlueprintSession(dataDir, safeId)
337
+ export function focusSession(dataDir, sessionId) {
338
+ const safeId = assertSessionId(sessionId)
339
+ requireManifest(dataDir, safeId)
340
+ writeActiveSession(dataDir, safeId)
290
341
  return safeId
291
342
  }
292
343
 
293
- function releaseBlueprintSession(dataDir, sessionId) {
294
- const safeId = assertSessionId(sessionId)
295
- if (readBlueprintSession(dataDir) === safeId) writeBlueprintSession(dataDir, null)
344
+ function sessionAllowsPlacement(manifest) {
345
+ return (
346
+ manifest.phase !== 'blueprint_ask' &&
347
+ manifest.phase !== 'finished' &&
348
+ manifest.phase !== 'stopped'
349
+ )
296
350
  }
297
351
 
298
- function focusSession(dataDir, sessionId) {
299
- const safeId = assertSessionId(sessionId)
300
- const locked = readBlueprintSession(dataDir)
301
- if (!locked || locked === safeId) writeActiveSession(dataDir, safeId)
352
+ function blueprintHasContent(blueprint) {
353
+ return (
354
+ (blueprint.userCreatedBlocks?.length ?? 0) > 0 ||
355
+ (blueprint.userCreatedIslands?.length ?? 0) > 0 ||
356
+ (blueprint.addedFunctions?.length ?? 0) > 0 ||
357
+ (blueprint.addedVariables?.length ?? 0) > 0 ||
358
+ (blueprint.addedImports?.length ?? 0) > 0
359
+ )
302
360
  }
303
361
 
304
362
  export function readManifest(dataDir, sessionId) {
@@ -324,6 +382,8 @@ export function readManifest(dataDir, sessionId) {
324
382
  value.workStartedAt ??= null
325
383
  }
326
384
  if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
385
+ value.initialInstruction =
386
+ typeof value.initialInstruction === 'string' ? value.initialInstruction : null
327
387
  return value
328
388
  }
329
389
 
@@ -353,13 +413,34 @@ export function chainThrough(manifest, diffId = manifest.activeDiffId) {
353
413
  return manifest.diffs.slice(0, entryIndex(manifest, diffId) + 1)
354
414
  }
355
415
 
356
- function unresolvedEntries(manifest, diffId = manifest.activeDiffId) {
416
+ function isSupersededPatch(entry) {
417
+ return (
418
+ entry.status === 'rejected' ||
419
+ entry.status === 'extend' ||
420
+ entry.status === 'extended'
421
+ )
422
+ }
423
+
424
+ function liveEntries(manifest, diffId) {
357
425
  const chain = chainThrough(manifest, diffId)
358
- let lastApplied = -1
359
- chain.forEach((entry, index) => {
360
- if (entry.status === 'applied') lastApplied = index
426
+ if (chain.length === 0) return []
427
+ const selected = chain.at(-1)
428
+ const browsingHistory = selected.id !== manifest.activeDiffId
429
+ return chain.filter((entry) => {
430
+ if (entry.status === 'rejected') return false
431
+ if (entry.status === 'extended' || entry.status === 'extend') {
432
+ return browsingHistory && entry.id === selected.id
433
+ }
434
+ return true
361
435
  })
362
- return chain.slice(lastApplied + 1).filter((entry) => entry.status !== 'rejected')
436
+ }
437
+
438
+ function workingPatchEntries(manifest) {
439
+ return manifest.diffs.filter((entry) => !isSupersededPatch(entry))
440
+ }
441
+
442
+ function unresolvedEntries(manifest, diffId = manifest.activeDiffId) {
443
+ return liveEntries(manifest, diffId).filter((entry) => entry.status !== 'applied')
363
444
  }
364
445
 
365
446
  export function previewPatchChain(patches, knownFileIds = []) {
@@ -444,10 +525,9 @@ export function sessionIntent(
444
525
  const patches =
445
526
  selectedIndex === null
446
527
  ? []
447
- : manifest.diffs
448
- .slice(0, selectedIndex + 1)
449
- .filter((entry) => entry.status !== 'rejected')
450
- .map((entry) => readDiff(dataDir, sessionId, entry))
528
+ : liveEntries(manifest, selectedId).map((entry) =>
529
+ readDiff(dataDir, sessionId, entry),
530
+ )
451
531
  const preview = previewPatchChain(patches, knownFileIds)
452
532
  const activeView = !selectedDiffId || selectedId === manifest.activeDiffId
453
533
  const phaseStatus = {
@@ -470,18 +550,19 @@ export function sessionIntent(
470
550
  )
471
551
  const previewVisible = patches.length > 0
472
552
  const blueprint = readBlueprint(dataDir, sessionId)
473
- const blueprintSessionId = readBlueprintSession(dataDir)
474
- const ownsBlueprintLock = blueprintSessionId === sessionId
475
- const canEnterBlueprint =
476
- manifest.phase === 'blueprint_ask' &&
477
- (!blueprintSessionId || ownsBlueprintLock)
553
+ const canEnterBlueprint = manifest.phase === 'blueprint_ask'
478
554
 
479
555
  return {
480
556
  updatedAt: manifest.updatedAt,
481
557
  showMap: previewVisible,
482
558
  status: activeView ? phaseStatus ?? historicalStatus : historicalStatus,
483
559
  phase: manifest.phase,
560
+ name: resolvedSessionName(manifest) || null,
484
561
  feature: manifest.feature,
562
+ initialInstruction:
563
+ typeof manifest.initialInstruction === 'string'
564
+ ? manifest.initialInstruction
565
+ : null,
485
566
  steps: manifest.steps,
486
567
  step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
487
568
  stepByStep: isStepByStep(manifest),
@@ -500,13 +581,20 @@ export function sessionIntent(
500
581
  isActiveDiff: Boolean(selected && selected.id === manifest.activeDiffId),
501
582
  preview: previewVisible,
502
583
  working:
503
- manifest.phase === 'preparing' ||
504
- manifest.phase === 'working' ||
505
- manifest.phase === 'replanning',
584
+ !manifest.awaitingAttach &&
585
+ (manifest.phase === 'preparing' ||
586
+ manifest.phase === 'working' ||
587
+ manifest.phase === 'replanning'),
506
588
  stalledWait: isStalledWorking(manifest, waiterIds, sessionId),
507
- creationMode: manifest.phase === 'blueprint' && ownsBlueprintLock,
589
+ llmIdle: !isSessionConnected(dataDir, sessionId, waiterIds),
590
+ awaitingAttach:
591
+ Boolean(manifest.awaitingAttach) &&
592
+ !isSessionConnected(dataDir, sessionId, waiterIds),
593
+ listening: waiterIds.has(sessionId),
594
+ lastAck: readSessionAck(dataDir, sessionId),
595
+ creationMode: sessionAllowsPlacement(manifest),
508
596
  canEnterBlueprint,
509
- blueprintSessionId,
597
+ blueprintSessionId: null,
510
598
  userCreatedBlocks: blueprint.userCreatedBlocks,
511
599
  userCreatedIslands: blueprint.userCreatedIslands,
512
600
  ...preview,
@@ -639,10 +727,6 @@ function unstagePaths(fromDir, absolutePaths) {
639
727
  }
640
728
  }
641
729
 
642
- function liveEntries(manifest, diffId) {
643
- return chainThrough(manifest, diffId).filter((entry) => entry.status !== 'rejected')
644
- }
645
-
646
730
  export function materializeDiff(dataDir, targetRoot, sessionId, diffId) {
647
731
  const manifest = requireManifest(dataDir, sessionId)
648
732
  const through = diffId || manifest.activeDiffId
@@ -682,9 +766,9 @@ function loadReplayContents(dataDir, sessionId, targetRoot, patches) {
682
766
  }
683
767
 
684
768
  export function validateContinuation(dataDir, manifest, targetRoot, patchText) {
685
- const prior = manifest.diffs
686
- .filter((entry) => entry.status !== 'rejected')
687
- .map((entry) => readDiff(dataDir, manifest.sessionId, entry))
769
+ const prior = workingPatchEntries(manifest).map((entry) =>
770
+ readDiff(dataDir, manifest.sessionId, entry),
771
+ )
688
772
  const patches = [...prior, patchText]
689
773
  const files = loadReplayContents(
690
774
  dataDir,
@@ -722,11 +806,6 @@ function planSteps(titles, startAt = 1) {
722
806
  })
723
807
  }
724
808
 
725
- function featureName(value) {
726
- const trimmed = typeof value === 'string' ? value.trim() : ''
727
- return trimmed
728
- }
729
-
730
809
  export function isStepByStep(manifest) {
731
810
  return manifest?.stepByStep !== false
732
811
  }
@@ -758,7 +837,12 @@ export function startSession(dataDir, input) {
758
837
  const sessionId = assertSessionId(input.sessionId)
759
838
  clearStoppedMarker(dataDir, sessionId)
760
839
  const existing = readManifest(dataDir, sessionId)
840
+ const name = sessionName(input.name) || sessionName(input.feature)
761
841
  if (existing) {
842
+ if (name && existing.name !== name) {
843
+ existing.name = name
844
+ writeManifest(dataDir, existing)
845
+ }
762
846
  focusSession(dataDir, sessionId)
763
847
  return existing
764
848
  }
@@ -767,7 +851,8 @@ export function startSession(dataDir, input) {
767
851
  const manifest = {
768
852
  version: 2,
769
853
  sessionId,
770
- feature: featureName(input.feature),
854
+ name,
855
+ feature: featureName(input.feature) || name,
771
856
  steps: [],
772
857
  status: 'active',
773
858
  phase: 'blueprint_ask',
@@ -775,6 +860,7 @@ export function startSession(dataDir, input) {
775
860
  currentStep: 1,
776
861
  activeDiffId: null,
777
862
  pendingInstruction: null,
863
+ initialInstruction: null,
778
864
  workStartedAt: null,
779
865
  createdAt: now,
780
866
  updatedAt: now,
@@ -786,12 +872,120 @@ export function startSession(dataDir, input) {
786
872
  return manifest
787
873
  }
788
874
 
875
+ export function setupSession(dataDir, input = {}) {
876
+ const sessionId = input.sessionId
877
+ ? assertSessionId(input.sessionId)
878
+ : generateVisualizerSessionId(dataDir)
879
+ const existing = readManifest(dataDir, sessionId)
880
+ if (existing && !isTerminalSession(existing)) {
881
+ throw new Error(`Session ${sessionId} already exists`)
882
+ }
883
+ if (existing) {
884
+ discardStoredSession(dataDir, sessionId, null, { restore: false })
885
+ }
886
+ clearStoppedMarker(dataDir, sessionId)
887
+ const now = new Date().toISOString()
888
+ const name = sessionName(input.name)
889
+ const manifest = {
890
+ version: 2,
891
+ sessionId,
892
+ name,
893
+ feature: featureName(input.feature) || name,
894
+ steps: [],
895
+ status: 'active',
896
+ phase: 'blueprint',
897
+ awaitingAttach: true,
898
+ stepByStep: true,
899
+ currentStep: 1,
900
+ activeDiffId: null,
901
+ pendingInstruction: null,
902
+ initialInstruction: null,
903
+ workStartedAt: null,
904
+ createdAt: now,
905
+ updatedAt: now,
906
+ diffs: [],
907
+ }
908
+ writeManifest(dataDir, manifest)
909
+ writeBlueprint(dataDir, sessionId, {
910
+ ...emptyBlueprint(),
911
+ enabled: true,
912
+ sent: false,
913
+ })
914
+ focusSession(dataDir, sessionId)
915
+ return manifest
916
+ }
917
+
918
+ export function setInitialInstruction(dataDir, sessionId, instruction) {
919
+ const manifest = requireManifest(dataDir, sessionId)
920
+ if (isTerminalSession(manifest)) {
921
+ throw sessionStoppedError(sessionId)
922
+ }
923
+ const text = typeof instruction === 'string' ? instruction : ''
924
+ if (text.length > 4000) {
925
+ throw new Error('instruction must be a string up to 4000 characters')
926
+ }
927
+ const next = text.trim() === '' ? null : text
928
+ if ((manifest.initialInstruction ?? null) === next) return manifest
929
+ manifest.initialInstruction = next
930
+ writeManifest(dataDir, manifest)
931
+ return manifest
932
+ }
933
+
934
+ function generateVisualizerSessionId(dataDir) {
935
+ for (let attempt = 0; attempt < 8; attempt += 1) {
936
+ const sessionId = `viz-${crypto.randomBytes(6).toString('hex')}`
937
+ if (!readManifest(dataDir, sessionId) && !isSessionStopped(dataDir, sessionId)) {
938
+ return sessionId
939
+ }
940
+ }
941
+ throw new Error('Could not allocate a visualizer session id')
942
+ }
943
+
944
+ export function readAttachedSession(dataDir) {
945
+ for (const sessionId of listOpenSessionIds(dataDir)) {
946
+ const manifest = readManifest(dataDir, sessionId)
947
+ if (manifest?.awaitingAttach === false) return sessionId
948
+ }
949
+ return null
950
+ }
951
+
952
+ export function attachSession(dataDir, sessionId) {
953
+ const safeId = sessionId
954
+ ? assertSessionId(sessionId)
955
+ : readActiveSession(dataDir)
956
+ if (!safeId) {
957
+ throw new Error(
958
+ 'No visualizer session is focused. Click Setup LLM session in the map, then /inbase.',
959
+ )
960
+ }
961
+ const manifest = requireManifest(
962
+ dataDir,
963
+ safeId,
964
+ `No visualizer session ${safeId}. Click Setup LLM session in the map, then /inbase.`,
965
+ )
966
+ if (isTerminalSession(manifest)) {
967
+ throw sessionStoppedError(safeId)
968
+ }
969
+ const attached = readAttachedSession(dataDir)
970
+ if (attached && attached !== safeId) {
971
+ const other = readManifest(dataDir, attached)
972
+ const label = resolvedSessionName(other) || attached
973
+ throw new Error(
974
+ `An LLM is already attached to ${label}. Stop that session before attaching another.`,
975
+ )
976
+ }
977
+ focusSession(dataDir, safeId)
978
+ touchSessionConnection(dataDir, safeId)
979
+ recordSessionAck(dataDir, safeId, 'attached', resolvedSessionName(manifest) || safeId)
980
+ maybeStartVisualizerHandshake(dataDir, safeId)
981
+ return readManifest(dataDir, safeId) ?? manifest
982
+ }
983
+
789
984
  export function answerBlueprint(dataDir, sessionId, enabled) {
790
985
  const manifest = requireManifest(dataDir, sessionId)
791
986
  if (manifest.phase !== 'blueprint_ask') {
792
987
  throw new Error(`Session ${sessionId} is not asking for a blueprint`)
793
988
  }
794
- if (enabled) claimBlueprintSession(dataDir, sessionId)
795
989
  const blueprint = {
796
990
  ...emptyBlueprint(),
797
991
  enabled: Boolean(enabled),
@@ -807,20 +1001,22 @@ export function answerBlueprint(dataDir, sessionId, enabled) {
807
1001
  export function updateBlueprint(dataDir, sessionId, input = {}) {
808
1002
  const safeId = assertSessionId(sessionId)
809
1003
  const manifest = requireManifest(dataDir, safeId)
810
- if (manifest.phase !== 'blueprint') {
811
- throw new Error(`Session ${safeId} is not in blueprint mode`)
1004
+ if (!sessionAllowsPlacement(manifest)) {
1005
+ throw new Error(`Session ${safeId} is not accepting blueprint edits`)
812
1006
  }
813
- assertBlueprintSessionAvailable(dataDir, safeId)
814
1007
  const current = readBlueprint(dataDir, safeId)
815
- writeBlueprint(dataDir, safeId, {
1008
+ const next = {
816
1009
  ...current,
817
- enabled: true,
818
- sent: false,
819
1010
  userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
820
1011
  userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
821
1012
  addedFunctions: input.addedFunctions ?? current.addedFunctions,
822
1013
  addedVariables: input.addedVariables ?? current.addedVariables,
823
1014
  addedImports: input.addedImports ?? current.addedImports,
1015
+ }
1016
+ writeBlueprint(dataDir, safeId, {
1017
+ ...next,
1018
+ enabled: current.enabled || blueprintHasContent(next),
1019
+ sent: manifest.phase === 'blueprint' ? false : current.sent,
824
1020
  })
825
1021
  return readBlueprint(dataDir, safeId)
826
1022
  }
@@ -831,16 +1027,37 @@ export function sendBlueprint(dataDir, sessionId, input = {}) {
831
1027
  if (manifest.phase !== 'blueprint') {
832
1028
  throw new Error(`Session ${safeId} is not in blueprint mode`)
833
1029
  }
834
- assertBlueprintSessionAvailable(dataDir, safeId)
835
1030
  const current = readBlueprint(dataDir, safeId)
836
- writeBlueprint(dataDir, safeId, {
837
- enabled: true,
838
- sent: true,
1031
+ const next = {
839
1032
  userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
840
1033
  userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
841
1034
  addedFunctions: input.addedFunctions ?? current.addedFunctions,
842
1035
  addedVariables: input.addedVariables ?? current.addedVariables,
843
1036
  addedImports: input.addedImports ?? current.addedImports,
1037
+ }
1038
+ writeBlueprint(dataDir, safeId, {
1039
+ ...next,
1040
+ enabled: blueprintHasContent(next),
1041
+ sent: true,
1042
+ })
1043
+ manifest.phase = 'preparing'
1044
+ manifest.workStartedAt = new Date().toISOString()
1045
+ writeManifest(dataDir, manifest)
1046
+ releaseBlueprintSession(dataDir, safeId)
1047
+ return manifest
1048
+ }
1049
+
1050
+ export function maybeStartVisualizerHandshake(dataDir, sessionId) {
1051
+ const safeId = assertSessionId(sessionId)
1052
+ const manifest = requireManifest(dataDir, safeId)
1053
+ if (manifest.phase !== 'blueprint_ask' && manifest.phase !== 'blueprint') {
1054
+ return manifest
1055
+ }
1056
+ const current = readBlueprint(dataDir, safeId)
1057
+ writeBlueprint(dataDir, safeId, {
1058
+ ...current,
1059
+ enabled: blueprintHasContent(current),
1060
+ sent: true,
844
1061
  })
845
1062
  manifest.phase = 'preparing'
846
1063
  manifest.workStartedAt = new Date().toISOString()
@@ -867,6 +1084,7 @@ export function reportPlan(dataDir, input) {
867
1084
  const manifest = existing ?? {
868
1085
  version: 2,
869
1086
  sessionId,
1087
+ name: sessionName(input.name) || sessionName(input.feature),
870
1088
  feature: input.feature,
871
1089
  steps: [],
872
1090
  status: 'active',
@@ -875,11 +1093,15 @@ export function reportPlan(dataDir, input) {
875
1093
  currentStep: 1,
876
1094
  activeDiffId: null,
877
1095
  pendingInstruction: null,
1096
+ initialInstruction: null,
878
1097
  workStartedAt: null,
879
1098
  createdAt: now,
880
1099
  updatedAt: now,
881
1100
  diffs: [],
882
1101
  }
1102
+ if (!sessionName(manifest.name)) {
1103
+ manifest.name = sessionName(input.feature)
1104
+ }
883
1105
  manifest.feature = input.feature
884
1106
  manifest.steps = planSteps(input.stepTitles)
885
1107
  manifest.status = 'active'
@@ -888,7 +1110,13 @@ export function reportPlan(dataDir, input) {
888
1110
  manifest.workStartedAt = null
889
1111
  writeManifest(dataDir, manifest)
890
1112
  focusSession(dataDir, sessionId)
891
- return autoAdvance(dataDir, sessionId)
1113
+ recordSessionAck(
1114
+ dataDir,
1115
+ sessionId,
1116
+ 'plan',
1117
+ `${manifest.steps.length} step(s)`,
1118
+ )
1119
+ return autoAdvance(dataDir, sessionId, input.targetRoot)
892
1120
  }
893
1121
 
894
1122
  if (existing.phase !== 'replanning') {
@@ -905,7 +1133,13 @@ export function reportPlan(dataDir, input) {
905
1133
  existing.workStartedAt = null
906
1134
  writeManifest(dataDir, existing)
907
1135
  focusSession(dataDir, sessionId)
908
- return autoAdvance(dataDir, sessionId)
1136
+ recordSessionAck(
1137
+ dataDir,
1138
+ sessionId,
1139
+ 'plan',
1140
+ `${existing.steps.filter((step) => step.index >= startAt).length} step(s)`,
1141
+ )
1142
+ return autoAdvance(dataDir, sessionId, input.targetRoot)
909
1143
  }
910
1144
 
911
1145
  export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
@@ -919,8 +1153,8 @@ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
919
1153
  if (step !== expected) {
920
1154
  throw new Error(
921
1155
  last
922
- ? `Run step ${active.step} to finish`
923
- : `Run step ${expected} to continue`,
1156
+ ? `Accept proposal on step ${active.step} to finish`
1157
+ : `Accept proposal on step ${active.step} to continue`,
924
1158
  )
925
1159
  }
926
1160
  applyUnresolved(dataDir, targetRoot, manifest, active.id)
@@ -945,9 +1179,33 @@ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
945
1179
  manifest.phase = 'working'
946
1180
  manifest.workStartedAt = new Date().toISOString()
947
1181
  writeManifest(dataDir, manifest)
1182
+ const title = manifest.steps.find((item) => item.index === step)?.title
1183
+ recordSessionAck(
1184
+ dataDir,
1185
+ sessionId,
1186
+ 'invoke',
1187
+ title ? `step ${step} — ${title}` : `step ${step}`,
1188
+ )
1189
+ if (targetRoot) snapshotPreStep(dataDir, sessionId, targetRoot)
948
1190
  return manifest
949
1191
  }
950
1192
 
1193
+ export function snapshotPreStep(dataDir, sessionId, targetRoot) {
1194
+ const { preStep } = sessionPaths(dataDir, sessionId)
1195
+ snapshotSourceTree(targetRoot, preStep)
1196
+ return preStep
1197
+ }
1198
+
1199
+ export function readLiveDiff(dataDir, sessionId, targetRoot) {
1200
+ const { preStep } = sessionPaths(dataDir, sessionId)
1201
+ if (!fs.existsSync(preStep)) {
1202
+ throw new Error(
1203
+ `Step ${sessionId} has no invoke snapshot. Wait for VISUAL_CODER_EXECUTE before recording file changes.`,
1204
+ )
1205
+ }
1206
+ return diffSourceTrees(preStep, targetRoot)
1207
+ }
1208
+
951
1209
  export function appendDiff(dataDir, targetRoot, input) {
952
1210
  const sessionId = assertSessionId(input.sessionId)
953
1211
  const manifest = requireManifest(
@@ -974,12 +1232,19 @@ export function appendDiff(dataDir, targetRoot, input) {
974
1232
  throw new Error(`The next diff must implement step ${parent.step + 1}`)
975
1233
  }
976
1234
 
977
- validateContinuation(dataDir, manifest, targetRoot, input.patchText)
1235
+ const patchText = input.patchText ?? readLiveDiff(dataDir, sessionId, targetRoot)
1236
+ if (!patchText.trim()) {
1237
+ throw new Error('No file changes to record for this step')
1238
+ }
1239
+
1240
+ const snapshotRoot = sessionPaths(dataDir, sessionId).preStep
1241
+ const originRoot = fs.existsSync(snapshotRoot) ? snapshotRoot : targetRoot
1242
+ validateContinuation(dataDir, manifest, originRoot, patchText)
978
1243
  captureBaseline(
979
1244
  dataDir,
980
1245
  sessionId,
981
- targetRoot,
982
- parseUnifiedPatch(input.patchText).entries.map((entry) => entry.id),
1246
+ originRoot,
1247
+ parseUnifiedPatch(patchText).entries.map((entry) => entry.id),
983
1248
  )
984
1249
  if (parent?.status === 'extend') parent.status = 'extended'
985
1250
 
@@ -1000,7 +1265,7 @@ export function appendDiff(dataDir, targetRoot, input) {
1000
1265
  fs.mkdirSync(paths.diffs, { recursive: true })
1001
1266
  atomicWrite(
1002
1267
  path.join(paths.root, file),
1003
- input.patchText.endsWith('\n') ? input.patchText : `${input.patchText}\n`,
1268
+ patchText.endsWith('\n') ? patchText : `${patchText}\n`,
1004
1269
  )
1005
1270
  manifest.activeDiffId = id
1006
1271
  manifest.phase = 'review'
@@ -1061,7 +1326,13 @@ export function continueDiff(dataDir, targetRoot, sessionId, diffId) {
1061
1326
  return autoAdvance(dataDir, sessionId, targetRoot)
1062
1327
  }
1063
1328
 
1064
- export function requestReplan(dataDir, sessionId, diffId, instruction) {
1329
+ export function requestReplan(
1330
+ dataDir,
1331
+ sessionId,
1332
+ diffId,
1333
+ instruction,
1334
+ targetRoot = null,
1335
+ ) {
1065
1336
  const manifest = requireManifest(dataDir, sessionId)
1066
1337
  const active = pendingActive(manifest, diffId)
1067
1338
  const guidance = typeof instruction === 'string' ? instruction.trim() : ''
@@ -1073,6 +1344,7 @@ export function requestReplan(dataDir, sessionId, diffId, instruction) {
1073
1344
  manifest.pendingInstruction = guidance
1074
1345
  manifest.workStartedAt = new Date().toISOString()
1075
1346
  writeManifest(dataDir, manifest)
1347
+ if (targetRoot) materializeDiff(dataDir, targetRoot, sessionId, active.id)
1076
1348
  return manifest
1077
1349
  }
1078
1350
 
@@ -1139,13 +1411,16 @@ export function discardInactiveDiffSessions(
1139
1411
  }
1140
1412
 
1141
1413
  for (const sessionId of listStoredSessionIds(dataDir)) {
1142
- const live = keep.has(sessionId) && Boolean(readManifest(dataDir, sessionId))
1414
+ const manifest = readManifest(dataDir, sessionId)
1415
+ if (!isTerminalSession(manifest)) continue
1143
1416
  const stopping = keep.has(sessionId) && isSessionStopped(dataDir, sessionId)
1144
- if (live || stopping) continue
1417
+ if (stopping) continue
1145
1418
  discardStoredSession(dataDir, sessionId, targetRoot)
1146
1419
  }
1147
1420
 
1148
- const liveIds = [...keep].filter((id) => readManifest(dataDir, id))
1421
+ const liveIds = listStoredSessionIds(dataDir).filter(
1422
+ (id) => !isTerminalSession(readManifest(dataDir, id)),
1423
+ )
1149
1424
  const active = readActiveSession(dataDir)
1150
1425
  if (active && !liveIds.includes(active)) writeActiveSession(dataDir, null)
1151
1426
  const locked = readBlueprintSession(dataDir)
@@ -1170,6 +1445,12 @@ export function clearDiffSessions(dataDir, targetRoot = null) {
1170
1445
  unstageDiffSessionArtifacts(dataDir, targetRoot)
1171
1446
  }
1172
1447
 
1448
+ export function recoverOpenDiffSessions(dataDir, targetRoot = null) {
1449
+ // Visualizer startup never reopens an LLM session.
1450
+ clearDiffSessions(dataDir, targetRoot)
1451
+ return []
1452
+ }
1453
+
1173
1454
  export function stopSession(dataDir, sessionId, targetRoot = null) {
1174
1455
  const safeId = assertSessionId(sessionId)
1175
1456
  writeStoppedMarker(dataDir, safeId)
@@ -1192,7 +1473,7 @@ export function decideDiff(
1192
1473
  return continueDiff(dataDir, targetRoot, sessionId, diffId)
1193
1474
  }
1194
1475
  if (decision === 'extend') {
1195
- return requestReplan(dataDir, sessionId, diffId, instruction)
1476
+ return requestReplan(dataDir, sessionId, diffId, instruction, targetRoot)
1196
1477
  }
1197
1478
  return stopSession(dataDir, sessionId, targetRoot)
1198
1479
  }