@arach/lattices 0.2.0 → 0.6.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -86
  3. package/apps/mac/Info.plist +43 -0
  4. package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
  5. package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
  6. package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  7. package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
  8. package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
  9. package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
  10. package/apps/mac/Lattices.entitlements +21 -0
  11. package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
  12. package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
  13. package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
  14. package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
  15. package/apps/mac/Resources/tap.wav +0 -0
  16. package/assets/AppIcon.icns +0 -0
  17. package/bin/assistant-intelligence.ts +912 -0
  18. package/bin/cli/capture.ts +252 -0
  19. package/bin/cli/daemon.ts +22 -0
  20. package/bin/cli/helpers.ts +105 -0
  21. package/bin/cli/layer.ts +178 -0
  22. package/bin/cli/runs.ts +43 -0
  23. package/bin/cli/search.ts +141 -0
  24. package/bin/cli/session.ts +32 -0
  25. package/bin/client.ts +17 -0
  26. package/bin/cua.ts +26 -0
  27. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  28. package/bin/handsoff-infer.ts +96 -0
  29. package/bin/handsoff-worker.ts +531 -0
  30. package/bin/infer.ts +424 -0
  31. package/bin/keychain.ts +75 -0
  32. package/bin/lattices-app.ts +655 -0
  33. package/bin/lattices-build +125 -0
  34. package/bin/lattices-build-env.ts +77 -0
  35. package/bin/lattices-dev +362 -0
  36. package/bin/lattices.ts +3260 -0
  37. package/bin/project-twin.ts +645 -0
  38. package/docs/agent-execution-plan.md +562 -0
  39. package/docs/agent-layer-guide.md +207 -0
  40. package/docs/agents.md +233 -0
  41. package/docs/ai-chat-ux-review.md +416 -0
  42. package/docs/api.md +1041 -47
  43. package/docs/app.md +96 -13
  44. package/docs/assistant-knowledge.md +130 -0
  45. package/docs/companion-deck.md +209 -0
  46. package/docs/component-extraction-roadmap.md +392 -0
  47. package/docs/concepts.md +13 -12
  48. package/docs/config.md +83 -10
  49. package/docs/gesture-customization-proposal.md +520 -0
  50. package/docs/handsoff-test-scenarios.md +84 -0
  51. package/docs/hyperspace-grid-snappiness.md +210 -0
  52. package/docs/layers.md +176 -28
  53. package/docs/mouse-gestures.md +244 -0
  54. package/docs/ocr.md +21 -9
  55. package/docs/overview.md +42 -23
  56. package/docs/presentation-execution-review.md +491 -0
  57. package/docs/prompts/hands-off-system.md +382 -0
  58. package/docs/prompts/hands-off-turn.md +30 -0
  59. package/docs/prompts/voice-advisor.md +31 -0
  60. package/docs/prompts/voice-fallback.md +23 -0
  61. package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
  62. package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
  63. package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
  64. package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
  65. package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
  66. package/docs/proposals/LAT-006-followup-gaps.md +103 -0
  67. package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
  68. package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
  69. package/docs/quickstart.md +8 -12
  70. package/docs/reference/dewey.config.ts +74 -0
  71. package/docs/reference/install-agent.md +79 -0
  72. package/docs/release.md +172 -0
  73. package/docs/repo-structure.md +100 -0
  74. package/docs/terminal-kit.md +87 -0
  75. package/docs/tiling-reference.md +224 -0
  76. package/docs/twins.md +138 -0
  77. package/docs/voice-command-protocol.md +278 -0
  78. package/docs/voice-error-model.md +73 -0
  79. package/docs/voice.md +221 -0
  80. package/package.json +69 -16
  81. package/packages/npm/sdk/cua.d.mts +1 -0
  82. package/packages/npm/sdk/cua.d.ts +188 -0
  83. package/packages/npm/sdk/cua.mjs +376 -0
  84. package/app/Lattices.app/Contents/Info.plist +0 -24
  85. package/app/Package.swift +0 -13
  86. package/app/Sources/ActionRow.swift +0 -61
  87. package/app/Sources/App.swift +0 -10
  88. package/app/Sources/AppDelegate.swift +0 -234
  89. package/app/Sources/AppShellView.swift +0 -62
  90. package/app/Sources/AppTypeClassifier.swift +0 -70
  91. package/app/Sources/AppWindowShell.swift +0 -63
  92. package/app/Sources/CheatSheetHUD.swift +0 -332
  93. package/app/Sources/CommandModeState.swift +0 -1362
  94. package/app/Sources/CommandModeView.swift +0 -1405
  95. package/app/Sources/CommandModeWindow.swift +0 -192
  96. package/app/Sources/CommandPaletteView.swift +0 -307
  97. package/app/Sources/CommandPaletteWindow.swift +0 -134
  98. package/app/Sources/DaemonProtocol.swift +0 -101
  99. package/app/Sources/DaemonServer.swift +0 -414
  100. package/app/Sources/DesktopModel.swift +0 -121
  101. package/app/Sources/DesktopModelTypes.swift +0 -71
  102. package/app/Sources/DiagnosticLog.swift +0 -271
  103. package/app/Sources/EventBus.swift +0 -30
  104. package/app/Sources/HotkeyManager.swift +0 -250
  105. package/app/Sources/HotkeyStore.swift +0 -338
  106. package/app/Sources/InventoryManager.swift +0 -35
  107. package/app/Sources/InventoryPath.swift +0 -43
  108. package/app/Sources/KeyRecorderView.swift +0 -210
  109. package/app/Sources/LatticesApi.swift +0 -1125
  110. package/app/Sources/MainView.swift +0 -467
  111. package/app/Sources/MainWindow.swift +0 -83
  112. package/app/Sources/OcrModel.swift +0 -309
  113. package/app/Sources/OcrStore.swift +0 -295
  114. package/app/Sources/OmniSearchState.swift +0 -283
  115. package/app/Sources/OmniSearchView.swift +0 -288
  116. package/app/Sources/OmniSearchWindow.swift +0 -105
  117. package/app/Sources/OrphanRow.swift +0 -129
  118. package/app/Sources/PaletteCommand.swift +0 -419
  119. package/app/Sources/PermissionChecker.swift +0 -125
  120. package/app/Sources/Preferences.swift +0 -92
  121. package/app/Sources/ProcessModel.swift +0 -199
  122. package/app/Sources/ProcessQuery.swift +0 -151
  123. package/app/Sources/Project.swift +0 -28
  124. package/app/Sources/ProjectRow.swift +0 -368
  125. package/app/Sources/ProjectScanner.swift +0 -121
  126. package/app/Sources/ScreenMapState.swift +0 -2387
  127. package/app/Sources/ScreenMapView.swift +0 -2820
  128. package/app/Sources/ScreenMapWindowController.swift +0 -89
  129. package/app/Sources/SessionManager.swift +0 -72
  130. package/app/Sources/SettingsView.swift +0 -1053
  131. package/app/Sources/SettingsWindow.swift +0 -20
  132. package/app/Sources/TabGroupRow.swift +0 -178
  133. package/app/Sources/Terminal.swift +0 -259
  134. package/app/Sources/TerminalQuery.swift +0 -156
  135. package/app/Sources/TerminalSynthesizer.swift +0 -200
  136. package/app/Sources/Theme.swift +0 -163
  137. package/app/Sources/TilePickerView.swift +0 -209
  138. package/app/Sources/TmuxModel.swift +0 -53
  139. package/app/Sources/TmuxQuery.swift +0 -81
  140. package/app/Sources/WindowTiler.swift +0 -1755
  141. package/app/Sources/WorkspaceManager.swift +0 -434
  142. package/bin/lattices-app.js +0 -221
  143. package/bin/lattices.js +0 -1418
package/bin/lattices.js DELETED
@@ -1,1418 +0,0 @@
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
- // ── OCR commands ──────────────────────────────────────────────────────
871
-
872
- async function ocrCommand(sub, ...rest) {
873
- const { daemonCall } = await getDaemonClient();
874
-
875
- if (!sub || sub === "snapshot" || sub === "ls") {
876
- // Default: show latest OCR snapshot
877
- try {
878
- const results = await daemonCall("ocr.snapshot", null, 5000);
879
- if (!results.length) {
880
- console.log("No OCR results yet. The first scan runs ~60s after launch.");
881
- return;
882
- }
883
- console.log(`\x1b[1mOCR Snapshot\x1b[0m (${results.length} windows)\n`);
884
- for (const r of results) {
885
- const age = Math.round((Date.now() / 1000) - r.timestamp);
886
- const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
887
- const lines = (r.fullText || "").split("\n").filter(Boolean);
888
- const preview = lines.slice(0, 3).map(l => l.length > 80 ? l.slice(0, 77) + "..." : l);
889
- console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid} \x1b[90m${ageStr}\x1b[0m`);
890
- console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
891
- if (preview.length) {
892
- for (const line of preview) {
893
- console.log(` \x1b[90m${line}\x1b[0m`);
894
- }
895
- if (lines.length > 3) {
896
- console.log(` \x1b[90m… ${lines.length - 3} more lines\x1b[0m`);
897
- }
898
- } else {
899
- console.log(` \x1b[90m(no text detected)\x1b[0m`);
900
- }
901
- console.log();
902
- }
903
- } catch {
904
- console.log("Daemon not running. Start with: lattices app");
905
- }
906
- return;
907
- }
908
-
909
- if (sub === "search") {
910
- const query = rest.join(" ");
911
- if (!query) {
912
- console.log("Usage: lattices ocr search <query>");
913
- return;
914
- }
915
- try {
916
- const results = await daemonCall("ocr.search", { query }, 5000);
917
- if (!results.length) {
918
- console.log(`No OCR matches for "${query}".`);
919
- return;
920
- }
921
- console.log(`\x1b[1mOCR Search\x1b[0m "${query}" (${results.length} matches)\n`);
922
- for (const r of results) {
923
- const snippet = r.snippet || r.fullText?.slice(0, 120) || "";
924
- console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
925
- console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
926
- console.log(` ${snippet}`);
927
- console.log();
928
- }
929
- } catch (e) {
930
- console.log(`Error: ${e.message}`);
931
- }
932
- return;
933
- }
934
-
935
- if (sub === "scan") {
936
- try {
937
- console.log("Triggering OCR scan...");
938
- await daemonCall("ocr.scan", null, 30000);
939
- console.log("Scan complete.");
940
- } catch (e) {
941
- console.log(`Error: ${e.message}`);
942
- }
943
- return;
944
- }
945
-
946
- if (sub === "history") {
947
- const wid = parseInt(rest[0], 10);
948
- if (isNaN(wid)) {
949
- console.log("Usage: lattices ocr history <wid>");
950
- return;
951
- }
952
- try {
953
- const results = await daemonCall("ocr.history", { wid }, 5000);
954
- if (!results.length) {
955
- console.log(`No OCR history for wid:${wid}.`);
956
- return;
957
- }
958
- console.log(`\x1b[1mOCR History\x1b[0m wid:${wid} (${results.length} entries)\n`);
959
- for (const r of results) {
960
- const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
961
- const lines = (r.fullText || "").split("\n").filter(Boolean);
962
- const preview = lines.slice(0, 2).map(l => l.length > 80 ? l.slice(0, 77) + "..." : l);
963
- console.log(` \x1b[90m${ts}\x1b[0m \x1b[1m${r.app}\x1b[0m — "${r.title}"`);
964
- for (const line of preview) {
965
- console.log(` \x1b[90m${line}\x1b[0m`);
966
- }
967
- console.log();
968
- }
969
- } catch (e) {
970
- console.log(`Error: ${e.message}`);
971
- }
972
- return;
973
- }
974
-
975
- // Unknown subcommand
976
- console.log(`lattices ocr — Screen text recognition
977
-
978
- Usage:
979
- lattices ocr Show latest OCR snapshot (all windows)
980
- lattices ocr search <q> Full-text search across all scanned windows
981
- lattices ocr scan Trigger an immediate scan
982
- lattices ocr history <id> Show OCR timeline for a window (by wid)
983
- `);
984
- }
985
-
986
- function printUsage() {
987
- console.log(`lattices — Claude Code + dev server in tmux
988
-
989
- Usage:
990
- lattices Create session (or reattach) for current project
991
- lattices init Generate .lattices.json config for this project
992
- lattices ls List active tmux sessions
993
- lattices status Show managed vs unmanaged session inventory
994
- lattices kill [name] Kill a session (defaults to current project)
995
- lattices sync Reconcile session to match declared config
996
- lattices restart [pane] Restart a pane's process (by name or index)
997
- lattices group [id] List tab groups or launch/attach a group
998
- lattices groups List all tab groups with status
999
- lattices tab <group> [tab] Switch tab within a group (by label or index)
1000
- lattices windows [--json] List all desktop windows (daemon required)
1001
- lattices focus <session> Focus a session's terminal window (daemon required)
1002
- lattices tile <position> Tile the frontmost window (left, right, top, etc.)
1003
- lattices distribute Smart-grid all visible windows (daemon required)
1004
- lattices layer [index] List layers or switch to a layer (daemon required)
1005
- lattices ocr Show latest OCR snapshot (all windows)
1006
- lattices ocr search <q> Full-text search screen text
1007
- lattices ocr scan Trigger an immediate scan
1008
- lattices ocr history <wid> OCR timeline for a specific window
1009
- lattices daemon status Show daemon status
1010
- lattices app Launch the menu bar companion app
1011
- lattices app build Rebuild the menu bar app
1012
- lattices app restart Rebuild and relaunch the menu bar app
1013
- lattices app quit Stop the menu bar app
1014
- lattices help Show this help
1015
-
1016
- Config (.lattices.json):
1017
- Place in your project root to customize the layout:
1018
-
1019
- {
1020
- "ensure": true,
1021
- "panes": [
1022
- { "name": "claude", "cmd": "claude", "size": 60 },
1023
- { "name": "server", "cmd": "pnpm dev" },
1024
- { "name": "tests", "cmd": "pnpm test --watch" }
1025
- ]
1026
- }
1027
-
1028
- size Width % for the first pane (default: 60)
1029
- cmd Command to run in the pane
1030
- name Label (for your reference)
1031
- ensure Auto-restart exited commands on reattach
1032
- prefill Type commands into idle panes on reattach (you hit Enter)
1033
-
1034
- Recovery:
1035
- lattices sync Recreates missing panes, restores commands, fixes layout.
1036
- Use when a pane was killed and you want to get back to the
1037
- declared state without killing the whole session.
1038
-
1039
- lattices restart Kills the process in a pane and re-runs its declared command.
1040
- Accepts a pane name or 0-based index (default: 0 / first pane).
1041
- Examples: lattices restart (restarts "claude")
1042
- lattices restart server (restarts "server" by name)
1043
- lattices restart 1 (restarts pane at index 1)
1044
-
1045
- Layouts:
1046
- 1 pane → single full-width (default when no dev server detected)
1047
- 2 panes → side-by-side split
1048
- 3+ panes → main-vertical (first pane left, rest stacked right)
1049
-
1050
- ┌────────────────────┐ ┌──────────┬─────────┐ ┌──────────┬─────────┐
1051
- │ claude │ │ claude │ server │ │ claude │ server │
1052
- │ │ │ (60%) │ (40%) │ │ (60%) ├─────────┤
1053
- └────────────────────┘ └──────────┴─────────┘ │ │ tests │
1054
- └──────────┴─────────┘
1055
- `);
1056
- }
1057
-
1058
- function initConfig() {
1059
- const dir = process.cwd();
1060
- const configPath = resolve(dir, ".lattices.json");
1061
-
1062
- if (existsSync(configPath)) {
1063
- console.log(".lattices.json already exists.");
1064
- return;
1065
- }
1066
-
1067
- const panes = defaultPanes(dir);
1068
- const config = {
1069
- ensure: true,
1070
- panes,
1071
- };
1072
-
1073
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
1074
- console.log("Created .lattices.json");
1075
- console.log(JSON.stringify(config, null, 2));
1076
- }
1077
-
1078
- function listSessions() {
1079
- const out = runQuiet(
1080
- "tmux list-sessions -F '#{session_name} (#{session_windows} windows, created #{session_created_string})'"
1081
- );
1082
- if (!out) {
1083
- console.log("No active tmux sessions.");
1084
- return;
1085
- }
1086
-
1087
- // Annotate sessions that belong to tab groups
1088
- const ws = readWorkspaceConfig();
1089
- const sessionGroupMap = new Map();
1090
- if (ws?.groups) {
1091
- for (const g of ws.groups) {
1092
- for (const tab of g.tabs || []) {
1093
- const tabSession = toSessionName(resolve(tab.path));
1094
- sessionGroupMap.set(tabSession, {
1095
- group: g.label || g.id,
1096
- tab: tab.label || basename(tab.path),
1097
- });
1098
- }
1099
- }
1100
- }
1101
-
1102
- const lines = out.split("\n").map((line) => {
1103
- const sessionName = line.split(" ")[0];
1104
- const info = sessionGroupMap.get(sessionName);
1105
- return info
1106
- ? `${line} \x1b[36m[${info.group}: ${info.tab}]\x1b[0m`
1107
- : line;
1108
- });
1109
-
1110
- console.log("Sessions:\n");
1111
- console.log(lines.join("\n"));
1112
- }
1113
-
1114
- function killSession(name) {
1115
- if (!name) name = toSessionName(process.cwd());
1116
- if (!sessionExists(name)) {
1117
- console.log(`No session "${name}".`);
1118
- return;
1119
- }
1120
- run(`tmux kill-session -t "${name}"`);
1121
- console.log(`Killed "${name}".`);
1122
- }
1123
-
1124
- // ── Window tiling ────────────────────────────────────────────────────
1125
-
1126
- function getScreenBounds() {
1127
- // Get the visible area (excludes menu bar and dock) in AppleScript coordinates (top-left origin)
1128
- const script = `
1129
- tell application "Finder"
1130
- set db to bounds of window of desktop
1131
- end tell
1132
- -- db = {left, top, right, bottom} of usable desktop
1133
- return (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)`;
1134
- const out = runQuiet(`osascript -e '${esc(script)}'`);
1135
- if (!out) return { x: 0, y: 25, w: 1920, h: 1055 };
1136
- const [x, y, right, bottom] = out.split(",").map(s => parseInt(s.trim()));
1137
- return { x, y, w: right - x, h: bottom - y };
1138
- }
1139
-
1140
- // Presets return AppleScript bounds: [left, top, right, bottom] within the visible area
1141
- const tilePresets = {
1142
- "left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
1143
- "left-half": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
1144
- "right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
1145
- "right-half": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
1146
- "top": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
1147
- "top-half": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
1148
- "bottom": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1149
- "bottom-half": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1150
- "top-left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h / 2],
1151
- "top-right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h / 2],
1152
- "bottom-left": (s) => [s.x, s.y + s.h / 2, s.x + s.w / 2, s.y + s.h],
1153
- "bottom-right": (s) => [s.x + s.w / 2, s.y + s.h / 2, s.x + s.w, s.y + s.h],
1154
- "maximize": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
1155
- "max": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
1156
- "center": (s) => {
1157
- const mw = Math.round(s.w * 0.7);
1158
- const mh = Math.round(s.h * 0.8);
1159
- const mx = s.x + Math.round((s.w - mw) / 2);
1160
- const my = s.y + Math.round((s.h - mh) / 2);
1161
- return [mx, my, mx + mw, my + mh];
1162
- },
1163
- "left-third": (s) => [s.x, s.y, s.x + Math.round(s.w * 0.333), s.y + s.h],
1164
- "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],
1165
- "right-third": (s) => [s.x + Math.round(s.w * 0.667), s.y, s.x + s.w, s.y + s.h],
1166
- };
1167
-
1168
- function tileWindow(position) {
1169
- const preset = tilePresets[position];
1170
- if (!preset) {
1171
- console.log(`Unknown position: ${position}`);
1172
- console.log(`Available: ${Object.keys(tilePresets).filter(k => !k.includes("-half") && k !== "max").join(", ")}`);
1173
- return;
1174
- }
1175
- const screen = getScreenBounds();
1176
- const [x1, y1, x2, y2] = preset(screen).map(Math.round);
1177
- const script = `
1178
- tell application "System Events"
1179
- set frontApp to name of first application process whose frontmost is true
1180
- end tell
1181
- tell application frontApp
1182
- set bounds of front window to {${x1}, ${y1}, ${x2}, ${y2}}
1183
- end tell`;
1184
- runQuiet(`osascript -e '${esc(script)}'`);
1185
- console.log(`Tiled → ${position}`);
1186
- }
1187
-
1188
- function createOrAttach() {
1189
- const dir = process.cwd();
1190
- const name = toSessionName(dir);
1191
-
1192
- if (sessionExists(name)) {
1193
- console.log(`Reattaching to "${name}"...`);
1194
- const config = readConfig(dir);
1195
- if (config?.ensure) {
1196
- restoreCommands(name, dir, "ensure");
1197
- } else if (config?.prefill) {
1198
- restoreCommands(name, dir, "prefill");
1199
- }
1200
- attach(name);
1201
- return;
1202
- }
1203
-
1204
- console.log(`Creating "${name}"...`);
1205
- createSession(dir);
1206
- attach(name);
1207
- }
1208
-
1209
- function attach(name) {
1210
- if (isInsideTmux()) {
1211
- execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
1212
- } else {
1213
- execSync(`tmux attach -t "${name}"`, { stdio: "inherit" });
1214
- }
1215
- }
1216
-
1217
- // ── Status / Inventory ───────────────────────────────────────────────
1218
-
1219
- function statusInventory() {
1220
- // Query all tmux sessions
1221
- const sessionsRaw = runQuiet(
1222
- 'tmux list-sessions -F "#{session_name}\t#{session_windows}\t#{session_attached}"'
1223
- );
1224
- if (!sessionsRaw) {
1225
- console.log("No active tmux sessions.");
1226
- return;
1227
- }
1228
-
1229
- // Query all panes
1230
- const panesRaw = runQuiet(
1231
- 'tmux list-panes -a -F "#{session_name}\t#{pane_title}\t#{pane_current_command}"'
1232
- );
1233
-
1234
- // Parse panes grouped by session
1235
- const panesBySession = new Map();
1236
- if (panesRaw) {
1237
- for (const line of panesRaw.split("\n").filter(Boolean)) {
1238
- const [sess, title, cmd] = line.split("\t");
1239
- if (!panesBySession.has(sess)) panesBySession.set(sess, []);
1240
- panesBySession.get(sess).push({ title, cmd });
1241
- }
1242
- }
1243
-
1244
- // Build managed session name set
1245
- const managed = new Map(); // name -> label
1246
-
1247
- // From workspace groups
1248
- const ws = readWorkspaceConfig();
1249
- if (ws?.groups) {
1250
- for (const g of ws.groups) {
1251
- for (const tab of g.tabs || []) {
1252
- const name = toSessionName(resolve(tab.path));
1253
- const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
1254
- managed.set(name, label);
1255
- }
1256
- }
1257
- }
1258
-
1259
- // From scanning .lattices.json files
1260
- const scanRoot =
1261
- process.env.LATTICE_SCAN_ROOT ||
1262
- resolve(homedir(), "dev");
1263
- const findResult = runQuiet(
1264
- `find "${scanRoot}" -name .lattices.json -maxdepth 3 -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null`
1265
- );
1266
- if (findResult) {
1267
- for (const configPath of findResult.split("\n").filter(Boolean)) {
1268
- const dir = resolve(configPath, "..");
1269
- const name = toSessionName(dir);
1270
- if (!managed.has(name)) {
1271
- managed.set(name, basename(dir));
1272
- }
1273
- }
1274
- }
1275
-
1276
- // Parse sessions and classify
1277
- const sessions = sessionsRaw.split("\n").filter(Boolean).map((line) => {
1278
- const [name, windows, attached] = line.split("\t");
1279
- return { name, windows: parseInt(windows) || 1, attached: attached !== "0" };
1280
- });
1281
-
1282
- const managedSessions = sessions.filter((s) => managed.has(s.name));
1283
- const orphanSessions = sessions.filter((s) => !managed.has(s.name));
1284
-
1285
- // Print managed
1286
- if (managedSessions.length > 0) {
1287
- console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
1288
- for (const s of managedSessions) {
1289
- const label = managed.get(s.name);
1290
- const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
1291
- console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
1292
- const panes = panesBySession.get(s.name) || [];
1293
- for (const p of panes) {
1294
- const name = p.title || "pane";
1295
- console.log(` ${name}: ${p.cmd}`);
1296
- }
1297
- console.log();
1298
- }
1299
- } else {
1300
- console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
1301
- }
1302
-
1303
- // Print orphans
1304
- if (orphanSessions.length > 0) {
1305
- console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
1306
- for (const s of orphanSessions) {
1307
- const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
1308
- console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag}`);
1309
- const panes = panesBySession.get(s.name) || [];
1310
- for (const p of panes) {
1311
- const name = p.title || "pane";
1312
- console.log(` ${name}: ${p.cmd}`);
1313
- }
1314
- console.log();
1315
- }
1316
- } else {
1317
- console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
1318
- }
1319
- }
1320
-
1321
- // ── Main ─────────────────────────────────────────────────────────────
1322
-
1323
- if (!hasTmux()) {
1324
- console.error("tmux is not installed. Install with: brew install tmux");
1325
- process.exit(1);
1326
- }
1327
-
1328
- switch (command) {
1329
- case "init":
1330
- initConfig();
1331
- break;
1332
- case "ls":
1333
- case "list":
1334
- // Try daemon first, fall back to direct tmux
1335
- if (!(await daemonLsCommand())) {
1336
- listSessions();
1337
- }
1338
- break;
1339
- case "kill":
1340
- case "rm":
1341
- killSession(args[1]);
1342
- break;
1343
- case "sync":
1344
- case "reconcile":
1345
- syncSession();
1346
- break;
1347
- case "restart":
1348
- case "respawn":
1349
- restartPane(args[1]);
1350
- break;
1351
- case "group":
1352
- groupCommand(args[1]);
1353
- break;
1354
- case "groups":
1355
- listGroups();
1356
- break;
1357
- case "tab":
1358
- tabCommand(args[1], args[2]);
1359
- break;
1360
- case "status":
1361
- case "inventory":
1362
- // Try daemon first, fall back to direct tmux
1363
- if (!(await daemonStatusInventory())) {
1364
- statusInventory();
1365
- }
1366
- break;
1367
- case "distribute":
1368
- await distributeCommand();
1369
- break;
1370
- case "tile":
1371
- case "t":
1372
- if (args[1]) {
1373
- tileWindow(args[1]);
1374
- } else {
1375
- console.log("Usage: lattices tile <position>\n");
1376
- console.log("Positions: left, right, top, bottom, top-left, top-right,");
1377
- console.log(" bottom-left, bottom-right, maximize, center,");
1378
- console.log(" left-third, center-third, right-third");
1379
- }
1380
- break;
1381
- case "windows":
1382
- await windowsCommand(args[1] === "--json");
1383
- break;
1384
- case "focus":
1385
- await focusCommand(args[1]);
1386
- break;
1387
- case "layer":
1388
- case "layers":
1389
- await layerCommand(args[1]);
1390
- break;
1391
- case "ocr":
1392
- await ocrCommand(args[1], ...args.slice(2));
1393
- break;
1394
- case "daemon":
1395
- if (args[1] === "status") {
1396
- await daemonStatusCommand();
1397
- } else {
1398
- console.log("Usage: lattices daemon status");
1399
- }
1400
- break;
1401
- case "app": {
1402
- // Forward to lattices-app script
1403
- const { execFileSync } = await import("node:child_process");
1404
- const __dirname2 = dirname(fileURLToPath(import.meta.url));
1405
- const appScript = resolve(__dirname2, "lattices-app.js");
1406
- try {
1407
- execFileSync("node", [appScript, ...args.slice(1)], { stdio: "inherit" });
1408
- } catch { /* exit code forwarded */ }
1409
- break;
1410
- }
1411
- case "-h":
1412
- case "--help":
1413
- case "help":
1414
- printUsage();
1415
- break;
1416
- default:
1417
- createOrAttach();
1418
- }