@lattices/cli 0.3.0

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 (74) hide show
  1. package/README.md +155 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/AccessibilityTextExtractor.swift +111 -0
  5. package/app/Sources/ActionRow.swift +61 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +242 -0
  8. package/app/Sources/AppShellView.swift +62 -0
  9. package/app/Sources/AppTypeClassifier.swift +70 -0
  10. package/app/Sources/AppWindowShell.swift +63 -0
  11. package/app/Sources/CheatSheetHUD.swift +332 -0
  12. package/app/Sources/CommandModeState.swift +1362 -0
  13. package/app/Sources/CommandModeView.swift +1405 -0
  14. package/app/Sources/CommandModeWindow.swift +192 -0
  15. package/app/Sources/CommandPaletteView.swift +307 -0
  16. package/app/Sources/CommandPaletteWindow.swift +134 -0
  17. package/app/Sources/DaemonProtocol.swift +101 -0
  18. package/app/Sources/DaemonServer.swift +414 -0
  19. package/app/Sources/DesktopModel.swift +149 -0
  20. package/app/Sources/DesktopModelTypes.swift +71 -0
  21. package/app/Sources/DiagnosticLog.swift +271 -0
  22. package/app/Sources/EventBus.swift +30 -0
  23. package/app/Sources/HotkeyManager.swift +254 -0
  24. package/app/Sources/HotkeyStore.swift +338 -0
  25. package/app/Sources/InventoryManager.swift +35 -0
  26. package/app/Sources/InventoryPath.swift +43 -0
  27. package/app/Sources/KeyRecorderView.swift +210 -0
  28. package/app/Sources/LatticesApi.swift +1234 -0
  29. package/app/Sources/LayerBezel.swift +203 -0
  30. package/app/Sources/MainView.swift +479 -0
  31. package/app/Sources/MainWindow.swift +83 -0
  32. package/app/Sources/OcrModel.swift +430 -0
  33. package/app/Sources/OcrStore.swift +329 -0
  34. package/app/Sources/OmniSearchState.swift +283 -0
  35. package/app/Sources/OmniSearchView.swift +288 -0
  36. package/app/Sources/OmniSearchWindow.swift +105 -0
  37. package/app/Sources/OrphanRow.swift +129 -0
  38. package/app/Sources/PaletteCommand.swift +419 -0
  39. package/app/Sources/PermissionChecker.swift +125 -0
  40. package/app/Sources/Preferences.swift +99 -0
  41. package/app/Sources/ProcessModel.swift +199 -0
  42. package/app/Sources/ProcessQuery.swift +151 -0
  43. package/app/Sources/Project.swift +28 -0
  44. package/app/Sources/ProjectRow.swift +368 -0
  45. package/app/Sources/ProjectScanner.swift +128 -0
  46. package/app/Sources/ScreenMapState.swift +2387 -0
  47. package/app/Sources/ScreenMapView.swift +2820 -0
  48. package/app/Sources/ScreenMapWindowController.swift +89 -0
  49. package/app/Sources/SessionManager.swift +72 -0
  50. package/app/Sources/SettingsView.swift +1064 -0
  51. package/app/Sources/SettingsWindow.swift +20 -0
  52. package/app/Sources/TabGroupRow.swift +178 -0
  53. package/app/Sources/Terminal.swift +259 -0
  54. package/app/Sources/TerminalQuery.swift +156 -0
  55. package/app/Sources/TerminalSynthesizer.swift +200 -0
  56. package/app/Sources/Theme.swift +163 -0
  57. package/app/Sources/TilePickerView.swift +209 -0
  58. package/app/Sources/TmuxModel.swift +53 -0
  59. package/app/Sources/TmuxQuery.swift +81 -0
  60. package/app/Sources/WindowTiler.swift +1778 -0
  61. package/app/Sources/WorkspaceManager.swift +575 -0
  62. package/bin/client.js +4 -0
  63. package/bin/daemon-client.js +187 -0
  64. package/bin/lattices-app.js +221 -0
  65. package/bin/lattices.js +1551 -0
  66. package/docs/api.md +924 -0
  67. package/docs/app.md +297 -0
  68. package/docs/concepts.md +135 -0
  69. package/docs/config.md +245 -0
  70. package/docs/layers.md +410 -0
  71. package/docs/ocr.md +185 -0
  72. package/docs/overview.md +94 -0
  73. package/docs/quickstart.md +75 -0
  74. package/package.json +42 -0
@@ -0,0 +1,1551 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { execSync } from "node:child_process";
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { basename, resolve, dirname } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ // Daemon client (lazy-loaded to avoid blocking startup for TTY commands)
11
+ let _daemonClient;
12
+ async function getDaemonClient() {
13
+ if (!_daemonClient) {
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ _daemonClient = await import(resolve(__dirname, "daemon-client.js"));
16
+ }
17
+ return _daemonClient;
18
+ }
19
+
20
+ const args = process.argv.slice(2);
21
+ const command = args[0];
22
+
23
+ // ── Helpers ──────────────────────────────────────────────────────────
24
+
25
+ function run(cmd, opts = {}) {
26
+ return execSync(cmd, { encoding: "utf8", ...opts }).trim();
27
+ }
28
+
29
+ function runQuiet(cmd) {
30
+ try {
31
+ return run(cmd, { stdio: "pipe" });
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function hasTmux() {
38
+ return runQuiet("which tmux") !== null;
39
+ }
40
+
41
+ function isInsideTmux() {
42
+ return !!process.env.TMUX;
43
+ }
44
+
45
+ function sessionExists(name) {
46
+ return runQuiet(`tmux has-session -t "${name}" 2>&1`) !== null;
47
+ }
48
+
49
+ function pathHash(dir) {
50
+ return createHash("sha256").update(resolve(dir)).digest("hex").slice(0, 6);
51
+ }
52
+
53
+ function toSessionName(dir) {
54
+ const base = basename(dir).replace(/[^a-zA-Z0-9_-]/g, "-");
55
+ return `${base}-${pathHash(dir)}`;
56
+ }
57
+
58
+ function esc(str) {
59
+ return str.replace(/'/g, "'\\''");
60
+ }
61
+
62
+ // ── Config ───────────────────────────────────────────────────────────
63
+
64
+ function readConfig(dir) {
65
+ const configPath = resolve(dir, ".lattices.json");
66
+ if (!existsSync(configPath)) return null;
67
+ try {
68
+ const raw = readFileSync(configPath, "utf8");
69
+ return JSON.parse(raw);
70
+ } catch (e) {
71
+ console.warn(`Warning: invalid .lattices.json — ${e.message}`);
72
+ return null;
73
+ }
74
+ }
75
+
76
+ // ── Workspace config (tab groups) ───────────────────────────────────
77
+
78
+ function readWorkspaceConfig() {
79
+ const configPath = resolve(homedir(), ".lattices", "workspace.json");
80
+ if (!existsSync(configPath)) return null;
81
+ try {
82
+ const raw = readFileSync(configPath, "utf8");
83
+ return JSON.parse(raw);
84
+ } catch (e) {
85
+ console.warn(`Warning: invalid workspace.json — ${e.message}`);
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function toGroupSessionName(groupId) {
91
+ return `lattices-group-${groupId}`;
92
+ }
93
+
94
+ /** Get ordered pane IDs for a specific window within a session */
95
+ function getPaneIdsForWindow(sessionName, windowIndex) {
96
+ const out = runQuiet(
97
+ `tmux list-panes -t "${sessionName}:${windowIndex}" -F "#{pane_id}"`
98
+ );
99
+ return out ? out.split("\n").filter(Boolean) : [];
100
+ }
101
+
102
+ /** Create a tmux window with pane layout for a project dir */
103
+ function createWindowForProject(sessionName, windowIndex, dir, label) {
104
+ const config = readConfig(dir);
105
+ const d = esc(dir);
106
+
107
+ let panes;
108
+ if (config?.panes?.length) {
109
+ panes = resolvePane(config.panes, dir);
110
+ } else {
111
+ panes = defaultPanes(dir);
112
+ }
113
+
114
+ if (windowIndex === 0) {
115
+ // First window already exists from new-session, just set working dir
116
+ run(`tmux send-keys -t "${sessionName}:0" 'cd ${d}' Enter`);
117
+ } else {
118
+ run(`tmux new-window -t "${sessionName}" -c '${d}'`);
119
+ }
120
+
121
+ const winTarget = `${sessionName}:${windowIndex}`;
122
+
123
+ // Rename the window
124
+ const winLabel = label || basename(dir);
125
+ runQuiet(`tmux rename-window -t "${winTarget}" "${winLabel}"`);
126
+
127
+ // Create pane splits
128
+ if (panes.length === 2) {
129
+ const mainSize = panes[0].size || 60;
130
+ run(`tmux split-window -h -t "${winTarget}" -c '${d}' -p ${100 - mainSize}`);
131
+ } else if (panes.length >= 3) {
132
+ const mainSize = panes[0].size || 60;
133
+ for (let i = 1; i < panes.length; i++) {
134
+ run(`tmux split-window -t "${winTarget}" -c '${d}'`);
135
+ }
136
+ runQuiet(`tmux set-option -t "${winTarget}" -w main-pane-width '${mainSize}%'`);
137
+ run(`tmux select-layout -t "${winTarget}" main-vertical`);
138
+ }
139
+
140
+ // Get pane IDs and send commands
141
+ const paneIds = getPaneIdsForWindow(sessionName, windowIndex);
142
+ for (let i = 0; i < panes.length && i < paneIds.length; i++) {
143
+ if (panes[i].cmd) {
144
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}' Enter`);
145
+ }
146
+ if (panes[i].name) {
147
+ runQuiet(`tmux select-pane -t "${paneIds[i]}" -T "${panes[i].name}"`);
148
+ }
149
+ }
150
+
151
+ // Focus first pane in this window
152
+ if (paneIds.length) {
153
+ run(`tmux select-pane -t "${paneIds[0]}"`);
154
+ }
155
+ }
156
+
157
+ /** Create a group session with one tmux window per tab */
158
+ function createGroupSession(group) {
159
+ const name = toGroupSessionName(group.id);
160
+ const tabs = group.tabs || [];
161
+
162
+ if (!tabs.length) {
163
+ console.log(`Group "${group.id}" has no tabs.`);
164
+ return null;
165
+ }
166
+
167
+ // Validate all paths exist
168
+ for (const tab of tabs) {
169
+ if (!existsSync(tab.path)) {
170
+ console.log(`Warning: path does not exist — ${tab.path}`);
171
+ }
172
+ }
173
+
174
+ const firstDir = esc(tabs[0].path);
175
+ console.log(`Creating group "${group.label || group.id}" (${tabs.length} tabs)...`);
176
+
177
+ // Create session with first window
178
+ run(`tmux new-session -d -s "${name}" -c '${firstDir}'`);
179
+
180
+ // Set up each window/tab
181
+ for (let i = 0; i < tabs.length; i++) {
182
+ const tab = tabs[i];
183
+ const dir = resolve(tab.path);
184
+ createWindowForProject(name, i, dir, tab.label);
185
+ }
186
+
187
+ // Tag the session title
188
+ runQuiet(`tmux set-option -t "${name}" set-titles on`);
189
+ runQuiet(`tmux set-option -t "${name}" set-titles-string "[lattices:${name}] #{window_name} — #{pane_title}"`);
190
+
191
+ // Select first window
192
+ runQuiet(`tmux select-window -t "${name}:0"`);
193
+
194
+ return name;
195
+ }
196
+
197
+ function listGroups() {
198
+ const ws = readWorkspaceConfig();
199
+ if (!ws?.groups?.length) {
200
+ console.log("No tab groups configured in ~/.lattices/workspace.json");
201
+ return;
202
+ }
203
+
204
+ console.log("Tab Groups:\n");
205
+ for (const group of ws.groups) {
206
+ const tabs = group.tabs || [];
207
+ const runningCount = tabs.filter((t) => sessionExists(toSessionName(resolve(t.path)))).length;
208
+ const running = runningCount > 0;
209
+ const status = running
210
+ ? `\x1b[32m● ${runningCount}/${tabs.length} running\x1b[0m`
211
+ : "\x1b[90m○ stopped\x1b[0m";
212
+ const tabLabels = tabs.map((t) => t.label || basename(t.path)).join(", ");
213
+ console.log(` ${group.label || group.id} ${status}`);
214
+ console.log(` id: ${group.id}`);
215
+ console.log(` tabs: ${tabLabels}`);
216
+ console.log();
217
+ }
218
+ }
219
+
220
+ function groupCommand(id) {
221
+ const ws = readWorkspaceConfig();
222
+ if (!ws?.groups?.length) {
223
+ console.log("No tab groups configured in ~/.lattices/workspace.json");
224
+ return;
225
+ }
226
+
227
+ if (!id) {
228
+ listGroups();
229
+ return;
230
+ }
231
+
232
+ const group = ws.groups.find((g) => g.id === id);
233
+ if (!group) {
234
+ console.log(`No group "${id}". Available: ${ws.groups.map((g) => g.id).join(", ")}`);
235
+ return;
236
+ }
237
+
238
+ const tabs = group.tabs || [];
239
+ if (!tabs.length) {
240
+ console.log(`Group "${group.id}" has no tabs.`);
241
+ return;
242
+ }
243
+
244
+ // Each tab gets its own lattices session (individual project sessions)
245
+ const firstDir = resolve(tabs[0].path);
246
+ const firstName = toSessionName(firstDir);
247
+
248
+ // If the first tab's session already exists, just attach
249
+ if (sessionExists(firstName)) {
250
+ console.log(`Reattaching to "${group.label || group.id}" (${tabs[0].label || basename(firstDir)})...`);
251
+ attach(firstName);
252
+ return;
253
+ }
254
+
255
+ // Create a detached session for each tab
256
+ console.log(`Launching group "${group.label || group.id}" (${tabs.length} tabs)...`);
257
+ for (const tab of tabs) {
258
+ const dir = resolve(tab.path);
259
+ const name = toSessionName(dir);
260
+ if (!sessionExists(name)) {
261
+ console.log(` Creating session: ${tab.label || basename(dir)}`);
262
+ createSession(dir);
263
+ }
264
+ }
265
+
266
+ // Attach to the first tab's session
267
+ attach(firstName);
268
+ }
269
+
270
+ function tabCommand(groupId, tabName) {
271
+ if (!groupId) {
272
+ console.log("Usage: lattices tab <group-id> <tab-name|index>");
273
+ return;
274
+ }
275
+
276
+ const ws = readWorkspaceConfig();
277
+ if (!ws?.groups?.length) {
278
+ console.log("No tab groups configured.");
279
+ return;
280
+ }
281
+
282
+ const group = ws.groups.find((g) => g.id === groupId);
283
+ if (!group) {
284
+ console.log(`No group "${groupId}".`);
285
+ return;
286
+ }
287
+
288
+ const tabs = group.tabs || [];
289
+
290
+ if (!tabName) {
291
+ // List tabs with their session status
292
+ console.log(`Tabs in "${group.label || group.id}":\n`);
293
+ for (let i = 0; i < tabs.length; i++) {
294
+ const label = tabs[i].label || basename(tabs[i].path);
295
+ const tabSession = toSessionName(resolve(tabs[i].path));
296
+ const running = sessionExists(tabSession);
297
+ const status = running ? "\x1b[32m●\x1b[0m" : "\x1b[90m○\x1b[0m";
298
+ console.log(` ${status} ${i}: ${label} (session: ${tabSession})`);
299
+ }
300
+ return;
301
+ }
302
+
303
+ // Resolve tab target to an index
304
+ let tabIdx;
305
+ if (/^\d+$/.test(tabName)) {
306
+ tabIdx = parseInt(tabName, 10);
307
+ } else {
308
+ tabIdx = tabs.findIndex(
309
+ (t) => (t.label || basename(t.path)).toLowerCase() === tabName.toLowerCase()
310
+ );
311
+ if (tabIdx === -1) {
312
+ const available = tabs.map((t) => t.label || basename(t.path)).join(", ");
313
+ console.log(`No tab "${tabName}". Available: ${available}`);
314
+ return;
315
+ }
316
+ }
317
+
318
+ if (tabIdx < 0 || tabIdx >= tabs.length) {
319
+ console.log(`Tab index ${tabIdx} is out of range (${tabs.length} tabs).`);
320
+ return;
321
+ }
322
+
323
+ // Each tab is its own lattices session — attach to it
324
+ const dir = resolve(tabs[tabIdx].path);
325
+ const tabSession = toSessionName(dir);
326
+ const label = tabs[tabIdx].label || basename(dir);
327
+
328
+ if (sessionExists(tabSession)) {
329
+ console.log(`Attaching to tab: ${label}`);
330
+ attach(tabSession);
331
+ } else {
332
+ console.log(`Creating session for tab: ${label}`);
333
+ createSession(dir);
334
+ attach(tabSession);
335
+ }
336
+ }
337
+
338
+ // ── Detect dev command ───────────────────────────────────────────────
339
+
340
+ function detectPackageManager(dir) {
341
+ if (existsSync(resolve(dir, "pnpm-lock.yaml"))) return "pnpm";
342
+ if (existsSync(resolve(dir, "bun.lockb")) || existsSync(resolve(dir, "bun.lock")))
343
+ return "bun";
344
+ if (existsSync(resolve(dir, "yarn.lock"))) return "yarn";
345
+ return "npm";
346
+ }
347
+
348
+ function detectDevCommand(dir) {
349
+ const pkgPath = resolve(dir, "package.json");
350
+ if (!existsSync(pkgPath)) return null;
351
+
352
+ let pkg;
353
+ try {
354
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
355
+ } catch {
356
+ return null;
357
+ }
358
+
359
+ const scripts = pkg.scripts || {};
360
+ const pm = detectPackageManager(dir);
361
+ const run = pm === "npm" ? "npm run" : pm;
362
+
363
+ if (scripts.dev) return `${run} dev`;
364
+ if (scripts.start) return `${run} start`;
365
+ if (scripts.serve) return `${run} serve`;
366
+ if (scripts.watch) return `${run} watch`;
367
+ return null;
368
+ }
369
+
370
+ // ── Session creation ─────────────────────────────────────────────────
371
+
372
+ function resolvePane(panes, dir) {
373
+ return panes.map((p) => ({
374
+ name: p.name || "",
375
+ cmd: p.cmd || undefined,
376
+ size: p.size || undefined,
377
+ }));
378
+ }
379
+
380
+ /** Get ordered pane IDs (e.g. ["%0", "%1"]) for a session */
381
+ function getPaneIds(name) {
382
+ const out = runQuiet(
383
+ `tmux list-panes -t "${name}" -F "#{pane_id}"`
384
+ );
385
+ return out ? out.split("\n").filter(Boolean) : [];
386
+ }
387
+
388
+ function createSession(dir) {
389
+ const name = toSessionName(dir);
390
+ const config = readConfig(dir);
391
+ const d = esc(dir);
392
+
393
+ let panes;
394
+ if (config?.panes?.length) {
395
+ panes = resolvePane(config.panes, dir);
396
+ console.log(`Using .lattices.json (${panes.length} panes)`);
397
+ } else {
398
+ panes = defaultPanes(dir);
399
+ if (panes.length > 1) console.log(`Detected: ${panes[1].cmd}`);
400
+ else console.log(`No dev server detected — single pane`);
401
+ }
402
+
403
+ // Create session (targets are config-agnostic — no hardcoded indices)
404
+ run(`tmux new-session -d -s "${name}" -c '${d}'`);
405
+
406
+ if (panes.length === 2) {
407
+ const mainSize = panes[0].size || 60;
408
+ run(
409
+ `tmux split-window -h -t "${name}" -c '${d}' -p ${100 - mainSize}`
410
+ );
411
+ } else if (panes.length >= 3) {
412
+ const mainSize = panes[0].size || 60;
413
+ for (let i = 1; i < panes.length; i++) {
414
+ run(`tmux split-window -t "${name}" -c '${d}'`);
415
+ }
416
+ runQuiet(
417
+ `tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
418
+ );
419
+ run(`tmux select-layout -t "${name}" main-vertical`);
420
+ }
421
+
422
+ // Get actual pane IDs (works regardless of base-index / pane-base-index)
423
+ const paneIds = getPaneIds(name);
424
+
425
+ // Send commands and name each pane
426
+ for (let i = 0; i < panes.length && i < paneIds.length; i++) {
427
+ if (panes[i].cmd) {
428
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}' Enter`);
429
+ }
430
+ if (panes[i].name) {
431
+ runQuiet(`tmux select-pane -t "${paneIds[i]}" -T "${panes[i].name}"`);
432
+ }
433
+ }
434
+
435
+ // Tag the terminal window title so the menu bar app can find it
436
+ // Format: [lattices:session-hash] pane_title: current_command
437
+ runQuiet(`tmux set-option -t "${name}" set-titles on`);
438
+ runQuiet(`tmux set-option -t "${name}" set-titles-string "[lattices:${name}] #{pane_title}"`);
439
+
440
+ // Name the tmux window after the project and focus the first pane
441
+ runQuiet(`tmux rename-window -t "${name}" "${basename(dir)}"`);
442
+ if (paneIds.length) {
443
+ run(`tmux select-pane -t "${paneIds[0]}"`);
444
+ }
445
+
446
+ return name;
447
+ }
448
+
449
+ /** Check each pane and prefill or restart commands that have exited.
450
+ * mode: "prefill" types the command without pressing Enter
451
+ * mode: "ensure" types the command and presses Enter */
452
+ function restoreCommands(name, dir, mode) {
453
+ const config = readConfig(dir);
454
+ let panes;
455
+ if (config?.panes?.length) {
456
+ panes = resolvePane(config.panes, dir);
457
+ } else {
458
+ panes = defaultPanes(dir);
459
+ }
460
+
461
+ const paneIds = getPaneIds(name);
462
+ const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
463
+
464
+ let count = 0;
465
+ for (let i = 0; i < panes.length && i < paneIds.length; i++) {
466
+ if (!panes[i].cmd) continue;
467
+ const cur = runQuiet(
468
+ `tmux display-message -t "${paneIds[i]}" -p "#{pane_current_command}"`
469
+ );
470
+ if (cur && shells.has(cur)) {
471
+ if (mode === "ensure") {
472
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}' Enter`);
473
+ } else {
474
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd)}'`);
475
+ }
476
+ count++;
477
+ }
478
+ }
479
+ if (count > 0) {
480
+ const verb = mode === "ensure" ? "Restarted" : "Prefilled";
481
+ console.log(`${verb} ${count} exited command${count > 1 ? "s" : ""}`);
482
+ }
483
+ }
484
+
485
+ // ── Sync / reconcile ────────────────────────────────────────────────
486
+
487
+ function resolvePanes(dir) {
488
+ const config = readConfig(dir);
489
+ if (config?.panes?.length) {
490
+ return resolvePane(config.panes, dir);
491
+ }
492
+ return defaultPanes(dir);
493
+ }
494
+
495
+ function defaultPanes(dir) {
496
+ const devCmd = detectDevCommand(dir);
497
+ if (devCmd) {
498
+ return [
499
+ { name: "claude", cmd: "claude", size: 60 },
500
+ { name: "server", cmd: devCmd },
501
+ ];
502
+ }
503
+ // No dev server detected → single pane
504
+ return [{ name: "claude", cmd: "claude" }];
505
+ }
506
+
507
+ function syncSession() {
508
+ const dir = process.cwd();
509
+ const name = toSessionName(dir);
510
+
511
+ if (!sessionExists(name)) {
512
+ console.log(`No session "${name}" — creating from scratch.`);
513
+ createSession(dir);
514
+ console.log("Session created.");
515
+ return;
516
+ }
517
+
518
+ const panes = resolvePanes(dir);
519
+ const actualIds = getPaneIds(name);
520
+ const declared = panes.length;
521
+ const actual = actualIds.length;
522
+ const d = esc(dir);
523
+ const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
524
+
525
+ console.log(`Session "${name}": ${actual} pane(s) found, ${declared} declared.`);
526
+
527
+ // Phase 1: recreate missing panes
528
+ if (actual < declared) {
529
+ const missing = declared - actual;
530
+ console.log(`Recreating ${missing} missing pane(s)...`);
531
+ for (let i = 0; i < missing; i++) {
532
+ run(`tmux split-window -t "${name}" -c '${d}'`);
533
+ }
534
+
535
+ // Re-apply layout
536
+ if (declared === 2) {
537
+ const mainSize = panes[0].size || 60;
538
+ // With 2 panes, use horizontal split layout
539
+ run(`tmux select-layout -t "${name}" even-horizontal`);
540
+ runQuiet(
541
+ `tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
542
+ );
543
+ run(`tmux select-layout -t "${name}" main-vertical`);
544
+ } else if (declared >= 3) {
545
+ const mainSize = panes[0].size || 60;
546
+ runQuiet(
547
+ `tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
548
+ );
549
+ run(`tmux select-layout -t "${name}" main-vertical`);
550
+ }
551
+ }
552
+
553
+ // Phase 2: restore commands and labels on all panes
554
+ const freshIds = getPaneIds(name);
555
+ let restored = 0;
556
+ for (let i = 0; i < panes.length && i < freshIds.length; i++) {
557
+ // Set pane title/label
558
+ if (panes[i].name) {
559
+ runQuiet(`tmux select-pane -t "${freshIds[i]}" -T "${panes[i].name}"`);
560
+ }
561
+ // If pane is idle at a shell prompt, send its declared command
562
+ if (panes[i].cmd) {
563
+ const cur = runQuiet(
564
+ `tmux display-message -t "${freshIds[i]}" -p "#{pane_current_command}"`
565
+ );
566
+ if (cur && shells.has(cur)) {
567
+ run(`tmux send-keys -t "${freshIds[i]}" '${esc(panes[i].cmd)}' Enter`);
568
+ restored++;
569
+ }
570
+ }
571
+ }
572
+
573
+ // Focus first pane
574
+ if (freshIds.length) {
575
+ run(`tmux select-pane -t "${freshIds[0]}"`);
576
+ }
577
+
578
+ if (restored > 0) {
579
+ console.log(`Restarted ${restored} command(s).`);
580
+ }
581
+ console.log("Sync complete.");
582
+ }
583
+
584
+ // ── Restart pane ────────────────────────────────────────────────────
585
+
586
+ function restartPane(target) {
587
+ const dir = process.cwd();
588
+ const name = toSessionName(dir);
589
+
590
+ if (!sessionExists(name)) {
591
+ console.log(`No session "${name}".`);
592
+ return;
593
+ }
594
+
595
+ const panes = resolvePanes(dir);
596
+ const paneIds = getPaneIds(name);
597
+
598
+ // Resolve target to an index
599
+ let idx;
600
+ if (target === undefined || target === null || target === "") {
601
+ // Default: first pane (claude)
602
+ idx = 0;
603
+ } else if (/^\d+$/.test(target)) {
604
+ idx = parseInt(target, 10);
605
+ } else {
606
+ // Match by name (case-insensitive)
607
+ idx = panes.findIndex(
608
+ (p) => p.name && p.name.toLowerCase() === target.toLowerCase()
609
+ );
610
+ if (idx === -1) {
611
+ console.log(
612
+ `No pane named "${target}". Available: ${panes.map((p, i) => p.name || `[${i}]`).join(", ")}`
613
+ );
614
+ return;
615
+ }
616
+ }
617
+
618
+ if (idx < 0 || idx >= paneIds.length) {
619
+ console.log(`Pane index ${idx} is out of range (${paneIds.length} panes).`);
620
+ return;
621
+ }
622
+
623
+ const paneId = paneIds[idx];
624
+ const pane = panes[idx] || {};
625
+ const label = pane.name || `pane ${idx}`;
626
+
627
+ // Get the PID of the process running in the pane
628
+ const panePid = runQuiet(
629
+ `tmux display-message -t "${paneId}" -p "#{pane_pid}"`
630
+ );
631
+
632
+ // Step 1: try C-c to gracefully stop
633
+ console.log(`Stopping ${label}...`);
634
+ run(`tmux send-keys -t "${paneId}" C-c`);
635
+
636
+ // Brief pause to let C-c propagate
637
+ execSync("sleep 0.5");
638
+
639
+ // Step 2: check if the process is still running (not back to shell)
640
+ const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
641
+ const cur = runQuiet(
642
+ `tmux display-message -t "${paneId}" -p "#{pane_current_command}"`
643
+ );
644
+
645
+ if (cur && !shells.has(cur)) {
646
+ // Still hung — escalate: kill the child processes of the pane
647
+ console.log(`Process still running (${cur}), sending SIGKILL...`);
648
+ if (panePid) {
649
+ // Kill all children of the pane's shell process
650
+ runQuiet(`pkill -KILL -P ${panePid}`);
651
+ execSync("sleep 0.3");
652
+ }
653
+ }
654
+
655
+ // Step 3: send the declared command
656
+ if (pane.cmd) {
657
+ console.log(`Starting: ${pane.cmd}`);
658
+ run(`tmux send-keys -t "${paneId}" '${esc(pane.cmd)}' Enter`);
659
+ } else {
660
+ console.log(`No command declared for ${label} — pane is at shell prompt.`);
661
+ }
662
+ }
663
+
664
+ // ── Commands ─────────────────────────────────────────────────────────
665
+
666
+ // ── Daemon-aware commands ────────────────────────────────────────────
667
+
668
+ async function daemonStatusCommand() {
669
+ try {
670
+ const { daemonCall } = await getDaemonClient();
671
+ const status = await daemonCall("daemon.status");
672
+ const uptime = Math.round(status.uptime);
673
+ const h = Math.floor(uptime / 3600);
674
+ const m = Math.floor((uptime % 3600) / 60);
675
+ const s = uptime % 60;
676
+ const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
677
+ console.log(`\x1b[32m●\x1b[0m Daemon running on ws://127.0.0.1:9399`);
678
+ console.log(` uptime: ${uptimeStr}`);
679
+ console.log(` clients: ${status.clientCount}`);
680
+ console.log(` windows: ${status.windowCount}`);
681
+ console.log(` tmux: ${status.tmuxSessionCount} sessions`);
682
+ console.log(` version: ${status.version}`);
683
+ } catch {
684
+ console.log("\x1b[90m○\x1b[0m Daemon not running (start with: lattices app)");
685
+ }
686
+ }
687
+
688
+ async function windowsCommand(jsonFlag) {
689
+ try {
690
+ const { daemonCall } = await getDaemonClient();
691
+ const windows = await daemonCall("windows.list");
692
+ if (jsonFlag) {
693
+ console.log(JSON.stringify(windows, null, 2));
694
+ return;
695
+ }
696
+ if (!windows.length) {
697
+ console.log("No windows tracked.");
698
+ return;
699
+ }
700
+ console.log(`Windows (${windows.length}):\n`);
701
+ for (const w of windows) {
702
+ const session = w.latticesSession ? ` \x1b[36m[lattices:${w.latticesSession}]\x1b[0m` : "";
703
+ const layer = w.layerTag ? ` \x1b[33m[layer:${w.layerTag}]\x1b[0m` : "";
704
+ const spaces = w.spaceIds.length ? ` space:${w.spaceIds.join(",")}` : "";
705
+ console.log(` \x1b[1m${w.app}\x1b[0m wid:${w.wid}${spaces}${session}${layer}`);
706
+ console.log(` "${w.title}"`);
707
+ console.log(` ${Math.round(w.frame.w)}×${Math.round(w.frame.h)} at (${Math.round(w.frame.x)},${Math.round(w.frame.y)})`);
708
+ console.log();
709
+ }
710
+ } catch {
711
+ console.log("Daemon not running. Start with: lattices app");
712
+ }
713
+ }
714
+
715
+ async function windowAssignCommand(wid, layerId) {
716
+ if (!wid || !layerId) {
717
+ console.log("Usage: lattices window assign <wid> <layer-id>");
718
+ return;
719
+ }
720
+ try {
721
+ const { daemonCall } = await getDaemonClient();
722
+ await daemonCall("window.assignLayer", { wid: parseInt(wid), layer: layerId });
723
+ console.log(`Tagged wid:${wid} → layer:${layerId}`);
724
+ } catch (e) {
725
+ console.log(`Error: ${e.message}`);
726
+ }
727
+ }
728
+
729
+ async function windowLayerMapCommand(jsonFlag) {
730
+ try {
731
+ const { daemonCall } = await getDaemonClient();
732
+ const map = await daemonCall("window.layerMap");
733
+ if (jsonFlag) {
734
+ console.log(JSON.stringify(map, null, 2));
735
+ return;
736
+ }
737
+ const entries = Object.entries(map);
738
+ if (!entries.length) {
739
+ console.log("No layer tags assigned.");
740
+ return;
741
+ }
742
+ console.log("Window → Layer map:\n");
743
+ for (const [wid, layer] of entries) {
744
+ console.log(` wid:${wid} → ${layer}`);
745
+ }
746
+ } catch {
747
+ console.log("Daemon not running. Start with: lattices app");
748
+ }
749
+ }
750
+
751
+ async function focusCommand(session) {
752
+ if (!session) {
753
+ console.log("Usage: lattices focus <session-name>");
754
+ return;
755
+ }
756
+ try {
757
+ const { daemonCall } = await getDaemonClient();
758
+ await daemonCall("window.focus", { session });
759
+ console.log(`Focused: ${session}`);
760
+ } catch (e) {
761
+ console.log(`Error: ${e.message}`);
762
+ }
763
+ }
764
+
765
+ async function layerCommand(index) {
766
+ try {
767
+ const { daemonCall } = await getDaemonClient();
768
+ if (index === undefined || index === null || index === "") {
769
+ const result = await daemonCall("layers.list");
770
+ if (!result.layers.length) {
771
+ console.log("No layers configured.");
772
+ return;
773
+ }
774
+ console.log("Layers:\n");
775
+ for (const layer of result.layers) {
776
+ const active = layer.index === result.active ? " \x1b[32m● active\x1b[0m" : "";
777
+ console.log(` [${layer.index}] ${layer.label} (${layer.projectCount} projects)${active}`);
778
+ }
779
+ return;
780
+ }
781
+ const idx = parseInt(index, 10);
782
+ if (!isNaN(idx)) {
783
+ await daemonCall("layer.switch", { index: idx });
784
+ console.log(`Switched to layer ${idx}`);
785
+ } else {
786
+ await daemonCall("layer.switch", { name: index });
787
+ console.log(`Switched to layer "${index}"`);
788
+ }
789
+ } catch (e) {
790
+ console.log(`Error: ${e.message}`);
791
+ }
792
+ }
793
+
794
+ async function diagCommand(limit) {
795
+ try {
796
+ const { daemonCall } = await getDaemonClient();
797
+ const result = await daemonCall("diagnostics.list", { limit: parseInt(limit, 10) || 40 });
798
+ if (!result.entries || !result.entries.length) {
799
+ console.log("No diagnostic entries.");
800
+ return;
801
+ }
802
+ for (const entry of result.entries) {
803
+ const icon = entry.level === "success" ? "\x1b[32m✓\x1b[0m" :
804
+ entry.level === "warning" ? "\x1b[33m⚠\x1b[0m" :
805
+ entry.level === "error" ? "\x1b[31m✗\x1b[0m" : "›";
806
+ console.log(` \x1b[90m${entry.time}\x1b[0m ${icon} ${entry.message}`);
807
+ }
808
+ } catch (e) {
809
+ console.log(`Error: ${e.message}`);
810
+ }
811
+ }
812
+
813
+ async function distributeCommand() {
814
+ try {
815
+ const { daemonCall } = await getDaemonClient();
816
+ await daemonCall("layout.distribute");
817
+ console.log("Distributed visible windows into grid");
818
+ } catch {
819
+ console.log("Daemon not running. Start with: lattices app");
820
+ }
821
+ }
822
+
823
+ async function daemonLsCommand() {
824
+ try {
825
+ const { daemonCall, isDaemonRunning } = await getDaemonClient();
826
+ if (!(await isDaemonRunning())) return false;
827
+ const sessions = await daemonCall("tmux.sessions");
828
+ if (!sessions.length) {
829
+ console.log("No active tmux sessions.");
830
+ return true;
831
+ }
832
+
833
+ // Annotate sessions with workspace group info
834
+ const ws = readWorkspaceConfig();
835
+ const sessionGroupMap = new Map();
836
+ if (ws?.groups) {
837
+ for (const g of ws.groups) {
838
+ for (const tab of g.tabs || []) {
839
+ const tabSession = toSessionName(resolve(tab.path));
840
+ sessionGroupMap.set(tabSession, {
841
+ group: g.label || g.id,
842
+ tab: tab.label || basename(tab.path),
843
+ });
844
+ }
845
+ }
846
+ }
847
+
848
+ console.log("Sessions:\n");
849
+ for (const s of sessions) {
850
+ const info = sessionGroupMap.get(s.name);
851
+ const groupTag = info ? ` \x1b[36m[${info.group}: ${info.tab}]\x1b[0m` : "";
852
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
853
+ console.log(` ${s.name} (${s.windowCount} windows)${attachTag}${groupTag}`);
854
+ }
855
+ return true;
856
+ } catch {
857
+ return false;
858
+ }
859
+ }
860
+
861
+ async function daemonStatusInventory() {
862
+ try {
863
+ const { daemonCall, isDaemonRunning } = await getDaemonClient();
864
+ if (!(await isDaemonRunning())) return false;
865
+ const inv = await daemonCall("tmux.inventory");
866
+
867
+ // Build managed session name set
868
+ const managed = new Map();
869
+ const ws = readWorkspaceConfig();
870
+ if (ws?.groups) {
871
+ for (const g of ws.groups) {
872
+ for (const tab of g.tabs || []) {
873
+ const name = toSessionName(resolve(tab.path));
874
+ const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
875
+ managed.set(name, label);
876
+ }
877
+ }
878
+ }
879
+ for (const s of inv.all) {
880
+ if (!managed.has(s.name)) {
881
+ // Check if it matches a scanned project (via daemon)
882
+ const projects = await daemonCall("projects.list");
883
+ for (const p of projects) {
884
+ managed.set(p.sessionName, p.name);
885
+ }
886
+ break;
887
+ }
888
+ }
889
+
890
+ const managedSessions = inv.all.filter((s) => managed.has(s.name));
891
+ const orphanSessions = inv.orphans;
892
+
893
+ if (managedSessions.length > 0) {
894
+ console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
895
+ for (const s of managedSessions) {
896
+ const label = managed.get(s.name) || s.name;
897
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
898
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
899
+ for (const p of s.panes) {
900
+ console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
901
+ }
902
+ console.log();
903
+ }
904
+ } else {
905
+ console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
906
+ }
907
+
908
+ if (orphanSessions.length > 0) {
909
+ console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
910
+ for (const s of orphanSessions) {
911
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
912
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag}`);
913
+ for (const p of s.panes) {
914
+ console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
915
+ }
916
+ console.log();
917
+ }
918
+ } else {
919
+ console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
920
+ }
921
+ return true;
922
+ } catch {
923
+ return false;
924
+ }
925
+ }
926
+
927
+ // ── OCR commands ──────────────────────────────────────────────────────
928
+
929
+ async function scanCommand(sub, ...rest) {
930
+ const { daemonCall } = await getDaemonClient();
931
+
932
+ if (!sub || sub === "snapshot" || sub === "ls" || sub === "--full" || sub === "-f" || sub === "--json") {
933
+ const full = sub === "--full" || sub === "-f" || rest.includes("--full") || rest.includes("-f");
934
+ const json = sub === "--json" || rest.includes("--json");
935
+ try {
936
+ const results = await daemonCall("ocr.snapshot", null, 5000);
937
+ if (!results.length) {
938
+ console.log("No scan results yet. The first scan runs ~60s after launch.");
939
+ return;
940
+ }
941
+ if (json) {
942
+ console.log(JSON.stringify(results, null, 2));
943
+ return;
944
+ }
945
+ console.log(`\x1b[1mScan\x1b[0m (${results.length} windows)\n`);
946
+ for (const r of results) {
947
+ const age = Math.round((Date.now() / 1000) - r.timestamp);
948
+ const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
949
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
950
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
951
+ console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid} ${src} \x1b[90m${ageStr}\x1b[0m`);
952
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
953
+ if (lines.length) {
954
+ if (full) {
955
+ for (const line of lines) {
956
+ console.log(` \x1b[90m${line}\x1b[0m`);
957
+ }
958
+ } else {
959
+ const maxPreview = 5;
960
+ const preview = lines.slice(0, maxPreview).map(l => l.length > 100 ? l.slice(0, 97) + "..." : l);
961
+ for (const line of preview) {
962
+ console.log(` \x1b[90m${line}\x1b[0m`);
963
+ }
964
+ if (lines.length > maxPreview) {
965
+ console.log(` \x1b[90m… ${lines.length - maxPreview} more lines\x1b[0m`);
966
+ }
967
+ }
968
+ } else {
969
+ console.log(` \x1b[90m(no text detected)\x1b[0m`);
970
+ }
971
+ console.log();
972
+ }
973
+ } catch {
974
+ console.log("Daemon not running. Start with: lattices app");
975
+ }
976
+ return;
977
+ }
978
+
979
+ if (sub === "search") {
980
+ const query = rest.join(" ");
981
+ if (!query) {
982
+ console.log("Usage: lattices scan search <query>");
983
+ return;
984
+ }
985
+ try {
986
+ const results = await daemonCall("ocr.search", { query }, 5000);
987
+ if (!results.length) {
988
+ console.log(`No matches for "${query}".`);
989
+ return;
990
+ }
991
+ console.log(`\x1b[1mSearch\x1b[0m "${query}" (${results.length} matches)\n`);
992
+ for (const r of results) {
993
+ const snippet = r.snippet || r.fullText?.slice(0, 120) || "";
994
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
995
+ console.log(` ${src} \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
996
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
997
+ console.log(` ${snippet}`);
998
+ console.log();
999
+ }
1000
+ } catch (e) {
1001
+ console.log(`Error: ${e.message}`);
1002
+ }
1003
+ return;
1004
+ }
1005
+
1006
+ if (sub === "recent" || sub === "log") {
1007
+ const full = rest.includes("--full") || rest.includes("-f");
1008
+ const numArg = rest.find(a => !a.startsWith("-"));
1009
+ const limit = parseInt(numArg, 10) || 20;
1010
+ try {
1011
+ const results = await daemonCall("ocr.recent", { limit }, 5000);
1012
+ if (!results.length) {
1013
+ console.log("No history yet. The first scan runs ~60s after launch.");
1014
+ return;
1015
+ }
1016
+ console.log(`\x1b[1mRecent\x1b[0m (${results.length} entries)\n`);
1017
+ for (const r of results) {
1018
+ const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
1019
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
1020
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
1021
+ console.log(` \x1b[90m${ts}\x1b[0m ${src} \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
1022
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
1023
+ if (full) {
1024
+ for (const line of lines) {
1025
+ console.log(` \x1b[90m${line}\x1b[0m`);
1026
+ }
1027
+ } else {
1028
+ const maxPreview = 5;
1029
+ const preview = lines.slice(0, maxPreview).map(l => l.length > 100 ? l.slice(0, 97) + "..." : l);
1030
+ for (const line of preview) {
1031
+ console.log(` \x1b[90m${line}\x1b[0m`);
1032
+ }
1033
+ if (lines.length > maxPreview) {
1034
+ console.log(` \x1b[90m… ${lines.length - maxPreview} more lines\x1b[0m`);
1035
+ }
1036
+ }
1037
+ console.log();
1038
+ }
1039
+ } catch {
1040
+ console.log("Daemon not running. Start with: lattices app");
1041
+ }
1042
+ return;
1043
+ }
1044
+
1045
+ if (sub === "deep" || sub === "now" || sub === "scan") {
1046
+ try {
1047
+ console.log("Triggering deep scan (Vision OCR)...");
1048
+ await daemonCall("ocr.scan", null, 30000);
1049
+ console.log("Done.");
1050
+ } catch (e) {
1051
+ console.log(`Error: ${e.message}`);
1052
+ }
1053
+ return;
1054
+ }
1055
+
1056
+ if (sub === "history") {
1057
+ const wid = parseInt(rest[0], 10);
1058
+ if (isNaN(wid)) {
1059
+ console.log("Usage: lattices scan history <wid>");
1060
+ return;
1061
+ }
1062
+ try {
1063
+ const results = await daemonCall("ocr.history", { wid }, 5000);
1064
+ if (!results.length) {
1065
+ console.log(`No history for wid:${wid}.`);
1066
+ return;
1067
+ }
1068
+ console.log(`\x1b[1mHistory\x1b[0m wid:${wid} (${results.length} entries)\n`);
1069
+ for (const r of results) {
1070
+ const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
1071
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
1072
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
1073
+ const preview = lines.slice(0, 2).map(l => l.length > 80 ? l.slice(0, 77) + "..." : l);
1074
+ console.log(` \x1b[90m${ts}\x1b[0m ${src} \x1b[1m${r.app}\x1b[0m — "${r.title}"`);
1075
+ for (const line of preview) {
1076
+ console.log(` \x1b[90m${line}\x1b[0m`);
1077
+ }
1078
+ console.log();
1079
+ }
1080
+ } catch (e) {
1081
+ console.log(`Error: ${e.message}`);
1082
+ }
1083
+ return;
1084
+ }
1085
+
1086
+ // Unknown subcommand
1087
+ console.log(`lattices scan — Screen text recognition
1088
+
1089
+ Usage:
1090
+ lattices scan Show text from all visible windows
1091
+ lattices scan --full Full text dump
1092
+ lattices scan --json JSON output
1093
+ lattices scan search <q> Full-text search across scanned windows
1094
+ lattices scan recent [n] Show recent scans chronologically (default 20)
1095
+ lattices scan deep Trigger a deep Vision OCR scan
1096
+ lattices scan history <wid> Show scan timeline for a window
1097
+ `);
1098
+ }
1099
+
1100
+ function printUsage() {
1101
+ console.log(`lattices — Claude Code + dev server in tmux
1102
+
1103
+ Usage:
1104
+ lattices Create session (or reattach) for current project
1105
+ lattices init Generate .lattices.json config for this project
1106
+ lattices ls List active tmux sessions
1107
+ lattices status Show managed vs unmanaged session inventory
1108
+ lattices kill [name] Kill a session (defaults to current project)
1109
+ lattices sync Reconcile session to match declared config
1110
+ lattices restart [pane] Restart a pane's process (by name or index)
1111
+ lattices group [id] List tab groups or launch/attach a group
1112
+ lattices groups List all tab groups with status
1113
+ lattices tab <group> [tab] Switch tab within a group (by label or index)
1114
+ lattices windows [--json] List all desktop windows (daemon required)
1115
+ lattices focus <session> Focus a session's terminal window (daemon required)
1116
+ lattices tile <position> Tile the frontmost window (left, right, top, etc.)
1117
+ lattices distribute Smart-grid all visible windows (daemon required)
1118
+ lattices layer [name|index] List layers or switch by name/index (daemon required)
1119
+ lattices scan Show text from all visible windows
1120
+ lattices scan --full Full text dump
1121
+ lattices scan search <q> Full-text search across scanned windows
1122
+ lattices scan recent [n] Show recent scans chronologically
1123
+ lattices scan deep Trigger a deep Vision OCR scan
1124
+ lattices scan history <wid> Scan timeline for a specific window
1125
+ lattices daemon status Show daemon status
1126
+ lattices diag [limit] Show diagnostic log entries
1127
+ lattices app Launch the menu bar companion app
1128
+ lattices app build Rebuild the menu bar app
1129
+ lattices app restart Rebuild and relaunch the menu bar app
1130
+ lattices app quit Stop the menu bar app
1131
+ lattices help Show this help
1132
+
1133
+ Config (.lattices.json):
1134
+ Place in your project root to customize the layout:
1135
+
1136
+ {
1137
+ "ensure": true,
1138
+ "panes": [
1139
+ { "name": "claude", "cmd": "claude", "size": 60 },
1140
+ { "name": "server", "cmd": "pnpm dev" },
1141
+ { "name": "tests", "cmd": "pnpm test --watch" }
1142
+ ]
1143
+ }
1144
+
1145
+ size Width % for the first pane (default: 60)
1146
+ cmd Command to run in the pane
1147
+ name Label (for your reference)
1148
+ ensure Auto-restart exited commands on reattach
1149
+ prefill Type commands into idle panes on reattach (you hit Enter)
1150
+
1151
+ Recovery:
1152
+ lattices sync Recreates missing panes, restores commands, fixes layout.
1153
+ Use when a pane was killed and you want to get back to the
1154
+ declared state without killing the whole session.
1155
+
1156
+ lattices restart Kills the process in a pane and re-runs its declared command.
1157
+ Accepts a pane name or 0-based index (default: 0 / first pane).
1158
+ Examples: lattices restart (restarts "claude")
1159
+ lattices restart server (restarts "server" by name)
1160
+ lattices restart 1 (restarts pane at index 1)
1161
+
1162
+ Layouts:
1163
+ 1 pane → single full-width (default when no dev server detected)
1164
+ 2 panes → side-by-side split
1165
+ 3+ panes → main-vertical (first pane left, rest stacked right)
1166
+
1167
+ ┌────────────────────┐ ┌──────────┬─────────┐ ┌──────────┬─────────┐
1168
+ │ claude │ │ claude │ server │ │ claude │ server │
1169
+ │ │ │ (60%) │ (40%) │ │ (60%) ├─────────┤
1170
+ └────────────────────┘ └──────────┴─────────┘ │ │ tests │
1171
+ └──────────┴─────────┘
1172
+ `);
1173
+ }
1174
+
1175
+ function initConfig() {
1176
+ const dir = process.cwd();
1177
+ const configPath = resolve(dir, ".lattices.json");
1178
+
1179
+ if (existsSync(configPath)) {
1180
+ console.log(".lattices.json already exists.");
1181
+ return;
1182
+ }
1183
+
1184
+ const panes = defaultPanes(dir);
1185
+ const config = {
1186
+ ensure: true,
1187
+ panes,
1188
+ };
1189
+
1190
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
1191
+ console.log("Created .lattices.json");
1192
+ console.log(JSON.stringify(config, null, 2));
1193
+ }
1194
+
1195
+ function listSessions() {
1196
+ const out = runQuiet(
1197
+ "tmux list-sessions -F '#{session_name} (#{session_windows} windows, created #{session_created_string})'"
1198
+ );
1199
+ if (!out) {
1200
+ console.log("No active tmux sessions.");
1201
+ return;
1202
+ }
1203
+
1204
+ // Annotate sessions that belong to tab groups
1205
+ const ws = readWorkspaceConfig();
1206
+ const sessionGroupMap = new Map();
1207
+ if (ws?.groups) {
1208
+ for (const g of ws.groups) {
1209
+ for (const tab of g.tabs || []) {
1210
+ const tabSession = toSessionName(resolve(tab.path));
1211
+ sessionGroupMap.set(tabSession, {
1212
+ group: g.label || g.id,
1213
+ tab: tab.label || basename(tab.path),
1214
+ });
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ const lines = out.split("\n").map((line) => {
1220
+ const sessionName = line.split(" ")[0];
1221
+ const info = sessionGroupMap.get(sessionName);
1222
+ return info
1223
+ ? `${line} \x1b[36m[${info.group}: ${info.tab}]\x1b[0m`
1224
+ : line;
1225
+ });
1226
+
1227
+ console.log("Sessions:\n");
1228
+ console.log(lines.join("\n"));
1229
+ }
1230
+
1231
+ function killSession(name) {
1232
+ if (!name) name = toSessionName(process.cwd());
1233
+ if (!sessionExists(name)) {
1234
+ console.log(`No session "${name}".`);
1235
+ return;
1236
+ }
1237
+ run(`tmux kill-session -t "${name}"`);
1238
+ console.log(`Killed "${name}".`);
1239
+ }
1240
+
1241
+ // ── Window tiling ────────────────────────────────────────────────────
1242
+
1243
+ function getScreenBounds() {
1244
+ // Get the visible area (excludes menu bar and dock) in AppleScript coordinates (top-left origin)
1245
+ const script = `
1246
+ tell application "Finder"
1247
+ set db to bounds of window of desktop
1248
+ end tell
1249
+ -- db = {left, top, right, bottom} of usable desktop
1250
+ return (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)`;
1251
+ const out = runQuiet(`osascript -e '${esc(script)}'`);
1252
+ if (!out) return { x: 0, y: 25, w: 1920, h: 1055 };
1253
+ const [x, y, right, bottom] = out.split(",").map(s => parseInt(s.trim()));
1254
+ return { x, y, w: right - x, h: bottom - y };
1255
+ }
1256
+
1257
+ // Presets return AppleScript bounds: [left, top, right, bottom] within the visible area
1258
+ const tilePresets = {
1259
+ "left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
1260
+ "left-half": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
1261
+ "right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
1262
+ "right-half": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
1263
+ "top": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
1264
+ "top-half": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
1265
+ "bottom": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1266
+ "bottom-half": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1267
+ "top-left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h / 2],
1268
+ "top-right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h / 2],
1269
+ "bottom-left": (s) => [s.x, s.y + s.h / 2, s.x + s.w / 2, s.y + s.h],
1270
+ "bottom-right": (s) => [s.x + s.w / 2, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1271
+ "maximize": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
1272
+ "max": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
1273
+ "center": (s) => {
1274
+ const mw = Math.round(s.w * 0.7);
1275
+ const mh = Math.round(s.h * 0.8);
1276
+ const mx = s.x + Math.round((s.w - mw) / 2);
1277
+ const my = s.y + Math.round((s.h - mh) / 2);
1278
+ return [mx, my, mx + mw, my + mh];
1279
+ },
1280
+ "left-third": (s) => [s.x, s.y, s.x + Math.round(s.w * 0.333), s.y + s.h],
1281
+ "center-third": (s) => [s.x + Math.round(s.w * 0.333), s.y, s.x + Math.round(s.w * 0.667), s.y + s.h],
1282
+ "right-third": (s) => [s.x + Math.round(s.w * 0.667), s.y, s.x + s.w, s.y + s.h],
1283
+ };
1284
+
1285
+ function tileWindow(position) {
1286
+ const preset = tilePresets[position];
1287
+ if (!preset) {
1288
+ console.log(`Unknown position: ${position}`);
1289
+ console.log(`Available: ${Object.keys(tilePresets).filter(k => !k.includes("-half") && k !== "max").join(", ")}`);
1290
+ return;
1291
+ }
1292
+ const screen = getScreenBounds();
1293
+ const [x1, y1, x2, y2] = preset(screen).map(Math.round);
1294
+ const script = `
1295
+ tell application "System Events"
1296
+ set frontApp to name of first application process whose frontmost is true
1297
+ end tell
1298
+ tell application frontApp
1299
+ set bounds of front window to {${x1}, ${y1}, ${x2}, ${y2}}
1300
+ end tell`;
1301
+ runQuiet(`osascript -e '${esc(script)}'`);
1302
+ console.log(`Tiled → ${position}`);
1303
+ }
1304
+
1305
+ function createOrAttach() {
1306
+ const dir = process.cwd();
1307
+ const name = toSessionName(dir);
1308
+
1309
+ if (sessionExists(name)) {
1310
+ console.log(`Reattaching to "${name}"...`);
1311
+ const config = readConfig(dir);
1312
+ if (config?.ensure) {
1313
+ restoreCommands(name, dir, "ensure");
1314
+ } else if (config?.prefill) {
1315
+ restoreCommands(name, dir, "prefill");
1316
+ }
1317
+ attach(name);
1318
+ return;
1319
+ }
1320
+
1321
+ console.log(`Creating "${name}"...`);
1322
+ createSession(dir);
1323
+ attach(name);
1324
+ }
1325
+
1326
+ function attach(name) {
1327
+ if (isInsideTmux()) {
1328
+ execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
1329
+ } else {
1330
+ execSync(`tmux attach -t "${name}"`, { stdio: "inherit" });
1331
+ }
1332
+ }
1333
+
1334
+ // ── Status / Inventory ───────────────────────────────────────────────
1335
+
1336
+ function statusInventory() {
1337
+ // Query all tmux sessions
1338
+ const sessionsRaw = runQuiet(
1339
+ 'tmux list-sessions -F "#{session_name}\t#{session_windows}\t#{session_attached}"'
1340
+ );
1341
+ if (!sessionsRaw) {
1342
+ console.log("No active tmux sessions.");
1343
+ return;
1344
+ }
1345
+
1346
+ // Query all panes
1347
+ const panesRaw = runQuiet(
1348
+ 'tmux list-panes -a -F "#{session_name}\t#{pane_title}\t#{pane_current_command}"'
1349
+ );
1350
+
1351
+ // Parse panes grouped by session
1352
+ const panesBySession = new Map();
1353
+ if (panesRaw) {
1354
+ for (const line of panesRaw.split("\n").filter(Boolean)) {
1355
+ const [sess, title, cmd] = line.split("\t");
1356
+ if (!panesBySession.has(sess)) panesBySession.set(sess, []);
1357
+ panesBySession.get(sess).push({ title, cmd });
1358
+ }
1359
+ }
1360
+
1361
+ // Build managed session name set
1362
+ const managed = new Map(); // name -> label
1363
+
1364
+ // From workspace groups
1365
+ const ws = readWorkspaceConfig();
1366
+ if (ws?.groups) {
1367
+ for (const g of ws.groups) {
1368
+ for (const tab of g.tabs || []) {
1369
+ const name = toSessionName(resolve(tab.path));
1370
+ const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
1371
+ managed.set(name, label);
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ // From scanning .lattices.json files
1377
+ const scanRoot =
1378
+ process.env.LATTICE_SCAN_ROOT ||
1379
+ resolve(homedir(), "dev");
1380
+ const findResult = runQuiet(
1381
+ `find "${scanRoot}" -name .lattices.json -maxdepth 3 -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null`
1382
+ );
1383
+ if (findResult) {
1384
+ for (const configPath of findResult.split("\n").filter(Boolean)) {
1385
+ const dir = resolve(configPath, "..");
1386
+ const name = toSessionName(dir);
1387
+ if (!managed.has(name)) {
1388
+ managed.set(name, basename(dir));
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Parse sessions and classify
1394
+ const sessions = sessionsRaw.split("\n").filter(Boolean).map((line) => {
1395
+ const [name, windows, attached] = line.split("\t");
1396
+ return { name, windows: parseInt(windows) || 1, attached: attached !== "0" };
1397
+ });
1398
+
1399
+ const managedSessions = sessions.filter((s) => managed.has(s.name));
1400
+ const orphanSessions = sessions.filter((s) => !managed.has(s.name));
1401
+
1402
+ // Print managed
1403
+ if (managedSessions.length > 0) {
1404
+ console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
1405
+ for (const s of managedSessions) {
1406
+ const label = managed.get(s.name);
1407
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
1408
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
1409
+ const panes = panesBySession.get(s.name) || [];
1410
+ for (const p of panes) {
1411
+ const name = p.title || "pane";
1412
+ console.log(` ${name}: ${p.cmd}`);
1413
+ }
1414
+ console.log();
1415
+ }
1416
+ } else {
1417
+ console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
1418
+ }
1419
+
1420
+ // Print orphans
1421
+ if (orphanSessions.length > 0) {
1422
+ console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
1423
+ for (const s of orphanSessions) {
1424
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
1425
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag}`);
1426
+ const panes = panesBySession.get(s.name) || [];
1427
+ for (const p of panes) {
1428
+ const name = p.title || "pane";
1429
+ console.log(` ${name}: ${p.cmd}`);
1430
+ }
1431
+ console.log();
1432
+ }
1433
+ } else {
1434
+ console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
1435
+ }
1436
+ }
1437
+
1438
+ // ── Main ─────────────────────────────────────────────────────────────
1439
+
1440
+ if (!hasTmux()) {
1441
+ console.error("tmux is not installed. Install with: brew install tmux");
1442
+ process.exit(1);
1443
+ }
1444
+
1445
+ switch (command) {
1446
+ case "init":
1447
+ initConfig();
1448
+ break;
1449
+ case "ls":
1450
+ case "list":
1451
+ // Try daemon first, fall back to direct tmux
1452
+ if (!(await daemonLsCommand())) {
1453
+ listSessions();
1454
+ }
1455
+ break;
1456
+ case "kill":
1457
+ case "rm":
1458
+ killSession(args[1]);
1459
+ break;
1460
+ case "sync":
1461
+ case "reconcile":
1462
+ syncSession();
1463
+ break;
1464
+ case "restart":
1465
+ case "respawn":
1466
+ restartPane(args[1]);
1467
+ break;
1468
+ case "group":
1469
+ groupCommand(args[1]);
1470
+ break;
1471
+ case "groups":
1472
+ listGroups();
1473
+ break;
1474
+ case "tab":
1475
+ tabCommand(args[1], args[2]);
1476
+ break;
1477
+ case "status":
1478
+ case "inventory":
1479
+ // Try daemon first, fall back to direct tmux
1480
+ if (!(await daemonStatusInventory())) {
1481
+ statusInventory();
1482
+ }
1483
+ break;
1484
+ case "distribute":
1485
+ await distributeCommand();
1486
+ break;
1487
+ case "tile":
1488
+ case "t":
1489
+ if (args[1]) {
1490
+ tileWindow(args[1]);
1491
+ } else {
1492
+ console.log("Usage: lattices tile <position>\n");
1493
+ console.log("Positions: left, right, top, bottom, top-left, top-right,");
1494
+ console.log(" bottom-left, bottom-right, maximize, center,");
1495
+ console.log(" left-third, center-third, right-third");
1496
+ }
1497
+ break;
1498
+ case "windows":
1499
+ await windowsCommand(args[1] === "--json");
1500
+ break;
1501
+ case "window":
1502
+ if (args[1] === "assign") {
1503
+ await windowAssignCommand(args[2], args[3]);
1504
+ } else if (args[1] === "map") {
1505
+ await windowLayerMapCommand(args[2] === "--json");
1506
+ } else {
1507
+ console.log("Usage:");
1508
+ console.log(" lattices window assign <wid> <layer-id> Tag a window to a layer");
1509
+ console.log(" lattices window map [--json] Show all layer tags");
1510
+ }
1511
+ break;
1512
+ case "focus":
1513
+ await focusCommand(args[1]);
1514
+ break;
1515
+ case "layer":
1516
+ case "layers":
1517
+ await layerCommand(args[1]);
1518
+ break;
1519
+ case "diag":
1520
+ case "diagnostics":
1521
+ await diagCommand(args[1]);
1522
+ break;
1523
+ case "scan":
1524
+ case "ocr":
1525
+ await scanCommand(args[1], ...args.slice(2));
1526
+ break;
1527
+ case "daemon":
1528
+ if (args[1] === "status") {
1529
+ await daemonStatusCommand();
1530
+ } else {
1531
+ console.log("Usage: lattices daemon status");
1532
+ }
1533
+ break;
1534
+ case "app": {
1535
+ // Forward to lattices-app script
1536
+ const { execFileSync } = await import("node:child_process");
1537
+ const __dirname2 = dirname(fileURLToPath(import.meta.url));
1538
+ const appScript = resolve(__dirname2, "lattices-app.js");
1539
+ try {
1540
+ execFileSync("node", [appScript, ...args.slice(1)], { stdio: "inherit" });
1541
+ } catch { /* exit code forwarded */ }
1542
+ break;
1543
+ }
1544
+ case "-h":
1545
+ case "--help":
1546
+ case "help":
1547
+ printUsage();
1548
+ break;
1549
+ default:
1550
+ createOrAttach();
1551
+ }