@jkwd/inbase 0.1.21 → 0.1.22

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.
Files changed (58) hide show
  1. package/README.md +13 -7
  2. package/apps/explorer/package.json +1 -0
  3. package/apps/explorer/scripts/explain-store.d.ts +135 -0
  4. package/apps/explorer/scripts/explain-store.mjs +666 -0
  5. package/apps/explorer/scripts/patch-lib.mjs +4 -0
  6. package/apps/explorer/scripts/scan-target.mjs +22 -5
  7. package/apps/explorer/scripts/session-store.d.ts +86 -11
  8. package/apps/explorer/scripts/session-store.mjs +371 -58
  9. package/apps/explorer/scripts/target-config.d.ts +38 -3
  10. package/apps/explorer/scripts/target-config.mjs +147 -3
  11. package/apps/explorer/src/App.tsx +1073 -158
  12. package/apps/explorer/src/agentIntent.ts +61 -7
  13. package/apps/explorer/src/codebase.ts +1 -1
  14. package/apps/explorer/src/devTargets.ts +66 -0
  15. package/apps/explorer/src/explain.ts +312 -0
  16. package/apps/explorer/src/index.css +874 -222
  17. package/apps/explorer/src/layout.ts +55 -0
  18. package/apps/explorer/src/scene/DistantFileBlocks.tsx +4 -2
  19. package/apps/explorer/src/scene/FileBlock.tsx +95 -72
  20. package/apps/explorer/src/scene/FolderArea.tsx +55 -32
  21. package/apps/explorer/src/scene/MapView.tsx +506 -33
  22. package/apps/explorer/src/scene/RelationLines.tsx +7 -0
  23. package/apps/explorer/src/scene/World.tsx +146 -32
  24. package/apps/explorer/src/speech.ts +228 -0
  25. package/apps/explorer/src/theme.ts +29 -0
  26. package/apps/explorer/src/types.ts +98 -8
  27. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +1 -1
  28. package/apps/explorer/src/ui/ExplainAskCard.tsx +142 -0
  29. package/apps/explorer/src/ui/ExplainHud.tsx +524 -0
  30. package/apps/explorer/src/ui/ExplainInfoPanel.tsx +135 -0
  31. package/apps/explorer/src/ui/ExplainPointer.tsx +73 -0
  32. package/apps/explorer/src/ui/EyeIcon.tsx +38 -1
  33. package/apps/explorer/src/ui/HUD.tsx +1067 -795
  34. package/apps/explorer/src/ui/NameInput.tsx +114 -5
  35. package/apps/explorer/src/userContext.ts +0 -11
  36. package/apps/explorer/src/userCreated.ts +54 -1
  37. package/apps/explorer/vite.config.ts +173 -26
  38. package/bin/inbase.mjs +11 -2
  39. package/bin/project.mjs +1 -1
  40. package/bin/session.mjs +287 -38
  41. package/package.json +4 -1
  42. package/skill/commands/amber.md +23 -0
  43. package/skill/commands/blue.md +13 -0
  44. package/skill/commands/coral.md +23 -0
  45. package/skill/commands/explain.md +77 -0
  46. package/skill/commands/green.md +23 -0
  47. package/skill/commands/inbase.md +7 -5
  48. package/skill/commands/lime.md +23 -0
  49. package/skill/commands/orange.md +23 -0
  50. package/skill/commands/purple.md +23 -0
  51. package/skill/commands/red.md +23 -0
  52. package/skill/commands/skipinbase.md +1 -1
  53. package/skill/commands/violet.md +23 -0
  54. package/skill/commands/yellow.md +23 -0
  55. package/skill/inbase/SKILL.md +124 -76
  56. package/apps/explorer/src/scene/BlockPlacer.tsx +0 -78
  57. package/apps/explorer/src/scene/IslandPlacer.tsx +0 -31
  58. package/apps/explorer/src/scene/SelectionThumbnail.tsx +0 -1069
@@ -16,6 +16,45 @@ import { diffSourceTrees, snapshotSourceTree } from './tree-diff.mjs'
16
16
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
17
17
  const CONNECTED_TTL_MS = 15_000
18
18
  const STALLED_WAIT_MS = 2_000
19
+ export const SESSION_SLOT_COUNT = 5
20
+ export const SESSION_COLORS = [
21
+ { id: 'coral', name: 'Coral', hex: '#f87171' },
22
+ { id: 'amber', name: 'Amber', hex: '#fbbf24' },
23
+ { id: 'lime', name: 'Lime', hex: '#a3e635' },
24
+ { id: 'orange', name: 'Orange', hex: '#fb923c' },
25
+ { id: 'violet', name: 'Violet', hex: '#c084fc' },
26
+ ]
27
+ export const SESSION_COLOR_ALIASES = {
28
+ coral: 'coral',
29
+ red: 'coral',
30
+ amber: 'amber',
31
+ yellow: 'amber',
32
+ lime: 'lime',
33
+ green: 'lime',
34
+ orange: 'orange',
35
+ violet: 'violet',
36
+ purple: 'violet',
37
+ }
38
+ const GLOBAL_COLOR_QUERIES = new Set(['blue', 'global', 'sky'])
39
+ export const GLOBAL_BLUEPRINT_COLOR = {
40
+ id: 'global',
41
+ name: 'Global',
42
+ hex: '#38bdf8',
43
+ }
44
+ export const CHAT_LIMIT_MESSAGE =
45
+ 'VISUAL_CODER_CHAT_LIMIT Only 5 Inbase chats can be connected at once. Finish or stop one in the map, then start a new chat.'
46
+ export const NOT_RUNNING_MESSAGE =
47
+ "VISUAL_CODER_NOT_RUNNING Inbase isn't running. Start it with `npx inbase run`, then send this request again."
48
+ export function colorUnknownMessage(query) {
49
+ const label = typeof query === 'string' && query.trim() ? query.trim() : 'That color'
50
+ return `VISUAL_CODER_COLOR_UNKNOWN ${label} is not a chat color. Connect with /coral, /amber, /lime, /orange, or /violet (aliases: /red, /yellow, /green, /purple). Blue is the global blueprint, not a chat.`
51
+ }
52
+ export function colorBusyMessage(colorName) {
53
+ return `VISUAL_CODER_COLOR_BUSY The ${colorName} session already has a chat connected. Finish or stop it in the map, then try again.`
54
+ }
55
+ export function colorMissingMessage(colorName) {
56
+ return `VISUAL_CODER_COLOR_UNKNOWN No ${colorName} session is open. Start Inbase with \`npx inbase run\`, then try again.`
57
+ }
19
58
  export const MAX_CONTEXT_FILES = 16
20
59
  export const MAX_CONTEXT_FILE_BYTES = 8 * 1024 * 1024
21
60
  export const MAX_CONTEXT_TOTAL_BYTES = 24 * 1024 * 1024
@@ -58,6 +97,46 @@ function resolvedSessionName(manifest) {
58
97
  return sessionName(manifest?.name) || sessionName(manifest?.feature)
59
98
  }
60
99
 
100
+ export function resolveSessionColor(colorId) {
101
+ if (typeof colorId !== 'string' || colorId.trim() === '') return null
102
+ return SESSION_COLORS.find((entry) => entry.id === colorId) ?? null
103
+ }
104
+
105
+ export function parseSessionColorQuery(value) {
106
+ if (typeof value !== 'string' || value.trim() === '') return null
107
+ const key = value.trim().toLowerCase()
108
+ if (GLOBAL_COLOR_QUERIES.has(key)) {
109
+ throw new Error(colorUnknownMessage(key === 'global' ? 'Global' : 'Blue'))
110
+ }
111
+ const id = SESSION_COLOR_ALIASES[key] ?? resolveSessionColor(key)?.id ?? null
112
+ const color = resolveSessionColor(id)
113
+ if (!color) throw new Error(colorUnknownMessage(value.trim()))
114
+ return color
115
+ }
116
+
117
+ function assignedSessionColors(dataDir) {
118
+ const used = new Set()
119
+ for (const sessionId of listOpenSessionIds(dataDir)) {
120
+ const color = readManifest(dataDir, sessionId)?.color
121
+ if (resolveSessionColor(color)) used.add(color)
122
+ }
123
+ return used
124
+ }
125
+
126
+ function nextSessionColor(dataDir) {
127
+ const used = assignedSessionColors(dataDir)
128
+ return SESSION_COLORS.find((entry) => !used.has(entry.id))?.id ?? SESSION_COLORS[0].id
129
+ }
130
+
131
+ function ensureManifestColor(dataDir, manifest) {
132
+ if (!manifest) return manifest
133
+ if (resolveSessionColor(manifest.color)) return manifest
134
+ manifest.color = nextSessionColor(dataDir)
135
+ const { manifest: file } = sessionPaths(dataDir, manifest.sessionId)
136
+ atomicWrite(file, `${JSON.stringify(manifest, null, 2)}\n`)
137
+ return manifest
138
+ }
139
+
61
140
  export function sessionPaths(dataDir, sessionId) {
62
141
  const safeId = assertSessionId(sessionId)
63
142
  const root = path.join(dataDir, 'diff-sessions', safeId)
@@ -346,6 +425,45 @@ export function focusSession(dataDir, sessionId) {
346
425
  return safeId
347
426
  }
348
427
 
428
+ function sessionPoolFile(dataDir) {
429
+ return path.join(dataDir, 'session-pool.json')
430
+ }
431
+
432
+ export function enableSessionPool(dataDir, count = SESSION_SLOT_COUNT) {
433
+ atomicWrite(
434
+ sessionPoolFile(dataDir),
435
+ `${JSON.stringify({ count }, null, 2)}\n`,
436
+ )
437
+ }
438
+
439
+ export function sessionPoolSize(dataDir) {
440
+ const value = readJson(sessionPoolFile(dataDir), null)
441
+ const count = value?.count
442
+ return Number.isInteger(count) && count > 0 ? count : 0
443
+ }
444
+
445
+ export function ensureSessionPool(dataDir, options = {}) {
446
+ const size = sessionPoolSize(dataDir)
447
+ if (size <= 0) {
448
+ enableSessionPool(dataDir, options.count ?? SESSION_SLOT_COUNT)
449
+ }
450
+ const count = sessionPoolSize(dataDir)
451
+ const created = []
452
+ while (listOpenSessionIds(dataDir).length < count) {
453
+ created.push(setupSession(dataDir, { focus: false }))
454
+ }
455
+ if (options.focus !== false && !readActiveSession(dataDir)) {
456
+ const next = nextAttachSessionId(dataDir) ?? listOpenSessionIds(dataDir)[0]
457
+ if (next) focusSession(dataDir, next)
458
+ }
459
+ return created
460
+ }
461
+
462
+ function refillSessionPool(dataDir) {
463
+ if (sessionPoolSize(dataDir) <= 0) return []
464
+ return ensureSessionPool(dataDir, { focus: false })
465
+ }
466
+
349
467
  function attachQueueFile(dataDir) {
350
468
  return path.join(dataDir, 'attach-queue.json')
351
469
  }
@@ -417,6 +535,23 @@ function blueprintFile(dataDir) {
417
535
  return path.join(dataDir, 'blueprint.json')
418
536
  }
419
537
 
538
+ function localBlueprintFile(dataDir, sessionId) {
539
+ return sessionPaths(dataDir, sessionId).blueprint
540
+ }
541
+
542
+ export function isGlobalBlueprintColor(colorId) {
543
+ return !colorId || colorId === GLOBAL_BLUEPRINT_COLOR.id
544
+ }
545
+
546
+ export function findSessionIdByColor(dataDir, colorId) {
547
+ if (isGlobalBlueprintColor(colorId)) return null
548
+ for (const sessionId of listOpenSessionIds(dataDir)) {
549
+ const color = resolveSessionColor(readManifest(dataDir, sessionId)?.color)
550
+ if (color?.id === colorId) return sessionId
551
+ }
552
+ return null
553
+ }
554
+
420
555
  function blueprintHasContent(blueprint) {
421
556
  return (
422
557
  (blueprint.userCreatedBlocks?.length ?? 0) > 0 ||
@@ -574,6 +709,7 @@ export function readManifest(dataDir, sessionId) {
574
709
  value.pendingInstruction ??= null
575
710
  value.workStartedAt ??= null
576
711
  }
712
+ if (typeof value.pendingExplain !== 'boolean') value.pendingExplain = false
577
713
  if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
578
714
  value.initialInstruction =
579
715
  typeof value.initialInstruction === 'string' ? value.initialInstruction : null
@@ -748,13 +884,18 @@ export function sessionIntent(
748
884
  const previewVisible = patches.length > 0
749
885
  const blueprint = readBlueprint(dataDir, sessionId)
750
886
  const canEnterBlueprint = manifest.phase === 'blueprint_ask'
887
+ const colored = ensureManifestColor(dataDir, manifest)
888
+ const color = resolveSessionColor(colored.color)
751
889
 
752
890
  return {
753
- updatedAt: manifest.updatedAt,
891
+ updatedAt: colored.updatedAt,
754
892
  showMap: previewVisible,
755
893
  status: activeView ? phaseStatus ?? historicalStatus : historicalStatus,
756
- phase: manifest.phase,
757
- name: resolvedSessionName(manifest) || null,
894
+ phase: colored.phase,
895
+ name: resolvedSessionName(colored) || null,
896
+ color: color?.id ?? null,
897
+ colorName: color?.name ?? null,
898
+ colorHex: color?.hex ?? null,
758
899
  feature: manifest.feature,
759
900
  initialInstruction:
760
901
  typeof manifest.initialInstruction === 'string'
@@ -790,11 +931,13 @@ export function sessionIntent(
790
931
  !isSessionConnected(dataDir, sessionId, waiterIds),
791
932
  listening: waiterIds.has(sessionId),
792
933
  lastAck: readSessionAck(dataDir, sessionId),
934
+ pendingExplain: Boolean(manifest.pendingExplain),
793
935
  creationMode: true,
794
936
  canEnterBlueprint,
795
937
  blueprintHidden: Boolean(blueprint.hidden),
796
938
  blueprintRevision: blueprint.revision,
797
939
  blueprintSessionId: null,
940
+ localBlueprintEnabled: readLocalBlueprint(dataDir, sessionId).enabled,
798
941
  userCreatedBlocks: blueprint.userCreatedBlocks,
799
942
  userCreatedIslands: blueprint.userCreatedIslands,
800
943
  ...preview,
@@ -1072,6 +1215,7 @@ export function startSession(dataDir, input) {
1072
1215
  currentStep: 1,
1073
1216
  activeDiffId: null,
1074
1217
  pendingInstruction: null,
1218
+ pendingExplain: false,
1075
1219
  initialInstruction: null,
1076
1220
  contextFiles: [],
1077
1221
  workStartedAt: null,
@@ -1092,6 +1236,9 @@ export function setupSession(dataDir, input = {}) {
1092
1236
  if (existing && !isTerminalSession(existing)) {
1093
1237
  throw new Error(`Session ${sessionId} already exists`)
1094
1238
  }
1239
+ if (!existing && listOpenSessionIds(dataDir).length >= SESSION_SLOT_COUNT) {
1240
+ throw new Error(CHAT_LIMIT_MESSAGE)
1241
+ }
1095
1242
  if (existing) {
1096
1243
  discardStoredSession(dataDir, sessionId, null, { restore: false })
1097
1244
  }
@@ -1102,6 +1249,7 @@ export function setupSession(dataDir, input = {}) {
1102
1249
  version: 2,
1103
1250
  sessionId,
1104
1251
  name,
1252
+ color: nextSessionColor(dataDir),
1105
1253
  feature: featureName(input.feature) || name,
1106
1254
  steps: [],
1107
1255
  status: 'active',
@@ -1111,6 +1259,7 @@ export function setupSession(dataDir, input = {}) {
1111
1259
  currentStep: 1,
1112
1260
  activeDiffId: null,
1113
1261
  pendingInstruction: null,
1262
+ pendingExplain: false,
1114
1263
  initialInstruction: null,
1115
1264
  contextFiles: [],
1116
1265
  workStartedAt: null,
@@ -1119,7 +1268,7 @@ export function setupSession(dataDir, input = {}) {
1119
1268
  diffs: [],
1120
1269
  }
1121
1270
  writeManifest(dataDir, manifest)
1122
- focusSession(dataDir, sessionId)
1271
+ if (input.focus !== false) focusSession(dataDir, sessionId)
1123
1272
  enqueueAttachSession(dataDir, sessionId)
1124
1273
  return manifest
1125
1274
  }
@@ -1244,28 +1393,47 @@ export function readAttachedSession(dataDir) {
1244
1393
  return null
1245
1394
  }
1246
1395
 
1247
- export function attachSession(dataDir, sessionId) {
1248
- const safeId = sessionId
1249
- ? assertSessionId(sessionId)
1250
- : nextAttachSessionId(dataDir)
1396
+ function resolveAttachSessionId(dataDir, sessionId, options = {}) {
1397
+ if (sessionId) return assertSessionId(sessionId)
1398
+ if (options.color) {
1399
+ const color = parseSessionColorQuery(options.color)
1400
+ if (!color) throw new Error(colorUnknownMessage(options.color))
1401
+ const matchId = findSessionIdByColor(dataDir, color.id)
1402
+ if (!matchId) throw new Error(colorMissingMessage(color.name))
1403
+ const existing = requireManifest(dataDir, matchId)
1404
+ if (!sessionIsWaitingToAttach(existing)) {
1405
+ throw new Error(colorBusyMessage(color.name))
1406
+ }
1407
+ return matchId
1408
+ }
1409
+ return nextAttachSessionId(dataDir)
1410
+ }
1411
+
1412
+ export function attachSession(dataDir, sessionId, options = {}) {
1413
+ const safeId = resolveAttachSessionId(dataDir, sessionId, options)
1251
1414
  if (!safeId) {
1252
- throw new Error(
1253
- 'No visualizer session is waiting to attach. Click Setup LLM session in the map, then /inbase.',
1254
- )
1415
+ throw new Error(CHAT_LIMIT_MESSAGE)
1255
1416
  }
1256
1417
  const manifest = requireManifest(
1257
1418
  dataDir,
1258
1419
  safeId,
1259
- `No visualizer session ${safeId}. Click Setup LLM session in the map, then /inbase.`,
1420
+ `No Inbase session ${safeId} is waiting to connect.`,
1260
1421
  )
1261
1422
  if (isTerminalSession(manifest)) {
1262
1423
  throw sessionStoppedError(safeId)
1263
1424
  }
1264
1425
  focusSession(dataDir, safeId)
1265
1426
  touchSessionConnection(dataDir, safeId)
1266
- recordSessionAck(dataDir, safeId, 'attached', resolvedSessionName(manifest) || safeId)
1427
+ const colored = ensureManifestColor(dataDir, manifest)
1428
+ const colorName = resolveSessionColor(colored.color)?.name
1429
+ recordSessionAck(
1430
+ dataDir,
1431
+ safeId,
1432
+ 'attached',
1433
+ colorName || resolvedSessionName(colored) || safeId,
1434
+ )
1267
1435
  maybeStartVisualizerHandshake(dataDir, safeId)
1268
- return readManifest(dataDir, safeId) ?? manifest
1436
+ return readManifest(dataDir, safeId) ?? colored
1269
1437
  }
1270
1438
 
1271
1439
  export function answerBlueprint(dataDir, sessionId, enabled) {
@@ -1280,37 +1448,28 @@ export function answerBlueprint(dataDir, sessionId, enabled) {
1280
1448
  }
1281
1449
 
1282
1450
  export function updateBlueprint(dataDir, _sessionId, input = {}) {
1283
- const current = readBlueprint(dataDir)
1451
+ const colorId = input.color
1452
+ const fields = blueprintInputFields(input)
1453
+ const current = readBlueprintByColor(dataDir, colorId)
1284
1454
  const next = {
1285
1455
  ...current,
1286
- userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
1287
- userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
1288
- addedFunctions: input.addedFunctions ?? current.addedFunctions,
1289
- addedVariables: input.addedVariables ?? current.addedVariables,
1290
- addedImports: input.addedImports ?? current.addedImports,
1291
- notes: input.notes ?? current.notes,
1292
- pointers: input.pointers ?? current.pointers,
1456
+ userCreatedBlocks: fields.userCreatedBlocks ?? current.userCreatedBlocks,
1457
+ userCreatedIslands: fields.userCreatedIslands ?? current.userCreatedIslands,
1458
+ addedFunctions: fields.addedFunctions ?? current.addedFunctions,
1459
+ addedVariables: fields.addedVariables ?? current.addedVariables,
1460
+ addedImports: fields.addedImports ?? current.addedImports,
1461
+ notes: fields.notes ?? current.notes,
1462
+ pointers: fields.pointers ?? current.pointers,
1293
1463
  }
1294
- return writeBlueprint(dataDir, next)
1464
+ return writeBlueprintByColor(dataDir, colorId, next)
1295
1465
  }
1296
1466
 
1297
- export function sendBlueprint(dataDir, sessionId, input = {}) {
1467
+ export function sendBlueprint(dataDir, sessionId, _input = {}) {
1298
1468
  const safeId = assertSessionId(sessionId)
1299
1469
  const manifest = requireManifest(dataDir, safeId)
1300
1470
  if (manifest.phase !== 'blueprint') {
1301
1471
  throw new Error(`Session ${safeId} is not in blueprint mode`)
1302
1472
  }
1303
- if (
1304
- input.userCreatedBlocks ||
1305
- input.userCreatedIslands ||
1306
- input.addedFunctions ||
1307
- input.addedVariables ||
1308
- input.addedImports ||
1309
- input.notes ||
1310
- input.pointers
1311
- ) {
1312
- updateBlueprint(dataDir, safeId, input)
1313
- }
1314
1473
  manifest.phase = 'preparing'
1315
1474
  manifest.workStartedAt = new Date().toISOString()
1316
1475
  writeManifest(dataDir, manifest)
@@ -1616,6 +1775,49 @@ export function requestReplan(
1616
1775
  return manifest
1617
1776
  }
1618
1777
 
1778
+ export function notifySessionExplain(dataDir, sessionId, detail) {
1779
+ const manifest = readManifest(dataDir, sessionId)
1780
+ if (!manifest) return null
1781
+ writeManifest(dataDir, manifest)
1782
+ recordSessionAck(
1783
+ dataDir,
1784
+ sessionId,
1785
+ 'explain',
1786
+ typeof detail === 'string' && detail.trim() ? detail.trim() : 'a map target',
1787
+ )
1788
+ return manifest
1789
+ }
1790
+
1791
+ export function requestExplainProposal(dataDir, sessionId, diffId) {
1792
+ const manifest = requireManifest(dataDir, sessionId)
1793
+ let title
1794
+ if (manifest.phase === 'review') {
1795
+ const active = pendingActive(manifest, diffId)
1796
+ title =
1797
+ manifest.steps.find((step) => step.index === active.step)?.title ||
1798
+ active.title ||
1799
+ `step ${active.step}`
1800
+ } else if (manifest.phase === 'plan_ready') {
1801
+ const step = manifest.currentStep
1802
+ title =
1803
+ manifest.steps.find((item) => item.index === step)?.title || `step ${step}`
1804
+ } else {
1805
+ throw new Error('No proposal to explain')
1806
+ }
1807
+ manifest.pendingExplain = true
1808
+ writeManifest(dataDir, manifest)
1809
+ recordSessionAck(dataDir, sessionId, 'explain', `the proposal for ${title}`)
1810
+ return manifest
1811
+ }
1812
+
1813
+ export function consumeExplainRequest(dataDir, sessionId) {
1814
+ const manifest = readManifest(dataDir, sessionId)
1815
+ if (!manifest?.pendingExplain) return null
1816
+ manifest.pendingExplain = false
1817
+ writeManifest(dataDir, manifest)
1818
+ return manifest
1819
+ }
1820
+
1619
1821
  function unstageDiffSessionArtifacts(dataDir, targetRoot, extraPaths = []) {
1620
1822
  if (!targetRoot) return
1621
1823
  unstagePaths(targetRoot, [
@@ -1697,6 +1899,31 @@ export function discardInactiveDiffSessions(
1697
1899
  return liveIds
1698
1900
  }
1699
1901
 
1902
+ export function recycleDisconnectedSessions(
1903
+ dataDir,
1904
+ targetRoot = null,
1905
+ waiterIds = waiterSessionIds(),
1906
+ ) {
1907
+ const waiters = new Set()
1908
+ for (const value of waiterIds) {
1909
+ try {
1910
+ waiters.add(assertSessionId(value))
1911
+ } catch {
1912
+ // Ignore process command lines with invalid session ids.
1913
+ }
1914
+ }
1915
+
1916
+ const recycled = []
1917
+ for (const sessionId of listOpenSessionIds(dataDir, waiters)) {
1918
+ const manifest = readManifest(dataDir, sessionId)
1919
+ if (!manifest || manifest.awaitingAttach) continue
1920
+ if (isSessionConnected(dataDir, sessionId, waiters)) continue
1921
+ stopSession(dataDir, sessionId, targetRoot)
1922
+ recycled.push(sessionId)
1923
+ }
1924
+ return recycled
1925
+ }
1926
+
1700
1927
  export function clearDiffSessions(dataDir, targetRoot = null) {
1701
1928
  for (const sessionId of listStoredSessionIds(dataDir)) {
1702
1929
  discardStoredSession(dataDir, sessionId, targetRoot)
@@ -1704,6 +1931,8 @@ export function clearDiffSessions(dataDir, targetRoot = null) {
1704
1931
  writeActiveSession(dataDir, null)
1705
1932
  writeBlueprintSession(dataDir, null)
1706
1933
  writeAttachQueue(dataDir, [])
1934
+ const poolFile = sessionPoolFile(dataDir)
1935
+ if (fs.existsSync(poolFile)) fs.unlinkSync(poolFile)
1707
1936
 
1708
1937
  const root = diffSessionsRoot(dataDir)
1709
1938
  fs.mkdirSync(root, { recursive: true })
@@ -1715,9 +1944,9 @@ export function clearDiffSessions(dataDir, targetRoot = null) {
1715
1944
  }
1716
1945
 
1717
1946
  export function recoverOpenDiffSessions(dataDir, targetRoot = null) {
1718
- // Visualizer startup never reopens an LLM session.
1947
+ // Visualizer startup never restores a previous LLM session.
1719
1948
  clearDiffSessions(dataDir, targetRoot)
1720
- return []
1949
+ return ensureSessionPool(dataDir).map((manifest) => manifest.sessionId)
1721
1950
  }
1722
1951
 
1723
1952
  export function stopSession(dataDir, sessionId, targetRoot = null) {
@@ -1727,6 +1956,7 @@ export function stopSession(dataDir, sessionId, targetRoot = null) {
1727
1956
  const waiters = waiterSessionIds()
1728
1957
  waiters.add(safeId)
1729
1958
  discardInactiveDiffSessions(dataDir, targetRoot, waiters)
1959
+ refillSessionPool(dataDir)
1730
1960
  return null
1731
1961
  }
1732
1962
 
@@ -1755,6 +1985,7 @@ export function closeSession(dataDir, sessionId) {
1755
1985
 
1756
1986
  export function finalizeFinishedSession(dataDir, sessionId) {
1757
1987
  discardStoredSession(dataDir, sessionId, null, { restore: false })
1988
+ refillSessionPool(dataDir)
1758
1989
  }
1759
1990
 
1760
1991
  export function emptyBlueprint() {
@@ -1950,14 +2181,7 @@ function normalizeBlueprint(value) {
1950
2181
  }
1951
2182
  }
1952
2183
 
1953
- export function readBlueprint(dataDir, _sessionId) {
1954
- return normalizeBlueprint(readJson(blueprintFile(dataDir), emptyBlueprint()))
1955
- }
1956
-
1957
- export function writeBlueprint(dataDir, sessionIdOrBlueprint, maybeBlueprint) {
1958
- const current = readBlueprint(dataDir)
1959
- const incoming =
1960
- maybeBlueprint === undefined ? sessionIdOrBlueprint : maybeBlueprint
2184
+ function persistBlueprintFile(file, incoming, current) {
1961
2185
  const next = normalizeBlueprint({
1962
2186
  ...current,
1963
2187
  ...incoming,
@@ -1972,7 +2196,7 @@ export function writeBlueprint(dataDir, sessionIdOrBlueprint, maybeBlueprint) {
1972
2196
  next.enabled = blueprintHasContent(next)
1973
2197
  next.sent = true
1974
2198
  atomicWrite(
1975
- blueprintFile(dataDir),
2199
+ file,
1976
2200
  `${JSON.stringify(
1977
2201
  {
1978
2202
  hidden: Boolean(next.hidden),
@@ -1991,24 +2215,104 @@ export function writeBlueprint(dataDir, sessionIdOrBlueprint, maybeBlueprint) {
1991
2215
  2,
1992
2216
  )}\n`,
1993
2217
  )
1994
- return readBlueprint(dataDir)
2218
+ return normalizeBlueprint(readJson(file, emptyBlueprint()))
2219
+ }
2220
+
2221
+ export function readBlueprint(dataDir, _sessionId) {
2222
+ return normalizeBlueprint(readJson(blueprintFile(dataDir), emptyBlueprint()))
2223
+ }
2224
+
2225
+ export function readLocalBlueprint(dataDir, sessionId) {
2226
+ if (!sessionId) return emptyBlueprint()
2227
+ return normalizeBlueprint(
2228
+ readJson(localBlueprintFile(dataDir, sessionId), emptyBlueprint()),
2229
+ )
1995
2230
  }
1996
2231
 
1997
- export function setBlueprintHidden(dataDir, hidden) {
1998
- return writeBlueprint(dataDir, { ...readBlueprint(dataDir), hidden: Boolean(hidden) })
2232
+ export function readBlueprintByColor(dataDir, colorId) {
2233
+ if (isGlobalBlueprintColor(colorId)) return readBlueprint(dataDir)
2234
+ const sessionId = findSessionIdByColor(dataDir, colorId)
2235
+ return readLocalBlueprint(dataDir, sessionId)
1999
2236
  }
2000
2237
 
2001
- export function clearBlueprint(dataDir) {
2002
- const current = readBlueprint(dataDir)
2003
- return writeBlueprint(dataDir, {
2238
+ export function listLocalBlueprints(dataDir) {
2239
+ const seen = new Set()
2240
+ const locals = []
2241
+ for (const sessionId of listOpenSessionIds(dataDir)) {
2242
+ const color = resolveSessionColor(readManifest(dataDir, sessionId)?.color)
2243
+ if (!color || seen.has(color.id)) continue
2244
+ seen.add(color.id)
2245
+ locals.push({
2246
+ color: color.id,
2247
+ colorName: color.name,
2248
+ colorHex: color.hex,
2249
+ sessionId,
2250
+ ...readLocalBlueprint(dataDir, sessionId),
2251
+ })
2252
+ }
2253
+ return locals.sort(
2254
+ (left, right) =>
2255
+ SESSION_COLORS.findIndex((entry) => entry.id === left.color) -
2256
+ SESSION_COLORS.findIndex((entry) => entry.id === right.color),
2257
+ )
2258
+ }
2259
+
2260
+ export function writeBlueprint(dataDir, sessionIdOrBlueprint, maybeBlueprint) {
2261
+ const incoming =
2262
+ maybeBlueprint === undefined ? sessionIdOrBlueprint : maybeBlueprint
2263
+ return persistBlueprintFile(
2264
+ blueprintFile(dataDir),
2265
+ incoming,
2266
+ readBlueprint(dataDir),
2267
+ )
2268
+ }
2269
+
2270
+ export function writeLocalBlueprint(dataDir, sessionId, incoming) {
2271
+ const safeId = assertSessionId(sessionId)
2272
+ requireManifest(dataDir, safeId)
2273
+ return persistBlueprintFile(
2274
+ localBlueprintFile(dataDir, safeId),
2275
+ incoming,
2276
+ readLocalBlueprint(dataDir, safeId),
2277
+ )
2278
+ }
2279
+
2280
+ export function writeBlueprintByColor(dataDir, colorId, incoming) {
2281
+ if (isGlobalBlueprintColor(colorId)) return writeBlueprint(dataDir, incoming)
2282
+ const sessionId = findSessionIdByColor(dataDir, colorId)
2283
+ if (!sessionId) return emptyBlueprint()
2284
+ return writeLocalBlueprint(dataDir, sessionId, incoming)
2285
+ }
2286
+
2287
+ function blueprintInputFields(input = {}) {
2288
+ const { color: _color, ...fields } = input
2289
+ return fields
2290
+ }
2291
+
2292
+ export function setBlueprintHidden(dataDir, hidden, colorId) {
2293
+ const current = readBlueprintByColor(dataDir, colorId)
2294
+ return writeBlueprintByColor(dataDir, colorId, {
2295
+ ...current,
2296
+ hidden: Boolean(hidden),
2297
+ })
2298
+ }
2299
+
2300
+ export function clearBlueprint(dataDir, colorId) {
2301
+ const current = readBlueprintByColor(dataDir, colorId)
2302
+ return writeBlueprintByColor(dataDir, colorId, {
2004
2303
  ...emptyBlueprint(),
2005
2304
  hidden: current.hidden,
2006
2305
  revision: current.revision,
2007
2306
  })
2008
2307
  }
2009
2308
 
2010
- export function cleanupBlueprint(dataDir, knownFileIds = [], knownFolderPaths = []) {
2011
- const current = readBlueprint(dataDir)
2309
+ export function cleanupBlueprint(
2310
+ dataDir,
2311
+ knownFileIds = [],
2312
+ knownFolderPaths = [],
2313
+ colorId,
2314
+ ) {
2315
+ const current = readBlueprintByColor(dataDir, colorId)
2012
2316
  const files = new Set(knownFileIds)
2013
2317
  const folders = new Set(knownFolderPaths)
2014
2318
  const removedFiles = new Set(
@@ -2026,14 +2330,23 @@ export function cleanupBlueprint(dataDir, knownFileIds = [], knownFolderPaths =
2026
2330
  notes: current.notes.filter((item) => !removedFiles.has(item.file)),
2027
2331
  pointers: current.pointers,
2028
2332
  }
2029
- return writeBlueprint(dataDir, next)
2333
+ return writeBlueprintByColor(dataDir, colorId, next)
2030
2334
  }
2031
2335
 
2032
- export function markBlueprintSeen(dataDir, sessionId, revision) {
2336
+ export function markBlueprintSeen(dataDir, sessionId, revision, localRevision) {
2033
2337
  const manifest = readManifest(dataDir, sessionId)
2034
2338
  if (!manifest) return null
2035
- if (manifest.blueprintRevision === revision) return manifest
2036
- manifest.blueprintRevision = revision
2339
+ const nextGlobal = Number.isInteger(revision) ? revision : manifest.blueprintRevision
2340
+ const nextLocal =
2341
+ Number.isInteger(localRevision) ? localRevision : manifest.localBlueprintRevision
2342
+ if (
2343
+ manifest.blueprintRevision === nextGlobal &&
2344
+ manifest.localBlueprintRevision === nextLocal
2345
+ ) {
2346
+ return manifest
2347
+ }
2348
+ manifest.blueprintRevision = nextGlobal
2349
+ if (Number.isInteger(localRevision)) manifest.localBlueprintRevision = nextLocal
2037
2350
  writeManifest(dataDir, manifest)
2038
2351
  return manifest
2039
2352
  }
@@ -1,8 +1,43 @@
1
1
  export function resolvePathValue(value: string | undefined, fallback: string): string
2
2
  export function resolveTargetRoot(value?: string): string
3
3
  export function resolveDataDir(value?: string): string
4
- export const targetRoot: string
5
4
  export const dataDir: string
6
- export const targetName: string
5
+ export function persistedTargetFile(dir?: string): string
6
+ export function readPersistedTargetId(dir?: string): string | null
7
+ export function writePersistedTargetId(id: string, dir?: string): void
8
+ export function isWorkspaceDevSwitcherEnabled(options?: {
9
+ exampleTarget?: string
10
+ resolvedDataDir?: string
11
+ explorerDataDir?: string
12
+ }): boolean
13
+ export type WorkspaceTarget = {
14
+ id: string
15
+ label: string
16
+ root: string
17
+ }
18
+ export function listWorkspaceTargets(options?: {
19
+ appsRoot?: string
20
+ repositoryRoot?: string
21
+ }): WorkspaceTarget[]
22
+ export function matchWorkspaceTargetId(
23
+ root: string,
24
+ targets?: WorkspaceTarget[],
25
+ ): string | null
26
+ export function resolveInitialTargetRoot(options?: {
27
+ envTarget?: string
28
+ persistedId?: string | null
29
+ switcherEnabled?: boolean
30
+ targets?: WorkspaceTarget[]
31
+ fallback?: string
32
+ }): string
33
+ export let targetRoot: string
34
+ export let targetName: string
35
+ export let targetPathPrefix: string | null
7
36
  export function resolveTargetPathPrefix(root?: string): string | null
8
- export const targetPathPrefix: string | null
37
+ export function currentWorkspaceTargetId(): string | null
38
+ export function workspaceDevTargetsState(): {
39
+ enabled: boolean
40
+ currentId: string | null
41
+ targets: Array<{ id: string; label: string }>
42
+ }
43
+ export function setWorkspaceTarget(id: string): WorkspaceTarget