@arach/lattices 0.1.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 (64) hide show
  1. package/README.md +157 -0
  2. package/app/Lattices.app/Contents/Info.plist +24 -0
  3. package/app/Package.swift +13 -0
  4. package/app/Sources/App.swift +49 -0
  5. package/app/Sources/AppDelegate.swift +104 -0
  6. package/app/Sources/AppShellView.swift +62 -0
  7. package/app/Sources/AppTypeClassifier.swift +70 -0
  8. package/app/Sources/AppWindowShell.swift +63 -0
  9. package/app/Sources/CheatSheetHUD.swift +331 -0
  10. package/app/Sources/CommandModeState.swift +1341 -0
  11. package/app/Sources/CommandModeView.swift +1380 -0
  12. package/app/Sources/CommandModeWindow.swift +192 -0
  13. package/app/Sources/CommandPaletteView.swift +307 -0
  14. package/app/Sources/CommandPaletteWindow.swift +134 -0
  15. package/app/Sources/DaemonProtocol.swift +101 -0
  16. package/app/Sources/DaemonServer.swift +406 -0
  17. package/app/Sources/DesktopModel.swift +121 -0
  18. package/app/Sources/DesktopModelTypes.swift +71 -0
  19. package/app/Sources/DiagnosticLog.swift +253 -0
  20. package/app/Sources/EventBus.swift +29 -0
  21. package/app/Sources/HotkeyManager.swift +249 -0
  22. package/app/Sources/HotkeyStore.swift +330 -0
  23. package/app/Sources/InventoryManager.swift +35 -0
  24. package/app/Sources/InventoryPath.swift +43 -0
  25. package/app/Sources/KeyRecorderView.swift +210 -0
  26. package/app/Sources/LatticesApi.swift +915 -0
  27. package/app/Sources/MainView.swift +507 -0
  28. package/app/Sources/MainWindow.swift +70 -0
  29. package/app/Sources/OrphanRow.swift +129 -0
  30. package/app/Sources/PaletteCommand.swift +409 -0
  31. package/app/Sources/PermissionChecker.swift +115 -0
  32. package/app/Sources/Preferences.swift +48 -0
  33. package/app/Sources/ProcessModel.swift +199 -0
  34. package/app/Sources/ProcessQuery.swift +151 -0
  35. package/app/Sources/Project.swift +28 -0
  36. package/app/Sources/ProjectRow.swift +368 -0
  37. package/app/Sources/ProjectScanner.swift +121 -0
  38. package/app/Sources/ScreenMapState.swift +2397 -0
  39. package/app/Sources/ScreenMapView.swift +2817 -0
  40. package/app/Sources/ScreenMapWindowController.swift +89 -0
  41. package/app/Sources/SessionManager.swift +72 -0
  42. package/app/Sources/SettingsView.swift +641 -0
  43. package/app/Sources/SettingsWindow.swift +20 -0
  44. package/app/Sources/TabGroupRow.swift +178 -0
  45. package/app/Sources/Terminal.swift +259 -0
  46. package/app/Sources/TerminalQuery.swift +156 -0
  47. package/app/Sources/TerminalSynthesizer.swift +200 -0
  48. package/app/Sources/Theme.swift +124 -0
  49. package/app/Sources/TilePickerView.swift +209 -0
  50. package/app/Sources/TmuxModel.swift +53 -0
  51. package/app/Sources/TmuxQuery.swift +81 -0
  52. package/app/Sources/WindowTiler.swift +1752 -0
  53. package/app/Sources/WorkspaceManager.swift +434 -0
  54. package/bin/daemon-client.js +187 -0
  55. package/bin/lattices-app.js +205 -0
  56. package/bin/lattices.js +1295 -0
  57. package/docs/api.md +707 -0
  58. package/docs/app.md +250 -0
  59. package/docs/concepts.md +225 -0
  60. package/docs/config.md +234 -0
  61. package/docs/layers.md +317 -0
  62. package/docs/overview.md +74 -0
  63. package/docs/quickstart.md +82 -0
  64. package/package.json +38 -0
@@ -0,0 +1,1295 @@
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 spaces = w.spaceIds.length ? ` space:${w.spaceIds.join(",")}` : "";
704
+ console.log(` \x1b[1m${w.app}\x1b[0m wid:${w.wid}${spaces}${session}`);
705
+ console.log(` "${w.title}"`);
706
+ console.log(` ${Math.round(w.frame.w)}×${Math.round(w.frame.h)} at (${Math.round(w.frame.x)},${Math.round(w.frame.y)})`);
707
+ console.log();
708
+ }
709
+ } catch {
710
+ console.log("Daemon not running. Start with: lattices app");
711
+ }
712
+ }
713
+
714
+ async function focusCommand(session) {
715
+ if (!session) {
716
+ console.log("Usage: lattices focus <session-name>");
717
+ return;
718
+ }
719
+ try {
720
+ const { daemonCall } = await getDaemonClient();
721
+ await daemonCall("window.focus", { session });
722
+ console.log(`Focused: ${session}`);
723
+ } catch (e) {
724
+ console.log(`Error: ${e.message}`);
725
+ }
726
+ }
727
+
728
+ async function layerCommand(index) {
729
+ try {
730
+ const { daemonCall } = await getDaemonClient();
731
+ if (index === undefined || index === null || index === "") {
732
+ const result = await daemonCall("layers.list");
733
+ if (!result.layers.length) {
734
+ console.log("No layers configured.");
735
+ return;
736
+ }
737
+ console.log("Layers:\n");
738
+ for (const layer of result.layers) {
739
+ const active = layer.index === result.active ? " \x1b[32m● active\x1b[0m" : "";
740
+ console.log(` [${layer.index}] ${layer.label} (${layer.projectCount} projects)${active}`);
741
+ }
742
+ return;
743
+ }
744
+ const idx = parseInt(index, 10);
745
+ if (isNaN(idx)) {
746
+ console.log("Usage: lattices layer <index>");
747
+ return;
748
+ }
749
+ await daemonCall("layer.switch", { index: idx });
750
+ console.log(`Switched to layer ${idx}`);
751
+ } catch (e) {
752
+ console.log(`Error: ${e.message}`);
753
+ }
754
+ }
755
+
756
+ async function distributeCommand() {
757
+ try {
758
+ const { daemonCall } = await getDaemonClient();
759
+ await daemonCall("layout.distribute");
760
+ console.log("Distributed visible windows into grid");
761
+ } catch {
762
+ console.log("Daemon not running. Start with: lattices app");
763
+ }
764
+ }
765
+
766
+ async function daemonLsCommand() {
767
+ try {
768
+ const { daemonCall, isDaemonRunning } = await getDaemonClient();
769
+ if (!(await isDaemonRunning())) return false;
770
+ const sessions = await daemonCall("tmux.sessions");
771
+ if (!sessions.length) {
772
+ console.log("No active tmux sessions.");
773
+ return true;
774
+ }
775
+
776
+ // Annotate sessions with workspace group info
777
+ const ws = readWorkspaceConfig();
778
+ const sessionGroupMap = new Map();
779
+ if (ws?.groups) {
780
+ for (const g of ws.groups) {
781
+ for (const tab of g.tabs || []) {
782
+ const tabSession = toSessionName(resolve(tab.path));
783
+ sessionGroupMap.set(tabSession, {
784
+ group: g.label || g.id,
785
+ tab: tab.label || basename(tab.path),
786
+ });
787
+ }
788
+ }
789
+ }
790
+
791
+ console.log("Sessions:\n");
792
+ for (const s of sessions) {
793
+ const info = sessionGroupMap.get(s.name);
794
+ const groupTag = info ? ` \x1b[36m[${info.group}: ${info.tab}]\x1b[0m` : "";
795
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
796
+ console.log(` ${s.name} (${s.windowCount} windows)${attachTag}${groupTag}`);
797
+ }
798
+ return true;
799
+ } catch {
800
+ return false;
801
+ }
802
+ }
803
+
804
+ async function daemonStatusInventory() {
805
+ try {
806
+ const { daemonCall, isDaemonRunning } = await getDaemonClient();
807
+ if (!(await isDaemonRunning())) return false;
808
+ const inv = await daemonCall("tmux.inventory");
809
+
810
+ // Build managed session name set
811
+ const managed = new Map();
812
+ const ws = readWorkspaceConfig();
813
+ if (ws?.groups) {
814
+ for (const g of ws.groups) {
815
+ for (const tab of g.tabs || []) {
816
+ const name = toSessionName(resolve(tab.path));
817
+ const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
818
+ managed.set(name, label);
819
+ }
820
+ }
821
+ }
822
+ for (const s of inv.all) {
823
+ if (!managed.has(s.name)) {
824
+ // Check if it matches a scanned project (via daemon)
825
+ const projects = await daemonCall("projects.list");
826
+ for (const p of projects) {
827
+ managed.set(p.sessionName, p.name);
828
+ }
829
+ break;
830
+ }
831
+ }
832
+
833
+ const managedSessions = inv.all.filter((s) => managed.has(s.name));
834
+ const orphanSessions = inv.orphans;
835
+
836
+ if (managedSessions.length > 0) {
837
+ console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
838
+ for (const s of managedSessions) {
839
+ const label = managed.get(s.name) || s.name;
840
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
841
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
842
+ for (const p of s.panes) {
843
+ console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
844
+ }
845
+ console.log();
846
+ }
847
+ } else {
848
+ console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
849
+ }
850
+
851
+ if (orphanSessions.length > 0) {
852
+ console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
853
+ for (const s of orphanSessions) {
854
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
855
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag}`);
856
+ for (const p of s.panes) {
857
+ console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
858
+ }
859
+ console.log();
860
+ }
861
+ } else {
862
+ console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
863
+ }
864
+ return true;
865
+ } catch {
866
+ return false;
867
+ }
868
+ }
869
+
870
+ function printUsage() {
871
+ console.log(`lattices — Claude Code + dev server in tmux
872
+
873
+ Usage:
874
+ lattices Create session (or reattach) for current project
875
+ lattices init Generate .lattices.json config for this project
876
+ lattices ls List active tmux sessions
877
+ lattices status Show managed vs unmanaged session inventory
878
+ lattices kill [name] Kill a session (defaults to current project)
879
+ lattices sync Reconcile session to match declared config
880
+ lattices restart [pane] Restart a pane's process (by name or index)
881
+ lattices group [id] List tab groups or launch/attach a group
882
+ lattices groups List all tab groups with status
883
+ lattices tab <group> [tab] Switch tab within a group (by label or index)
884
+ lattices windows [--json] List all desktop windows (daemon required)
885
+ lattices focus <session> Focus a session's terminal window (daemon required)
886
+ lattices tile <position> Tile the frontmost window (left, right, top, etc.)
887
+ lattices distribute Smart-grid all visible windows (daemon required)
888
+ lattices layer [index] List layers or switch to a layer (daemon required)
889
+ lattices daemon status Show daemon status
890
+ lattices app Launch the menu bar companion app
891
+ lattices app build Rebuild the menu bar app
892
+ lattices app restart Rebuild and relaunch the menu bar app
893
+ lattices app quit Stop the menu bar app
894
+ lattices help Show this help
895
+
896
+ Config (.lattices.json):
897
+ Place in your project root to customize the layout:
898
+
899
+ {
900
+ "ensure": true,
901
+ "panes": [
902
+ { "name": "claude", "cmd": "claude", "size": 60 },
903
+ { "name": "server", "cmd": "pnpm dev" },
904
+ { "name": "tests", "cmd": "pnpm test --watch" }
905
+ ]
906
+ }
907
+
908
+ size Width % for the first pane (default: 60)
909
+ cmd Command to run in the pane
910
+ name Label (for your reference)
911
+ ensure Auto-restart exited commands on reattach
912
+ prefill Type commands into idle panes on reattach (you hit Enter)
913
+
914
+ Recovery:
915
+ lattices sync Recreates missing panes, restores commands, fixes layout.
916
+ Use when a pane was killed and you want to get back to the
917
+ declared state without killing the whole session.
918
+
919
+ lattices restart Kills the process in a pane and re-runs its declared command.
920
+ Accepts a pane name or 0-based index (default: 0 / first pane).
921
+ Examples: lattices restart (restarts "claude")
922
+ lattices restart server (restarts "server" by name)
923
+ lattices restart 1 (restarts pane at index 1)
924
+
925
+ Layouts:
926
+ 1 pane → single full-width (default when no dev server detected)
927
+ 2 panes → side-by-side split
928
+ 3+ panes → main-vertical (first pane left, rest stacked right)
929
+
930
+ ┌────────────────────┐ ┌──────────┬─────────┐ ┌──────────┬─────────┐
931
+ │ claude │ │ claude │ server │ │ claude │ server │
932
+ │ │ │ (60%) │ (40%) │ │ (60%) ├─────────┤
933
+ └────────────────────┘ └──────────┴─────────┘ │ │ tests │
934
+ └──────────┴─────────┘
935
+ `);
936
+ }
937
+
938
+ function initConfig() {
939
+ const dir = process.cwd();
940
+ const configPath = resolve(dir, ".lattices.json");
941
+
942
+ if (existsSync(configPath)) {
943
+ console.log(".lattices.json already exists.");
944
+ return;
945
+ }
946
+
947
+ const panes = defaultPanes(dir);
948
+ const config = {
949
+ ensure: true,
950
+ panes,
951
+ };
952
+
953
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
954
+ console.log("Created .lattices.json");
955
+ console.log(JSON.stringify(config, null, 2));
956
+ }
957
+
958
+ function listSessions() {
959
+ const out = runQuiet(
960
+ "tmux list-sessions -F '#{session_name} (#{session_windows} windows, created #{session_created_string})'"
961
+ );
962
+ if (!out) {
963
+ console.log("No active tmux sessions.");
964
+ return;
965
+ }
966
+
967
+ // Annotate sessions that belong to tab groups
968
+ const ws = readWorkspaceConfig();
969
+ const sessionGroupMap = new Map();
970
+ if (ws?.groups) {
971
+ for (const g of ws.groups) {
972
+ for (const tab of g.tabs || []) {
973
+ const tabSession = toSessionName(resolve(tab.path));
974
+ sessionGroupMap.set(tabSession, {
975
+ group: g.label || g.id,
976
+ tab: tab.label || basename(tab.path),
977
+ });
978
+ }
979
+ }
980
+ }
981
+
982
+ const lines = out.split("\n").map((line) => {
983
+ const sessionName = line.split(" ")[0];
984
+ const info = sessionGroupMap.get(sessionName);
985
+ return info
986
+ ? `${line} \x1b[36m[${info.group}: ${info.tab}]\x1b[0m`
987
+ : line;
988
+ });
989
+
990
+ console.log("Sessions:\n");
991
+ console.log(lines.join("\n"));
992
+ }
993
+
994
+ function killSession(name) {
995
+ if (!name) name = toSessionName(process.cwd());
996
+ if (!sessionExists(name)) {
997
+ console.log(`No session "${name}".`);
998
+ return;
999
+ }
1000
+ run(`tmux kill-session -t "${name}"`);
1001
+ console.log(`Killed "${name}".`);
1002
+ }
1003
+
1004
+ // ── Window tiling ────────────────────────────────────────────────────
1005
+
1006
+ function getScreenBounds() {
1007
+ // Get the visible area (excludes menu bar and dock) in AppleScript coordinates (top-left origin)
1008
+ const script = `
1009
+ tell application "Finder"
1010
+ set db to bounds of window of desktop
1011
+ end tell
1012
+ -- db = {left, top, right, bottom} of usable desktop
1013
+ return (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)`;
1014
+ const out = runQuiet(`osascript -e '${esc(script)}'`);
1015
+ if (!out) return { x: 0, y: 25, w: 1920, h: 1055 };
1016
+ const [x, y, right, bottom] = out.split(",").map(s => parseInt(s.trim()));
1017
+ return { x, y, w: right - x, h: bottom - y };
1018
+ }
1019
+
1020
+ // Presets return AppleScript bounds: [left, top, right, bottom] within the visible area
1021
+ const tilePresets = {
1022
+ "left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
1023
+ "left-half": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
1024
+ "right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
1025
+ "right-half": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
1026
+ "top": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
1027
+ "top-half": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
1028
+ "bottom": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1029
+ "bottom-half": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1030
+ "top-left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h / 2],
1031
+ "top-right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h / 2],
1032
+ "bottom-left": (s) => [s.x, s.y + s.h / 2, s.x + s.w / 2, s.y + s.h],
1033
+ "bottom-right": (s) => [s.x + s.w / 2, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1034
+ "maximize": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
1035
+ "max": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
1036
+ "center": (s) => {
1037
+ const mw = Math.round(s.w * 0.7);
1038
+ const mh = Math.round(s.h * 0.8);
1039
+ const mx = s.x + Math.round((s.w - mw) / 2);
1040
+ const my = s.y + Math.round((s.h - mh) / 2);
1041
+ return [mx, my, mx + mw, my + mh];
1042
+ },
1043
+ "left-third": (s) => [s.x, s.y, s.x + Math.round(s.w * 0.333), s.y + s.h],
1044
+ "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],
1045
+ "right-third": (s) => [s.x + Math.round(s.w * 0.667), s.y, s.x + s.w, s.y + s.h],
1046
+ };
1047
+
1048
+ function tileWindow(position) {
1049
+ const preset = tilePresets[position];
1050
+ if (!preset) {
1051
+ console.log(`Unknown position: ${position}`);
1052
+ console.log(`Available: ${Object.keys(tilePresets).filter(k => !k.includes("-half") && k !== "max").join(", ")}`);
1053
+ return;
1054
+ }
1055
+ const screen = getScreenBounds();
1056
+ const [x1, y1, x2, y2] = preset(screen).map(Math.round);
1057
+ const script = `
1058
+ tell application "System Events"
1059
+ set frontApp to name of first application process whose frontmost is true
1060
+ end tell
1061
+ tell application frontApp
1062
+ set bounds of front window to {${x1}, ${y1}, ${x2}, ${y2}}
1063
+ end tell`;
1064
+ runQuiet(`osascript -e '${esc(script)}'`);
1065
+ console.log(`Tiled → ${position}`);
1066
+ }
1067
+
1068
+ function createOrAttach() {
1069
+ const dir = process.cwd();
1070
+ const name = toSessionName(dir);
1071
+
1072
+ if (sessionExists(name)) {
1073
+ console.log(`Reattaching to "${name}"...`);
1074
+ const config = readConfig(dir);
1075
+ if (config?.ensure) {
1076
+ restoreCommands(name, dir, "ensure");
1077
+ } else if (config?.prefill) {
1078
+ restoreCommands(name, dir, "prefill");
1079
+ }
1080
+ attach(name);
1081
+ return;
1082
+ }
1083
+
1084
+ console.log(`Creating "${name}"...`);
1085
+ createSession(dir);
1086
+ attach(name);
1087
+ }
1088
+
1089
+ function attach(name) {
1090
+ if (isInsideTmux()) {
1091
+ execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
1092
+ } else {
1093
+ execSync(`tmux attach -t "${name}"`, { stdio: "inherit" });
1094
+ }
1095
+ }
1096
+
1097
+ // ── Status / Inventory ───────────────────────────────────────────────
1098
+
1099
+ function statusInventory() {
1100
+ // Query all tmux sessions
1101
+ const sessionsRaw = runQuiet(
1102
+ 'tmux list-sessions -F "#{session_name}\t#{session_windows}\t#{session_attached}"'
1103
+ );
1104
+ if (!sessionsRaw) {
1105
+ console.log("No active tmux sessions.");
1106
+ return;
1107
+ }
1108
+
1109
+ // Query all panes
1110
+ const panesRaw = runQuiet(
1111
+ 'tmux list-panes -a -F "#{session_name}\t#{pane_title}\t#{pane_current_command}"'
1112
+ );
1113
+
1114
+ // Parse panes grouped by session
1115
+ const panesBySession = new Map();
1116
+ if (panesRaw) {
1117
+ for (const line of panesRaw.split("\n").filter(Boolean)) {
1118
+ const [sess, title, cmd] = line.split("\t");
1119
+ if (!panesBySession.has(sess)) panesBySession.set(sess, []);
1120
+ panesBySession.get(sess).push({ title, cmd });
1121
+ }
1122
+ }
1123
+
1124
+ // Build managed session name set
1125
+ const managed = new Map(); // name -> label
1126
+
1127
+ // From workspace groups
1128
+ const ws = readWorkspaceConfig();
1129
+ if (ws?.groups) {
1130
+ for (const g of ws.groups) {
1131
+ for (const tab of g.tabs || []) {
1132
+ const name = toSessionName(resolve(tab.path));
1133
+ const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
1134
+ managed.set(name, label);
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ // From scanning .lattices.json files
1140
+ const scanRoot =
1141
+ process.env.LATTICE_SCAN_ROOT ||
1142
+ resolve(homedir(), "dev");
1143
+ const findResult = runQuiet(
1144
+ `find "${scanRoot}" -name .lattices.json -maxdepth 3 -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null`
1145
+ );
1146
+ if (findResult) {
1147
+ for (const configPath of findResult.split("\n").filter(Boolean)) {
1148
+ const dir = resolve(configPath, "..");
1149
+ const name = toSessionName(dir);
1150
+ if (!managed.has(name)) {
1151
+ managed.set(name, basename(dir));
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ // Parse sessions and classify
1157
+ const sessions = sessionsRaw.split("\n").filter(Boolean).map((line) => {
1158
+ const [name, windows, attached] = line.split("\t");
1159
+ return { name, windows: parseInt(windows) || 1, attached: attached !== "0" };
1160
+ });
1161
+
1162
+ const managedSessions = sessions.filter((s) => managed.has(s.name));
1163
+ const orphanSessions = sessions.filter((s) => !managed.has(s.name));
1164
+
1165
+ // Print managed
1166
+ if (managedSessions.length > 0) {
1167
+ console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
1168
+ for (const s of managedSessions) {
1169
+ const label = managed.get(s.name);
1170
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
1171
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
1172
+ const panes = panesBySession.get(s.name) || [];
1173
+ for (const p of panes) {
1174
+ const name = p.title || "pane";
1175
+ console.log(` ${name}: ${p.cmd}`);
1176
+ }
1177
+ console.log();
1178
+ }
1179
+ } else {
1180
+ console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
1181
+ }
1182
+
1183
+ // Print orphans
1184
+ if (orphanSessions.length > 0) {
1185
+ console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
1186
+ for (const s of orphanSessions) {
1187
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
1188
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag}`);
1189
+ const panes = panesBySession.get(s.name) || [];
1190
+ for (const p of panes) {
1191
+ const name = p.title || "pane";
1192
+ console.log(` ${name}: ${p.cmd}`);
1193
+ }
1194
+ console.log();
1195
+ }
1196
+ } else {
1197
+ console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
1198
+ }
1199
+ }
1200
+
1201
+ // ── Main ─────────────────────────────────────────────────────────────
1202
+
1203
+ if (!hasTmux()) {
1204
+ console.error("tmux is not installed. Install with: brew install tmux");
1205
+ process.exit(1);
1206
+ }
1207
+
1208
+ switch (command) {
1209
+ case "init":
1210
+ initConfig();
1211
+ break;
1212
+ case "ls":
1213
+ case "list":
1214
+ // Try daemon first, fall back to direct tmux
1215
+ if (!(await daemonLsCommand())) {
1216
+ listSessions();
1217
+ }
1218
+ break;
1219
+ case "kill":
1220
+ case "rm":
1221
+ killSession(args[1]);
1222
+ break;
1223
+ case "sync":
1224
+ case "reconcile":
1225
+ syncSession();
1226
+ break;
1227
+ case "restart":
1228
+ case "respawn":
1229
+ restartPane(args[1]);
1230
+ break;
1231
+ case "group":
1232
+ groupCommand(args[1]);
1233
+ break;
1234
+ case "groups":
1235
+ listGroups();
1236
+ break;
1237
+ case "tab":
1238
+ tabCommand(args[1], args[2]);
1239
+ break;
1240
+ case "status":
1241
+ case "inventory":
1242
+ // Try daemon first, fall back to direct tmux
1243
+ if (!(await daemonStatusInventory())) {
1244
+ statusInventory();
1245
+ }
1246
+ break;
1247
+ case "distribute":
1248
+ await distributeCommand();
1249
+ break;
1250
+ case "tile":
1251
+ case "t":
1252
+ if (args[1]) {
1253
+ tileWindow(args[1]);
1254
+ } else {
1255
+ console.log("Usage: lattices tile <position>\n");
1256
+ console.log("Positions: left, right, top, bottom, top-left, top-right,");
1257
+ console.log(" bottom-left, bottom-right, maximize, center,");
1258
+ console.log(" left-third, center-third, right-third");
1259
+ }
1260
+ break;
1261
+ case "windows":
1262
+ await windowsCommand(args[1] === "--json");
1263
+ break;
1264
+ case "focus":
1265
+ await focusCommand(args[1]);
1266
+ break;
1267
+ case "layer":
1268
+ case "layers":
1269
+ await layerCommand(args[1]);
1270
+ break;
1271
+ case "daemon":
1272
+ if (args[1] === "status") {
1273
+ await daemonStatusCommand();
1274
+ } else {
1275
+ console.log("Usage: lattices daemon status");
1276
+ }
1277
+ break;
1278
+ case "app": {
1279
+ // Forward to lattices-app script
1280
+ const { execFileSync } = await import("node:child_process");
1281
+ const __dirname2 = dirname(fileURLToPath(import.meta.url));
1282
+ const appScript = resolve(__dirname2, "lattices-app.js");
1283
+ try {
1284
+ execFileSync("node", [appScript, ...args.slice(1)], { stdio: "inherit" });
1285
+ } catch { /* exit code forwarded */ }
1286
+ break;
1287
+ }
1288
+ case "-h":
1289
+ case "--help":
1290
+ case "help":
1291
+ printUsage();
1292
+ break;
1293
+ default:
1294
+ createOrAttach();
1295
+ }