@jkwd/inbase 0.1.10 → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joris Kuijper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  </picture>
6
6
  </p>
7
7
 
8
- A first-person 3D map of a JavaScript or TypeScript codebase. Files become blocks, folders become walkable areas, and imports become lines in the air.
8
+ A first-person 3D map of a codebase. Files become blocks, folders become walkable areas, and imports become lines in the air.
9
9
 
10
10
  <p align="center">
11
11
  <img src="docs/inbase-1.png" alt="First-person walk view of the codebase map" width="47%" />
@@ -47,16 +47,21 @@ inbase run --target /path/to/your/project
47
47
 
48
48
  <img src="docs/inbase-llm.png" alt="LLM working through a plan in the heads-up display overlay" align="left" width="280" hspace="16" />
49
49
 
50
- Inbase does not call a model itself. The visual coding loop currently supports **Cursor**. The installed skill makes the agent work through the map: it reports a plan, waits on the **HUD** (heads-up display — the overlay panel on the 3D map), and publishes each step as a patch file. Those patch files are the source of truth and are applied on every step update.
50
+ Inbase does not call a model itself. The visual coding loop currently supports **Cursor**. The installed skill makes the agent work through the map: it reports a plan, waits on the **HUD** (heads-up display — the overlay panel on the 3D map), edits live files for each invoked step, and records that step as a patch. Those stored patches are the session record and are applied on every step update.
51
51
 
52
- When a chat starts, that overlay asks whether to set up a **blueprint**. **Create blueprint** lets you place files (`Space`) and folders (`B`), then **Send blueprint** that layout is the source of truth for the chat. **Let LLM continue** skips the initial placement. You can still place files and islands on later steps; they are stored on that chat’s blueprint. When the session finishes, placement stops.
52
+ Sessions start in the map: click **Setup LLM session**, type an **initial instruction**, optionally place a **blueprint** (`Space` for files, `B` for folders), then open a Cursor chat and run **`/inbase`**. That command connects the chat and starts the work. The layout is the source of truth for the chat. You can still place files and islands on later steps; they are stored on that chat’s blueprint. When the session finishes, placement stops and the session is discarded. Restarting the visualizer also starts with no LLM session; leftover session files are not restored.
53
53
 
54
- If several chats are open, only one session window is shown at a time. Switch with the tabs so the focused chat is the one whose blueprint you see and edit.
54
+ A normal chat request does not open a session. Use `/inbase` after Setup LLM session, or `/skipinbase [request]` to work outside the map. `/inbase` starts the session immediately; `wait-for-blueprint` only reads the optional blueprint.
55
55
 
56
- Turn on **Make LLM look where I look** if the agent should prefer the island you are standing on and the blocks you are facing. With **Step by step** on, click **Run step** to start a step, then **Accept proposal** when the patch is ready. With it off, the LLM implements the full plan; you can still walk Previous/Next over the diffs, then **Accept proposal**. Send an alternative instruction from the HUD to revise the remaining plan, or **Stop** to end the session.
56
+ If several sessions are open, only one session window is shown at a time. Switch tabs so the focused session is the one `/inbase` connects to.
57
57
 
58
- The LLM never writes project files itself. It only publishes patch files. Those
59
- stored patches are the source of truth and are applied on every step update.
58
+ Turn on **Make LLM look where I look** if the agent should prefer the island you are standing on and the blocks you are facing. With **Step by step** on, click **Create proposal** to start a step, then **Accept proposal** when the patch is ready. With it off, the LLM implements the full plan; you can still walk Previous/Next over the diffs, then **Accept proposal**. Send an alternative instruction from the HUD to revise the remaining plan, or **Stop** to end the session.
59
+
60
+ When no LLM is currently making changes, turn on **Show branch changes** (or press **G**) to highlight the current git branch against its base — committed, unstaged, and untracked files — instead of LLM patch files. The control is disabled while an LLM session is writing or reviewing a patch.
61
+
62
+ The LLM edits project files after a step is invoked. Inbase records those edits
63
+ as a patch against the snapshot taken at invoke. Those stored patches are the
64
+ session record and are applied on every step update.
60
65
 
61
66
  <br clear="all" />
62
67
 
@@ -68,7 +73,7 @@ stored patches are the source of truth and are applied on every step update.
68
73
  | `inbase run` | Scan this repo and start the local map |
69
74
  | `inbase run --port 5174` | Start on another port |
70
75
 
71
- Session commands (`start-session`, `wait-for-blueprint`, `report-plan`, `wait-for-approval`, `propose-patch`) are used by the Cursor skill. You do not need to run them yourself.
76
+ Session commands (`attach`, `wait-for-blueprint`, `report-plan`, `wait-for-approval`, `propose-patch`) are used by the Cursor skill. You do not need to run them yourself.
72
77
 
73
78
  ## Editor support
74
79
 
@@ -76,7 +81,7 @@ The map runs in the browser. The LLM plan and patch loop currently works in **Cu
76
81
 
77
82
  ## Language support
78
83
 
79
- Source files (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`) become blocks with **functions**, **classes**, and **variables**, plus import edges from `import` and `require()`. Styles and docs (`.css`, `.scss`, `.html`, `.json`, `.md`) show up as blocks without symbols. Only relative specifiers (`./`, `../`) become edges — package names like `react` or `@angular/core` do not.
84
+ Every text file in the project becomes a block. JavaScript and TypeScript (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`) also get **functions**, **classes**, and **variables**. Relative `import` / `require()` specifiers become edges when they resolve including `.astro` and other JS-style modules. Package names like `react` or `@angular/core` do not. Binary files are skipped.
80
85
 
81
86
  ## How to read the map
82
87
 
@@ -106,3 +111,11 @@ To run the example React app itself:
106
111
  ```bash
107
112
  npm run dev:target
108
113
  ```
114
+
115
+ ## Contributing
116
+
117
+ Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
118
+
119
+ ## License
120
+
121
+ Inbase is open source under the [MIT License](LICENSE).
@@ -2,6 +2,7 @@
2
2
  "name": "@visual-coder/explorer",
3
3
  "private": true,
4
4
  "version": "0.0.1",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "scripts": {
7
8
  "scan": "node scripts/scan-target.mjs",
@@ -0,0 +1,24 @@
1
+ import type { PatchImport, PatchImportAddition, PatchSymbolAddition } from './patch-lib.d.ts'
2
+
3
+ export type BranchChanges = {
4
+ available: boolean
5
+ branch: string | null
6
+ base: string | null
7
+ files: string[]
8
+ creates: string[]
9
+ deletes: string[]
10
+ createFolders: string[]
11
+ createLines: Record<string, number>
12
+ imports: PatchImport[]
13
+ addedFunctions: PatchSymbolAddition[]
14
+ addedVariables: PatchSymbolAddition[]
15
+ addedImports: PatchImportAddition[]
16
+ changedFunctions: PatchSymbolAddition[]
17
+ changedVariables: PatchSymbolAddition[]
18
+ }
19
+
20
+ export function emptyBranchChanges(): BranchChanges
21
+ export function readBranchChanges(
22
+ targetRoot: string,
23
+ knownFileIds?: string[],
24
+ ): BranchChanges
@@ -0,0 +1,237 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import {
5
+ collectCreateFolders,
6
+ extractPatchAdditions,
7
+ extractPatchImports,
8
+ foldersFromFileIds,
9
+ parseUnifiedPatch,
10
+ } from './patch-lib.mjs'
11
+
12
+ const SKIP_DIRS = new Set([
13
+ 'node_modules',
14
+ 'dist',
15
+ 'build',
16
+ 'out',
17
+ 'coverage',
18
+ '.git',
19
+ '.inbase',
20
+ ])
21
+ const SKIP_FILES = new Set(['package-lock.json'])
22
+ const BINARY_PROBE_BYTES = 8000
23
+
24
+ function toPosix(filePath) {
25
+ return filePath.split(path.sep).join('/')
26
+ }
27
+
28
+ function unique(ids) {
29
+ return [...new Set(ids)]
30
+ }
31
+
32
+ function runGit(cwd, args) {
33
+ return spawnSync('git', args, {
34
+ cwd,
35
+ encoding: 'utf8',
36
+ maxBuffer: 32 * 1024 * 1024,
37
+ })
38
+ }
39
+
40
+ function gitTopLevel(fromDir) {
41
+ try {
42
+ const result = runGit(fromDir, ['rev-parse', '--show-toplevel'])
43
+ if (result.status !== 0) return null
44
+ const root = result.stdout.trim()
45
+ return root ? path.resolve(root) : null
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ function isBinaryFile(filePath) {
52
+ try {
53
+ const fd = fs.openSync(filePath, 'r')
54
+ try {
55
+ const buf = Buffer.alloc(BINARY_PROBE_BYTES)
56
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0)
57
+ return buf.subarray(0, bytes).includes(0)
58
+ } finally {
59
+ fs.closeSync(fd)
60
+ }
61
+ } catch {
62
+ return true
63
+ }
64
+ }
65
+
66
+ function shouldSkipPath(fileId) {
67
+ const parts = fileId.split('/').filter(Boolean)
68
+ if (parts.some((part) => part.startsWith('.') || SKIP_DIRS.has(part))) {
69
+ return true
70
+ }
71
+ return SKIP_FILES.has(parts.at(-1) ?? '')
72
+ }
73
+
74
+ function currentBranch(cwd) {
75
+ const result = runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
76
+ if (result.status !== 0) return null
77
+ const name = result.stdout.trim()
78
+ return name || null
79
+ }
80
+
81
+ function resolveBaseRef(gitRoot) {
82
+ const originHead = runGit(gitRoot, [
83
+ 'symbolic-ref',
84
+ '--quiet',
85
+ 'refs/remotes/origin/HEAD',
86
+ ])
87
+ if (originHead.status === 0) {
88
+ const ref = originHead.stdout.trim().replace(/^refs\/remotes\//, '')
89
+ if (ref) return ref
90
+ }
91
+ for (const name of ['origin/main', 'origin/master', 'main', 'master']) {
92
+ const check = runGit(gitRoot, ['rev-parse', '--verify', '--quiet', name])
93
+ if (check.status === 0 && check.stdout.trim()) return name
94
+ }
95
+ return 'HEAD'
96
+ }
97
+
98
+ function resolveMergeBase(gitRoot, baseRef) {
99
+ const result = runGit(gitRoot, ['merge-base', 'HEAD', baseRef])
100
+ if (result.status === 0 && result.stdout.trim()) return result.stdout.trim()
101
+ return 'HEAD'
102
+ }
103
+
104
+ function parseNameStatus(text) {
105
+ const files = []
106
+ const creates = []
107
+ const deletes = []
108
+ for (const line of text.split('\n')) {
109
+ if (!line.trim()) continue
110
+ const tab = line.indexOf('\t')
111
+ if (tab < 0) continue
112
+ const status = line.slice(0, tab).trim()
113
+ const filePath = toPosix(line.slice(tab + 1).trim())
114
+ if (!filePath || filePath.includes('\t') || shouldSkipPath(filePath)) continue
115
+ const code = status[0]
116
+ if (code === 'A') creates.push(filePath)
117
+ else if (code === 'D') deletes.push(filePath)
118
+ else if (code === 'M' || code === 'T') files.push(filePath)
119
+ }
120
+ return { files, creates, deletes }
121
+ }
122
+
123
+ function fileAsAddPatch(fileId, contents) {
124
+ const lines = contents.split('\n')
125
+ if (contents.endsWith('\n')) lines.pop()
126
+ const count = lines.length
127
+ const hunk =
128
+ count === 0
129
+ ? []
130
+ : [`@@ -0,0 +1,${count} @@`, ...lines.map((line) => `+${line}`)]
131
+ return [
132
+ `diff --git a/${fileId} b/${fileId}`,
133
+ 'new file mode 100644',
134
+ '--- /dev/null',
135
+ `+++ b/${fileId}`,
136
+ ...hunk,
137
+ '',
138
+ ].join('\n')
139
+ }
140
+
141
+ function collectUntracked(targetRoot) {
142
+ const result = runGit(targetRoot, ['ls-files', '--others', '--exclude-standard'])
143
+ const creates = []
144
+ const patches = []
145
+ const createLines = {}
146
+ if (result.status !== 0) return { creates, patches, createLines }
147
+
148
+ for (const raw of result.stdout.split('\n')) {
149
+ const fileId = toPosix(raw.trim())
150
+ if (!fileId || shouldSkipPath(fileId)) continue
151
+ const absolute = path.join(targetRoot, fileId)
152
+ try {
153
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue
154
+ } catch {
155
+ continue
156
+ }
157
+ if (isBinaryFile(absolute)) continue
158
+ const contents = fs.readFileSync(absolute, 'utf8')
159
+ creates.push(fileId)
160
+ patches.push(fileAsAddPatch(fileId, contents))
161
+ const lineCount =
162
+ contents === ''
163
+ ? 1
164
+ : contents.split('\n').length - (contents.endsWith('\n') ? 1 : 0)
165
+ createLines[fileId] = Math.max(1, lineCount)
166
+ }
167
+ return { creates, patches, createLines }
168
+ }
169
+
170
+ export function emptyBranchChanges() {
171
+ return {
172
+ available: false,
173
+ branch: null,
174
+ base: null,
175
+ files: [],
176
+ creates: [],
177
+ deletes: [],
178
+ createFolders: [],
179
+ createLines: {},
180
+ imports: [],
181
+ addedFunctions: [],
182
+ addedVariables: [],
183
+ addedImports: [],
184
+ changedFunctions: [],
185
+ changedVariables: [],
186
+ }
187
+ }
188
+
189
+ export function readBranchChanges(targetRoot, knownFileIds = []) {
190
+ const empty = emptyBranchChanges()
191
+ if (!targetRoot || !fs.existsSync(targetRoot)) return empty
192
+ const gitRoot = gitTopLevel(targetRoot)
193
+ if (!gitRoot) return empty
194
+
195
+ const branch = currentBranch(targetRoot) ?? currentBranch(gitRoot)
196
+ const base = resolveBaseRef(gitRoot)
197
+ const mergeBase = resolveMergeBase(gitRoot, base)
198
+ const diffArgs = [
199
+ '--no-color',
200
+ '--no-ext-diff',
201
+ '--no-renames',
202
+ '--relative',
203
+ mergeBase,
204
+ ]
205
+ const statusResult = runGit(targetRoot, ['diff', '--name-status', ...diffArgs])
206
+ const patchResult = runGit(targetRoot, ['diff', ...diffArgs])
207
+ const named = parseNameStatus(
208
+ statusResult.status === 0 ? statusResult.stdout : '',
209
+ )
210
+ const untracked = collectUntracked(targetRoot)
211
+ const creates = unique([...named.creates, ...untracked.creates])
212
+ const deletes = named.deletes.filter((id) => !creates.includes(id))
213
+ const files = named.files.filter(
214
+ (id) => !creates.includes(id) && !deletes.includes(id),
215
+ )
216
+ const patchText = [patchResult.status === 0 ? patchResult.stdout : '', ...untracked.patches]
217
+ .filter((part) => part.trim())
218
+ .join('\n')
219
+ const parsed = parseUnifiedPatch(patchText)
220
+ const known = unique([...knownFileIds, ...creates, ...files])
221
+
222
+ return {
223
+ available: true,
224
+ branch,
225
+ base,
226
+ files,
227
+ creates,
228
+ deletes,
229
+ createFolders: collectCreateFolders(
230
+ creates,
231
+ foldersFromFileIds(knownFileIds.filter((id) => !creates.includes(id))),
232
+ ),
233
+ createLines: { ...parsed.createLines, ...untracked.createLines },
234
+ imports: extractPatchImports(parsed.entries, known),
235
+ ...extractPatchAdditions(parsed.entries),
236
+ }
237
+ }
@@ -1,17 +1,3 @@
1
- export const FILE_EXTENSIONS = new Set([
2
- '.ts',
3
- '.tsx',
4
- '.js',
5
- '.jsx',
6
- '.mjs',
7
- '.cjs',
8
- '.css',
9
- '.scss',
10
- '.json',
11
- '.html',
12
- '.md',
13
- ])
14
-
15
1
  export const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
16
2
 
17
3
  export const RESOLVE_EXTENSIONS = [
@@ -147,16 +133,27 @@ export function extractImportBindings(source) {
147
133
  return bindings
148
134
  }
149
135
 
136
+ function firstKnownWithPrefix(prefix, known) {
137
+ for (const id of known) {
138
+ if (!id.startsWith(prefix)) continue
139
+ if (id.slice(prefix.length).includes('/')) continue
140
+ return id
141
+ }
142
+ return null
143
+ }
144
+
150
145
  export function resolveSpecifierAgainst(candidate, known) {
151
146
  if (known.has(candidate)) return candidate
152
147
  for (const ext of RESOLVE_EXTENSIONS) {
153
148
  if (known.has(candidate + ext)) return candidate + ext
154
149
  }
150
+ const withExt = firstKnownWithPrefix(`${candidate}.`, known)
151
+ if (withExt) return withExt
155
152
  for (const ext of RESOLVE_EXTENSIONS) {
156
153
  const indexFile = `${candidate}/index${ext}`
157
154
  if (known.has(indexFile)) return indexFile
158
155
  }
159
- return null
156
+ return firstKnownWithPrefix(`${candidate}/index.`, known)
160
157
  }
161
158
 
162
159
  function destructuredNames(pattern, { importClause = false } = {}) {
@@ -80,6 +80,7 @@ export const emptyIntent: {
80
80
  updatedAt: null
81
81
  showMap: boolean
82
82
  status: string
83
+ name: null
83
84
  feature: null
84
85
  steps: unknown[]
85
86
  step: null
@@ -107,6 +108,10 @@ export const emptyIntent: {
107
108
  working: boolean
108
109
  stalledWait: boolean
109
110
  llmIdle: boolean
111
+ awaitingAttach: boolean
112
+ listening: boolean
113
+ lastAck: { kind: string; detail: string; at: string | null } | null
114
+ initialInstruction: string | null
110
115
  creationMode: boolean
111
116
  canEnterBlueprint: boolean
112
117
  blueprintSessionId: string | null
@@ -507,6 +507,7 @@ export const emptyIntent = {
507
507
  updatedAt: null,
508
508
  showMap: false,
509
509
  status: 'idle',
510
+ name: null,
510
511
  feature: null,
511
512
  steps: [],
512
513
  step: null,
@@ -534,6 +535,10 @@ export const emptyIntent = {
534
535
  working: false,
535
536
  stalledWait: false,
536
537
  llmIdle: false,
538
+ awaitingAttach: false,
539
+ listening: false,
540
+ lastAck: null,
541
+ initialInstruction: null,
537
542
  creationMode: false,
538
543
  canEnterBlueprint: false,
539
544
  blueprintSessionId: null,
@@ -2,7 +2,6 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import {
5
- FILE_EXTENSIONS,
6
5
  SOURCE_EXTENSIONS,
7
6
  RESOLVE_EXTENSIONS,
8
7
  collectImportSpecifiers,
@@ -25,11 +24,23 @@ const IGNORE_DIRS = new Set([
25
24
  '.inbase',
26
25
  ])
27
26
  const IGNORE_FILES = new Set(['package-lock.json'])
27
+ const BINARY_PROBE_BYTES = 8000
28
28
 
29
29
  function toPosix(filePath) {
30
30
  return filePath.split(path.sep).join('/')
31
31
  }
32
32
 
33
+ function isBinaryFile(filePath) {
34
+ const fd = fs.openSync(filePath, 'r')
35
+ try {
36
+ const buf = Buffer.alloc(BINARY_PROBE_BYTES)
37
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0)
38
+ return buf.subarray(0, bytes).includes(0)
39
+ } finally {
40
+ fs.closeSync(fd)
41
+ }
42
+ }
43
+
33
44
  function walk(dir, acc = []) {
34
45
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
35
46
  if (entry.name.startsWith('.')) continue
@@ -39,8 +50,9 @@ function walk(dir, acc = []) {
39
50
  continue
40
51
  }
41
52
  if (IGNORE_FILES.has(entry.name)) continue
42
- if (!FILE_EXTENSIONS.has(path.extname(entry.name))) continue
43
- acc.push(path.join(dir, entry.name))
53
+ const absolutePath = path.join(dir, entry.name)
54
+ if (isBinaryFile(absolutePath)) continue
55
+ acc.push(absolutePath)
44
56
  }
45
57
  return acc
46
58
  }
@@ -55,24 +67,40 @@ function extractSymbols(source, ext) {
55
67
  return extractJsSymbols(source)
56
68
  }
57
69
 
58
- function resolveExisting(candidate) {
59
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
60
- return candidate
70
+ function isUsableFile(filePath) {
71
+ return fs.existsSync(filePath) && fs.statSync(filePath).isFile() && !isBinaryFile(filePath)
72
+ }
73
+
74
+ function fileWithStem(dir, stem) {
75
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null
76
+ for (const name of fs.readdirSync(dir)) {
77
+ if (name.startsWith('.')) continue
78
+ const parsed = path.parse(name)
79
+ if (parsed.name !== stem || !parsed.ext) continue
80
+ const full = path.join(dir, name)
81
+ if (isUsableFile(full)) return full
61
82
  }
83
+ return null
84
+ }
85
+
86
+ function resolveExisting(candidate) {
87
+ if (isUsableFile(candidate)) return candidate
62
88
 
63
89
  for (const ext of RESOLVE_EXTENSIONS) {
64
90
  const withExt = candidate + ext
65
- if (fs.existsSync(withExt) && fs.statSync(withExt).isFile()) {
66
- return withExt
67
- }
91
+ if (isUsableFile(withExt)) return withExt
68
92
  }
69
93
 
70
- const indexDir = candidate
71
- if (fs.existsSync(indexDir) && fs.statSync(indexDir).isDirectory()) {
94
+ const sibling = fileWithStem(path.dirname(candidate), path.basename(candidate))
95
+ if (sibling) return sibling
96
+
97
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
72
98
  for (const ext of RESOLVE_EXTENSIONS) {
73
- const indexFile = path.join(indexDir, `index${ext}`)
74
- if (fs.existsSync(indexFile)) return indexFile
99
+ const indexFile = path.join(candidate, `index${ext}`)
100
+ if (isUsableFile(indexFile)) return indexFile
75
101
  }
102
+ const indexFile = fileWithStem(candidate, 'index')
103
+ if (indexFile) return indexFile
76
104
  }
77
105
 
78
106
  return null
@@ -110,6 +138,11 @@ function ensureFolder(folders, folderPath, rootName) {
110
138
  if (parent) ensureFolder(folders, parent, rootName)
111
139
  }
112
140
 
141
+ export function listSourceFiles(root) {
142
+ if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) return []
143
+ return walk(root).map((absolutePath) => toPosix(path.relative(root, absolutePath)))
144
+ }
145
+
113
146
  export function scanTarget({
114
147
  root = defaultTargetRoot,
115
148
  name = path.basename(root),
@@ -20,6 +20,7 @@ export type DiffEntry = {
20
20
  export type DiffManifest = {
21
21
  version: number
22
22
  sessionId: string
23
+ name: string
23
24
  feature: string
24
25
  steps: Array<{ index: number; title: string }>
25
26
  status: 'active' | 'finished' | 'rejected'
@@ -33,9 +34,11 @@ export type DiffManifest = {
33
34
  | 'replanning'
34
35
  | 'finished'
35
36
  | 'stopped'
37
+ awaitingAttach?: boolean
36
38
  currentStep: number
37
39
  activeDiffId: string | null
38
40
  pendingInstruction: string | null
41
+ initialInstruction?: string | null
39
42
  workStartedAt: string | null
40
43
  stepByStep: boolean
41
44
  createdAt: string
@@ -47,7 +50,26 @@ export function assertSessionId(value: unknown): string
47
50
  export function readActiveSession(dataDir: string): string | null
48
51
  export function writeActiveSession(dataDir: string, sessionId: string | null): void
49
52
  export function focusSession(dataDir: string, sessionId: string): string
53
+ export function sessionPaths(
54
+ dataDir: string,
55
+ sessionId: string,
56
+ ): {
57
+ root: string
58
+ diffs: string
59
+ manifest: string
60
+ blueprint: string
61
+ baseline: string
62
+ baselineFiles: string
63
+ preStep: string
64
+ stopped: string
65
+ }
50
66
  export function touchSessionConnection(dataDir: string, sessionId: string): void
67
+ export function recordSessionAck(
68
+ dataDir: string,
69
+ sessionId: string,
70
+ kind: string,
71
+ detail?: string,
72
+ ): { kind: string; detail: string; at: string } | null
51
73
  export function isSessionConnected(
52
74
  dataDir: string,
53
75
  sessionId: string,
@@ -86,9 +108,28 @@ export function startSession(
86
108
  dataDir: string,
87
109
  input: {
88
110
  sessionId: string
111
+ name?: string
112
+ feature?: string
113
+ },
114
+ ): DiffManifest
115
+ export function setupSession(
116
+ dataDir: string,
117
+ input?: {
118
+ sessionId?: string
119
+ name?: string
89
120
  feature?: string
90
121
  },
91
122
  ): DiffManifest
123
+ export function attachSession(
124
+ dataDir: string,
125
+ sessionId?: string | null,
126
+ ): DiffManifest
127
+ export function setInitialInstruction(
128
+ dataDir: string,
129
+ sessionId: string,
130
+ instruction: string | null | undefined,
131
+ ): DiffManifest
132
+ export function readAttachedSession(dataDir: string): string | null
92
133
  export type SessionBlueprint = {
93
134
  enabled: boolean
94
135
  sent: boolean
@@ -132,12 +173,18 @@ export function sendBlueprint(
132
173
  addedImports?: unknown[]
133
174
  },
134
175
  ): DiffManifest
176
+ export function maybeStartVisualizerHandshake(
177
+ dataDir: string,
178
+ sessionId: string,
179
+ ): DiffManifest
135
180
  export function reportPlan(
136
181
  dataDir: string,
137
182
  input: {
138
183
  sessionId: string
184
+ name?: string
139
185
  feature: string
140
186
  stepTitles: string[]
187
+ targetRoot?: string | null
141
188
  },
142
189
  ): DiffManifest
143
190
  export function isStepByStep(manifest: DiffManifest | null | undefined): boolean
@@ -186,6 +233,16 @@ export function materializeDiff(
186
233
  sessionId: string,
187
234
  diffId?: string | null,
188
235
  ): DiffManifest
236
+ export function snapshotPreStep(
237
+ dataDir: string,
238
+ sessionId: string,
239
+ targetRoot: string,
240
+ ): string
241
+ export function readLiveDiff(
242
+ dataDir: string,
243
+ sessionId: string,
244
+ targetRoot: string,
245
+ ): string
189
246
  export function inspectTargetFile(
190
247
  dataDir: string,
191
248
  targetRoot: string,
@@ -200,7 +257,7 @@ export function appendDiff(
200
257
  targetRoot: string,
201
258
  input: {
202
259
  sessionId: string
203
- patchText: string
260
+ patchText?: string
204
261
  },
205
262
  ): { manifest: DiffManifest; entry: DiffEntry }
206
263
  export function continueDiff(