@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
@@ -0,0 +1,3260 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { withDaemon, type DaemonClient } from "./cli/daemon.ts";
8
+ import {
9
+ hasFlag,
10
+ nonFlagArgs,
11
+ parseFlagValue,
12
+ parseOptionalNumber,
13
+ pause,
14
+ run,
15
+ runQuiet,
16
+ } from "./cli/helpers.ts";
17
+ import { searchCommand, placeCommand } from "./cli/search.ts";
18
+ import { captureCommand } from "./cli/capture.ts";
19
+ import { layerCommand } from "./cli/layer.ts";
20
+ import { runsCommand } from "./cli/runs.ts";
21
+ import {
22
+ esc,
23
+ sessionExists,
24
+ slugify,
25
+ toGroupSessionName,
26
+ toSessionName,
27
+ } from "./cli/session.ts";
28
+
29
+ // Daemon client (lazy-loaded to avoid blocking startup for TTY commands)
30
+ let _daemonClient: typeof import("./daemon-client.ts") | undefined;
31
+ async function getDaemonClient(): Promise<typeof import("./daemon-client.ts")> {
32
+ if (!_daemonClient) {
33
+ _daemonClient = await import("./daemon-client.ts");
34
+ }
35
+ return _daemonClient;
36
+ }
37
+
38
+ const args: string[] = process.argv.slice(2);
39
+ const command: string | undefined = args[0];
40
+
41
+ // ── Helpers ──────────────────────────────────────────────────────────
42
+
43
+ function hasTmux(): boolean {
44
+ return runQuiet("which tmux") !== null;
45
+ }
46
+
47
+ /** Commands that require tmux to be installed */
48
+ const tmuxRequiredCommands = new Set([
49
+ "start", "tmux", "init", "ls", "list", "kill", "rm", "sync", "reconcile",
50
+ "restart", "respawn", "group", "groups", "tab", "status",
51
+ "inventory", "sessions",
52
+ ]);
53
+
54
+ function requireTmux(command: string | undefined): void {
55
+ if (hasTmux()) return;
56
+
57
+ if (!command) return;
58
+
59
+ if (!tmuxRequiredCommands.has(command)) return;
60
+
61
+ console.error(`
62
+ \x1b[1;31m✘ tmux not found\x1b[0m
63
+
64
+ Lattices uses tmux for terminal session management.
65
+ Install it with Homebrew:
66
+
67
+ \x1b[1mbrew install tmux\x1b[0m
68
+
69
+ If tmux is installed somewhere else, make sure it's on your PATH:
70
+
71
+ \x1b[90mexport PATH="/path/to/tmux/bin:$PATH"\x1b[0m
72
+
73
+ Then run this command again.
74
+ `.trim());
75
+ process.exit(1);
76
+ }
77
+
78
+ function isInsideTmux(): boolean {
79
+ return !!process.env.TMUX;
80
+ }
81
+
82
+ function appleScriptString(str: string): string {
83
+ return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
84
+ }
85
+
86
+ // ── Config ───────────────────────────────────────────────────────────
87
+
88
+ function readConfig(dir: string): any | null {
89
+ const configPath = resolve(dir, ".lattices.json");
90
+ if (!existsSync(configPath)) return null;
91
+ try {
92
+ const raw = readFileSync(configPath, "utf8");
93
+ return JSON.parse(raw);
94
+ } catch (e: unknown) {
95
+ console.warn(`Warning: invalid .lattices.json — ${(e as Error).message}`);
96
+ return null;
97
+ }
98
+ }
99
+
100
+ // ── Workspace config (tab groups) ───────────────────────────────────
101
+
102
+ function readWorkspaceConfig(): any | null {
103
+ const configPath = resolve(homedir(), ".lattices", "workspace.json");
104
+ if (!existsSync(configPath)) return null;
105
+ try {
106
+ const raw = readFileSync(configPath, "utf8");
107
+ return JSON.parse(raw);
108
+ } catch (e: unknown) {
109
+ console.warn(`Warning: invalid workspace.json — ${(e as Error).message}`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /** Get ordered pane IDs for a specific window within a session */
115
+ function getPaneIdsForWindow(sessionName: string, windowIndex: number): string[] {
116
+ const out = runQuiet(
117
+ `tmux list-panes -t "${sessionName}:${windowIndex}" -F "#{pane_id}"`
118
+ );
119
+ return out ? out.split("\n").filter(Boolean) : [];
120
+ }
121
+
122
+ interface PaneConfig {
123
+ name?: string;
124
+ cmd?: string;
125
+ size?: number;
126
+ }
127
+
128
+ /** Create a tmux window with pane layout for a project dir */
129
+ function createWindowForProject(sessionName: string, windowIndex: number, dir: string, label?: string): void {
130
+ const config = readConfig(dir);
131
+ const d = esc(dir);
132
+
133
+ let panes: PaneConfig[];
134
+ if (config?.panes?.length) {
135
+ panes = resolvePane(config.panes, dir);
136
+ } else {
137
+ panes = defaultPanes(dir);
138
+ }
139
+
140
+ if (windowIndex === 0) {
141
+ // First window already exists from new-session, just set working dir
142
+ run(`tmux send-keys -t "${sessionName}:0" 'cd ${d}' Enter`);
143
+ } else {
144
+ run(`tmux new-window -t "${sessionName}" -c '${d}'`);
145
+ }
146
+
147
+ const winTarget = `${sessionName}:${windowIndex}`;
148
+
149
+ // Rename the window
150
+ const winLabel = label || basename(dir);
151
+ runQuiet(`tmux rename-window -t "${winTarget}" "${winLabel}"`);
152
+
153
+ // Create pane splits
154
+ if (panes.length === 2) {
155
+ const mainSize = panes[0].size || 60;
156
+ run(`tmux split-window -h -t "${winTarget}" -c '${d}' -p ${100 - mainSize}`);
157
+ } else if (panes.length >= 3) {
158
+ const mainSize = panes[0].size || 60;
159
+ for (let i = 1; i < panes.length; i++) {
160
+ run(`tmux split-window -t "${winTarget}" -c '${d}'`);
161
+ }
162
+ runQuiet(`tmux set-option -t "${winTarget}" -w main-pane-width '${mainSize}%'`);
163
+ run(`tmux select-layout -t "${winTarget}" main-vertical`);
164
+ }
165
+
166
+ // Get pane IDs and send commands
167
+ const paneIds = getPaneIdsForWindow(sessionName, windowIndex);
168
+ for (let i = 0; i < panes.length && i < paneIds.length; i++) {
169
+ if (panes[i].cmd) {
170
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd!)}' Enter`);
171
+ }
172
+ if (panes[i].name) {
173
+ runQuiet(`tmux select-pane -t "${paneIds[i]}" -T "${panes[i].name}"`);
174
+ }
175
+ }
176
+
177
+ // Focus first pane in this window
178
+ if (paneIds.length) {
179
+ run(`tmux select-pane -t "${paneIds[0]}"`);
180
+ }
181
+ }
182
+
183
+ interface TabConfig {
184
+ path: string;
185
+ label?: string;
186
+ }
187
+
188
+ interface GroupConfig {
189
+ id: string;
190
+ label?: string;
191
+ tabs?: TabConfig[];
192
+ }
193
+
194
+ /** Create a group session with one tmux window per tab */
195
+ function createGroupSession(group: GroupConfig): string | null {
196
+ const name = toGroupSessionName(group.id);
197
+ const tabs = group.tabs || [];
198
+
199
+ if (!tabs.length) {
200
+ console.log(`Group "${group.id}" has no tabs.`);
201
+ return null;
202
+ }
203
+
204
+ // Validate all paths exist
205
+ for (const tab of tabs) {
206
+ if (!existsSync(tab.path)) {
207
+ console.log(`Warning: path does not exist — ${tab.path}`);
208
+ }
209
+ }
210
+
211
+ const firstDir = esc(tabs[0].path);
212
+ console.log(`Creating group "${group.label || group.id}" (${tabs.length} tabs)...`);
213
+
214
+ // Create session with first window
215
+ run(`tmux new-session -d -s "${name}" -c '${firstDir}'`);
216
+
217
+ // Set up each window/tab
218
+ for (let i = 0; i < tabs.length; i++) {
219
+ const tab = tabs[i];
220
+ const dir = resolve(tab.path);
221
+ createWindowForProject(name, i, dir, tab.label);
222
+ }
223
+
224
+ // Tag the session title
225
+ runQuiet(`tmux set-option -t "${name}" set-titles on`);
226
+ runQuiet(`tmux set-option -t "${name}" set-titles-string "[lattices:${name}] #{window_name} — #{pane_title}"`);
227
+
228
+ // Select first window
229
+ runQuiet(`tmux select-window -t "${name}:0"`);
230
+
231
+ return name;
232
+ }
233
+
234
+ function listGroups(): void {
235
+ const ws = readWorkspaceConfig();
236
+ if (!ws?.groups?.length) {
237
+ console.log("No tab groups configured in ~/.lattices/workspace.json");
238
+ return;
239
+ }
240
+
241
+ console.log("Tab Groups:\n");
242
+ for (const group of ws.groups) {
243
+ const tabs = group.tabs || [];
244
+ const runningCount = tabs.filter((t: TabConfig) => sessionExists(toSessionName(resolve(t.path)))).length;
245
+ const running = runningCount > 0;
246
+ const status = running
247
+ ? `\x1b[32m● ${runningCount}/${tabs.length} running\x1b[0m`
248
+ : "\x1b[90m○ stopped\x1b[0m";
249
+ const tabLabels = tabs.map((t: TabConfig) => t.label || basename(t.path)).join(", ");
250
+ console.log(` ${group.label || group.id} ${status}`);
251
+ console.log(` id: ${group.id}`);
252
+ console.log(` tabs: ${tabLabels}`);
253
+ console.log();
254
+ }
255
+ }
256
+
257
+ function groupCommand(id?: string): void {
258
+ const ws = readWorkspaceConfig();
259
+ if (!ws?.groups?.length) {
260
+ console.log("No tab groups configured in ~/.lattices/workspace.json");
261
+ return;
262
+ }
263
+
264
+ if (!id) {
265
+ listGroups();
266
+ return;
267
+ }
268
+
269
+ const group = ws.groups.find((g: GroupConfig) => g.id === id);
270
+ if (!group) {
271
+ console.log(`No group "${id}". Available: ${ws.groups.map((g: GroupConfig) => g.id).join(", ")}`);
272
+ return;
273
+ }
274
+
275
+ const tabs = group.tabs || [];
276
+ if (!tabs.length) {
277
+ console.log(`Group "${group.id}" has no tabs.`);
278
+ return;
279
+ }
280
+
281
+ // Each tab gets its own lattices session (individual project sessions)
282
+ const firstDir = resolve(tabs[0].path);
283
+ const firstName = toSessionName(firstDir);
284
+
285
+ // If the first tab's session already exists, just attach
286
+ if (sessionExists(firstName)) {
287
+ console.log(`Reattaching to "${group.label || group.id}" (${tabs[0].label || basename(firstDir)})...`);
288
+ attach(firstName);
289
+ return;
290
+ }
291
+
292
+ // Create a detached session for each tab
293
+ console.log(`Launching group "${group.label || group.id}" (${tabs.length} tabs)...`);
294
+ for (const tab of tabs) {
295
+ const dir = resolve(tab.path);
296
+ const name = toSessionName(dir);
297
+ if (!sessionExists(name)) {
298
+ console.log(` Creating session: ${tab.label || basename(dir)}`);
299
+ createSession(dir);
300
+ }
301
+ }
302
+
303
+ // Attach to the first tab's session
304
+ attach(firstName);
305
+ }
306
+
307
+ function tabCommand(groupId?: string, tabName?: string): void {
308
+ if (!groupId) {
309
+ console.log("Usage: lattices tab <group-id> <tab-name|index>");
310
+ return;
311
+ }
312
+
313
+ const ws = readWorkspaceConfig();
314
+ if (!ws?.groups?.length) {
315
+ console.log("No tab groups configured.");
316
+ return;
317
+ }
318
+
319
+ const group = ws.groups.find((g: GroupConfig) => g.id === groupId);
320
+ if (!group) {
321
+ console.log(`No group "${groupId}".`);
322
+ return;
323
+ }
324
+
325
+ const tabs: TabConfig[] = group.tabs || [];
326
+
327
+ if (!tabName) {
328
+ // List tabs with their session status
329
+ console.log(`Tabs in "${group.label || group.id}":\n`);
330
+ for (let i = 0; i < tabs.length; i++) {
331
+ const label = tabs[i].label || basename(tabs[i].path);
332
+ const tabSession = toSessionName(resolve(tabs[i].path));
333
+ const running = sessionExists(tabSession);
334
+ const status = running ? "\x1b[32m●\x1b[0m" : "\x1b[90m○\x1b[0m";
335
+ console.log(` ${status} ${i}: ${label} (session: ${tabSession})`);
336
+ }
337
+ return;
338
+ }
339
+
340
+ // Resolve tab target to an index
341
+ let tabIdx: number;
342
+ if (/^\d+$/.test(tabName)) {
343
+ tabIdx = parseInt(tabName, 10);
344
+ } else {
345
+ tabIdx = tabs.findIndex(
346
+ (t) => (t.label || basename(t.path)).toLowerCase() === tabName.toLowerCase()
347
+ );
348
+ if (tabIdx === -1) {
349
+ const available = tabs.map((t) => t.label || basename(t.path)).join(", ");
350
+ console.log(`No tab "${tabName}". Available: ${available}`);
351
+ return;
352
+ }
353
+ }
354
+
355
+ if (tabIdx < 0 || tabIdx >= tabs.length) {
356
+ console.log(`Tab index ${tabIdx} is out of range (${tabs.length} tabs).`);
357
+ return;
358
+ }
359
+
360
+ // Each tab is its own lattices session — attach to it
361
+ const dir = resolve(tabs[tabIdx].path);
362
+ const tabSession = toSessionName(dir);
363
+ const label = tabs[tabIdx].label || basename(dir);
364
+
365
+ if (sessionExists(tabSession)) {
366
+ console.log(`Attaching to tab: ${label}`);
367
+ attach(tabSession);
368
+ } else {
369
+ console.log(`Creating session for tab: ${label}`);
370
+ createSession(dir);
371
+ attach(tabSession);
372
+ }
373
+ }
374
+
375
+ // ── Detect dev command ───────────────────────────────────────────────
376
+
377
+ function detectPackageManager(dir: string): string {
378
+ if (existsSync(resolve(dir, "bun.lockb")) || existsSync(resolve(dir, "bun.lock")))
379
+ return "bun";
380
+ if (existsSync(resolve(dir, "pnpm-lock.yaml"))) return "pnpm";
381
+ if (existsSync(resolve(dir, "yarn.lock"))) return "yarn";
382
+ return "npm";
383
+ }
384
+
385
+ function detectDevCommand(dir: string): string | null {
386
+ const pkgPath = resolve(dir, "package.json");
387
+ if (!existsSync(pkgPath)) return null;
388
+
389
+ let pkg: any;
390
+ try {
391
+ pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
392
+ } catch {
393
+ return null;
394
+ }
395
+
396
+ const scripts = pkg.scripts || {};
397
+ const pm = detectPackageManager(dir);
398
+ const runCmd = pm === "npm" ? "npm run" : pm;
399
+
400
+ if (scripts.dev) return `${runCmd} dev`;
401
+ if (scripts.start) return `${runCmd} start`;
402
+ if (scripts.serve) return `${runCmd} serve`;
403
+ if (scripts.watch) return `${runCmd} watch`;
404
+ return null;
405
+ }
406
+
407
+ // ── Session creation ─────────────────────────────────────────────────
408
+
409
+ function resolvePane(panes: any[], dir: string): PaneConfig[] {
410
+ return panes.map((p: any) => ({
411
+ name: p.name || "",
412
+ cmd: p.cmd || undefined,
413
+ size: p.size || undefined,
414
+ }));
415
+ }
416
+
417
+ /** Get ordered pane IDs (e.g. ["%0", "%1"]) for a session */
418
+ function getPaneIds(name: string): string[] {
419
+ const out = runQuiet(
420
+ `tmux list-panes -t "${name}" -F "#{pane_id}"`
421
+ );
422
+ return out ? out.split("\n").filter(Boolean) : [];
423
+ }
424
+
425
+ function createSession(dir: string): string {
426
+ const name = toSessionName(dir);
427
+ const config = readConfig(dir);
428
+ const d = esc(dir);
429
+
430
+ let panes: PaneConfig[];
431
+ if (config?.panes?.length) {
432
+ panes = resolvePane(config.panes, dir);
433
+ console.log(`Using .lattices.json (${panes.length} panes)`);
434
+ } else {
435
+ panes = defaultPanes(dir);
436
+ if (panes.length > 1) console.log(`Detected: ${panes[1].cmd}`);
437
+ else console.log(`No dev server detected — single pane`);
438
+ }
439
+
440
+ // Create session (targets are config-agnostic — no hardcoded indices)
441
+ run(`tmux new-session -d -s "${name}" -c '${d}'`);
442
+
443
+ if (panes.length === 2) {
444
+ const mainSize = panes[0].size || 60;
445
+ run(
446
+ `tmux split-window -h -t "${name}" -c '${d}' -p ${100 - mainSize}`
447
+ );
448
+ } else if (panes.length >= 3) {
449
+ const mainSize = panes[0].size || 60;
450
+ for (let i = 1; i < panes.length; i++) {
451
+ run(`tmux split-window -t "${name}" -c '${d}'`);
452
+ }
453
+ runQuiet(
454
+ `tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
455
+ );
456
+ run(`tmux select-layout -t "${name}" main-vertical`);
457
+ }
458
+
459
+ // Get actual pane IDs (works regardless of base-index / pane-base-index)
460
+ const paneIds = getPaneIds(name);
461
+
462
+ // Send commands and name each pane
463
+ for (let i = 0; i < panes.length && i < paneIds.length; i++) {
464
+ if (panes[i].cmd) {
465
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd!)}' Enter`);
466
+ }
467
+ if (panes[i].name) {
468
+ runQuiet(`tmux select-pane -t "${paneIds[i]}" -T "${panes[i].name}"`);
469
+ }
470
+ }
471
+
472
+ // Tag the terminal window title so the menu bar app can find it
473
+ // Format: [lattices:session-hash] pane_title: current_command
474
+ runQuiet(`tmux set-option -t "${name}" set-titles on`);
475
+ runQuiet(`tmux set-option -t "${name}" set-titles-string "[lattices:${name}] #{pane_title}"`);
476
+
477
+ // Name the tmux window after the project and focus the first pane
478
+ runQuiet(`tmux rename-window -t "${name}" "${basename(dir)}"`);
479
+ if (paneIds.length) {
480
+ run(`tmux select-pane -t "${paneIds[0]}"`);
481
+ }
482
+
483
+ return name;
484
+ }
485
+
486
+ /** Check each pane and prefill or restart commands that have exited.
487
+ * mode: "prefill" types the command without pressing Enter
488
+ * mode: "ensure" types the command and presses Enter */
489
+ function restoreCommands(name: string, dir: string, mode: "prefill" | "ensure"): void {
490
+ const config = readConfig(dir);
491
+ let panes: PaneConfig[];
492
+ if (config?.panes?.length) {
493
+ panes = resolvePane(config.panes, dir);
494
+ } else {
495
+ panes = defaultPanes(dir);
496
+ }
497
+
498
+ const paneIds = getPaneIds(name);
499
+ const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
500
+
501
+ let count = 0;
502
+ for (let i = 0; i < panes.length && i < paneIds.length; i++) {
503
+ if (!panes[i].cmd) continue;
504
+ const cur = runQuiet(
505
+ `tmux display-message -t "${paneIds[i]}" -p "#{pane_current_command}"`
506
+ );
507
+ if (cur && shells.has(cur)) {
508
+ if (mode === "ensure") {
509
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd!)}' Enter`);
510
+ } else {
511
+ run(`tmux send-keys -t "${paneIds[i]}" '${esc(panes[i].cmd!)}'`);
512
+ }
513
+ count++;
514
+ }
515
+ }
516
+ if (count > 0) {
517
+ const verb = mode === "ensure" ? "Restarted" : "Prefilled";
518
+ console.log(`${verb} ${count} exited command${count > 1 ? "s" : ""}`);
519
+ }
520
+ }
521
+
522
+ // ── Sync / reconcile ────────────────────────────────────────────────
523
+
524
+ function resolvePanes(dir: string): PaneConfig[] {
525
+ const config = readConfig(dir);
526
+ if (config?.panes?.length) {
527
+ return resolvePane(config.panes, dir);
528
+ }
529
+ return defaultPanes(dir);
530
+ }
531
+
532
+ // ── Dev command ──────────────────────────────────────────────────────
533
+
534
+ function detectProjectType(dir: string): string | null {
535
+ // Check for lattices-style hybrid project (Swift app + Node CLI)
536
+ if (existsSync(resolve(dir, "apps/mac/Package.swift")) && existsSync(resolve(dir, "bin/lattices-app.ts")))
537
+ return "lattices-app";
538
+ if (existsSync(resolve(dir, "Package.swift"))) return "swift";
539
+ if (existsSync(resolve(dir, "Cargo.toml"))) return "rust";
540
+ if (existsSync(resolve(dir, "go.mod"))) return "go";
541
+ if (existsSync(resolve(dir, "package.json"))) return "node";
542
+ if (existsSync(resolve(dir, "Makefile"))) return "make";
543
+ return null;
544
+ }
545
+
546
+ async function forwardToLatticesDevHelper(dir: string, cmd: string, extraFlags: string[] = []): Promise<void> {
547
+ const localDevScript = resolve(dir, "bin/lattices-dev");
548
+ const devScript = existsSync(localDevScript) ? localDevScript : resolve(import.meta.dir, "lattices-dev");
549
+ const { execFileSync } = await import("node:child_process");
550
+ try {
551
+ execFileSync(devScript, [cmd, ...extraFlags], { stdio: "inherit" });
552
+ } catch {
553
+ /* exit code forwarded */
554
+ }
555
+ }
556
+
557
+ async function devCommand(sub?: string, ...flags: string[]): Promise<void> {
558
+ const dir = process.cwd();
559
+ const type = detectProjectType(dir);
560
+
561
+ if (!sub) {
562
+ // bare `lattices dev` — run dev server
563
+ if (!type) {
564
+ console.log("No recognized project in current directory.");
565
+ return;
566
+ }
567
+ console.log(`Detected: ${type} project`);
568
+ if (type === "lattices-app") {
569
+ await forwardToLatticesDevHelper(dir, "restart", flags);
570
+ } else if (type === "node") {
571
+ const cmd = detectDevCommand(dir);
572
+ if (cmd) {
573
+ console.log(`Running: ${cmd}`);
574
+ execSync(cmd, { cwd: dir, stdio: "inherit" });
575
+ } else {
576
+ console.log("No dev script found in package.json.");
577
+ }
578
+ } else if (type === "swift") {
579
+ console.log("Running: swift run");
580
+ execSync("swift run", { cwd: dir, stdio: "inherit" });
581
+ } else if (type === "rust") {
582
+ console.log("Running: cargo run");
583
+ execSync("cargo run", { cwd: dir, stdio: "inherit" });
584
+ } else if (type === "go") {
585
+ console.log("Running: go run .");
586
+ execSync("go run .", { cwd: dir, stdio: "inherit" });
587
+ } else if (type === "make") {
588
+ execSync("make", { cwd: dir, stdio: "inherit" });
589
+ }
590
+ return;
591
+ }
592
+
593
+ if (sub === "placement-smoke") {
594
+ await placementSmokeCommand(flags);
595
+ return;
596
+ }
597
+
598
+ if (sub === "build") {
599
+ if (!type) {
600
+ console.log("No recognized project in current directory.");
601
+ return;
602
+ }
603
+ if (type === "lattices-app") {
604
+ await forwardToLatticesDevHelper(dir, "build");
605
+ } else if (type === "swift") {
606
+ console.log("Building: swift build -c release");
607
+ execSync("swift build -c release", { cwd: dir, stdio: "inherit" });
608
+ } else if (type === "node") {
609
+ const pm = detectPackageManager(dir);
610
+ const runCmd = pm === "npm" ? "npm run" : pm;
611
+ const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8"));
612
+ if (pkg.scripts?.build) {
613
+ console.log(`Running: ${runCmd} build`);
614
+ execSync(`${runCmd} build`, { cwd: dir, stdio: "inherit" });
615
+ } else {
616
+ console.log("No build script found in package.json.");
617
+ }
618
+ } else if (type === "rust") {
619
+ console.log("Building: cargo build --release");
620
+ execSync("cargo build --release", { cwd: dir, stdio: "inherit" });
621
+ } else if (type === "go") {
622
+ console.log("Building: go build .");
623
+ execSync("go build .", { cwd: dir, stdio: "inherit" });
624
+ } else if (type === "make") {
625
+ execSync("make", { cwd: dir, stdio: "inherit" });
626
+ }
627
+ return;
628
+ }
629
+
630
+ if (sub === "restart") {
631
+ if (type === "lattices-app") {
632
+ await forwardToLatticesDevHelper(dir, "restart", flags);
633
+ } else {
634
+ // For other project types, just rebuild
635
+ await devCommand("build");
636
+ }
637
+ return;
638
+ }
639
+
640
+ if (sub === "type") {
641
+ console.log(type || "unknown");
642
+ return;
643
+ }
644
+
645
+ console.log(`Unknown dev subcommand: ${sub}`);
646
+ console.log("Usage: lattices dev [build|restart|type]");
647
+ }
648
+
649
+ function defaultPanes(dir: string): PaneConfig[] {
650
+ const devCmd = detectDevCommand(dir);
651
+ if (devCmd) {
652
+ return [
653
+ { name: "shell", size: 60 },
654
+ { name: "server", cmd: devCmd },
655
+ ];
656
+ }
657
+ // No dev server detected → single pane
658
+ return [{ name: "shell" }];
659
+ }
660
+
661
+ function syncSession(): void {
662
+ const dir = process.cwd();
663
+ const name = toSessionName(dir);
664
+
665
+ if (!sessionExists(name)) {
666
+ console.log(`No session "${name}" — creating from scratch.`);
667
+ createSession(dir);
668
+ console.log("Session created.");
669
+ return;
670
+ }
671
+
672
+ const panes = resolvePanes(dir);
673
+ const actualIds = getPaneIds(name);
674
+ const declared = panes.length;
675
+ const actual = actualIds.length;
676
+ const d = esc(dir);
677
+ const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
678
+
679
+ console.log(`Session "${name}": ${actual} pane(s) found, ${declared} declared.`);
680
+
681
+ // Phase 1: recreate missing panes
682
+ if (actual < declared) {
683
+ const missing = declared - actual;
684
+ console.log(`Recreating ${missing} missing pane(s)...`);
685
+ for (let i = 0; i < missing; i++) {
686
+ run(`tmux split-window -t "${name}" -c '${d}'`);
687
+ }
688
+
689
+ // Re-apply layout
690
+ if (declared === 2) {
691
+ const mainSize = panes[0].size || 60;
692
+ // With 2 panes, use horizontal split layout
693
+ run(`tmux select-layout -t "${name}" even-horizontal`);
694
+ runQuiet(
695
+ `tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
696
+ );
697
+ run(`tmux select-layout -t "${name}" main-vertical`);
698
+ } else if (declared >= 3) {
699
+ const mainSize = panes[0].size || 60;
700
+ runQuiet(
701
+ `tmux set-option -t "${name}" -w main-pane-width '${mainSize}%'`
702
+ );
703
+ run(`tmux select-layout -t "${name}" main-vertical`);
704
+ }
705
+ }
706
+
707
+ // Phase 2: restore commands and labels on all panes
708
+ const freshIds = getPaneIds(name);
709
+ let restored = 0;
710
+ for (let i = 0; i < panes.length && i < freshIds.length; i++) {
711
+ // Set pane title/label
712
+ if (panes[i].name) {
713
+ runQuiet(`tmux select-pane -t "${freshIds[i]}" -T "${panes[i].name}"`);
714
+ }
715
+ // If pane is idle at a shell prompt, send its declared command
716
+ if (panes[i].cmd) {
717
+ const cur = runQuiet(
718
+ `tmux display-message -t "${freshIds[i]}" -p "#{pane_current_command}"`
719
+ );
720
+ if (cur && shells.has(cur)) {
721
+ run(`tmux send-keys -t "${freshIds[i]}" '${esc(panes[i].cmd!)}' Enter`);
722
+ restored++;
723
+ }
724
+ }
725
+ }
726
+
727
+ // Focus first pane
728
+ if (freshIds.length) {
729
+ run(`tmux select-pane -t "${freshIds[0]}"`);
730
+ }
731
+
732
+ if (restored > 0) {
733
+ console.log(`Restarted ${restored} command(s).`);
734
+ }
735
+ console.log("Sync complete.");
736
+ }
737
+
738
+ // ── Restart pane ────────────────────────────────────────────────────
739
+
740
+ function restartPane(target?: string): void {
741
+ const dir = process.cwd();
742
+ const name = toSessionName(dir);
743
+
744
+ if (!sessionExists(name)) {
745
+ console.log(`No session "${name}".`);
746
+ return;
747
+ }
748
+
749
+ const panes = resolvePanes(dir);
750
+ const paneIds = getPaneIds(name);
751
+
752
+ // Resolve target to an index
753
+ let idx: number;
754
+ if (target === undefined || target === null || target === "") {
755
+ // Default: first pane
756
+ idx = 0;
757
+ } else if (/^\d+$/.test(target)) {
758
+ idx = parseInt(target, 10);
759
+ } else {
760
+ // Match by name (case-insensitive)
761
+ idx = panes.findIndex(
762
+ (p) => p.name && p.name.toLowerCase() === target.toLowerCase()
763
+ );
764
+ if (idx === -1) {
765
+ console.log(
766
+ `No pane named "${target}". Available: ${panes.map((p, i) => p.name || `[${i}]`).join(", ")}`
767
+ );
768
+ return;
769
+ }
770
+ }
771
+
772
+ if (idx < 0 || idx >= paneIds.length) {
773
+ console.log(`Pane index ${idx} is out of range (${paneIds.length} panes).`);
774
+ return;
775
+ }
776
+
777
+ const paneId = paneIds[idx];
778
+ const pane = panes[idx] || {};
779
+ const label = pane.name || `pane ${idx}`;
780
+
781
+ // Get the PID of the process running in the pane
782
+ const panePid = runQuiet(
783
+ `tmux display-message -t "${paneId}" -p "#{pane_pid}"`
784
+ );
785
+
786
+ // Step 1: try C-c to gracefully stop
787
+ console.log(`Stopping ${label}...`);
788
+ run(`tmux send-keys -t "${paneId}" C-c`);
789
+
790
+ // Brief pause to let C-c propagate
791
+ execSync("sleep 0.5");
792
+
793
+ // Step 2: check if the process is still running (not back to shell)
794
+ const shells = new Set(["bash", "zsh", "fish", "sh", "dash"]);
795
+ const cur = runQuiet(
796
+ `tmux display-message -t "${paneId}" -p "#{pane_current_command}"`
797
+ );
798
+
799
+ if (cur && !shells.has(cur)) {
800
+ // Still hung — escalate: kill the child processes of the pane
801
+ console.log(`Process still running (${cur}), sending SIGKILL...`);
802
+ if (panePid) {
803
+ // Kill all children of the pane's shell process
804
+ runQuiet(`pkill -KILL -P ${panePid}`);
805
+ execSync("sleep 0.3");
806
+ }
807
+ }
808
+
809
+ // Step 3: send the declared command
810
+ if (pane.cmd) {
811
+ console.log(`Starting: ${pane.cmd}`);
812
+ run(`tmux send-keys -t "${paneId}" '${esc(pane.cmd)}' Enter`);
813
+ } else {
814
+ console.log(`No command declared for ${label} — pane is at shell prompt.`);
815
+ }
816
+ }
817
+
818
+ // ── Commands ─────────────────────────────────────────────────────────
819
+
820
+ // ── Daemon-aware commands ────────────────────────────────────────────
821
+
822
+ async function mouseCommand(sub?: string): Promise<void> {
823
+ await withDaemon(async ({ daemonCall }) => {
824
+ if (sub === "summon") {
825
+ const result = await daemonCall("mouse.summon") as any;
826
+ console.log(`🎯 Mouse summoned to (${result.x}, ${result.y})`);
827
+ } else {
828
+ // Default: find
829
+ const result = await daemonCall("mouse.find") as any;
830
+ console.log(`🔍 Mouse at (${result.x}, ${result.y})`);
831
+ }
832
+ });
833
+ }
834
+
835
+ async function daemonStatusCommand(): Promise<void> {
836
+ try {
837
+ const { daemonCall } = await getDaemonClient();
838
+ const status = await daemonCall("daemon.status") as any;
839
+ const uptime = Math.round(status.uptime);
840
+ const h = Math.floor(uptime / 3600);
841
+ const m = Math.floor((uptime % 3600) / 60);
842
+ const s = uptime % 60;
843
+ const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`;
844
+ console.log(`\x1b[32m●\x1b[0m Daemon running on ws://127.0.0.1:9399`);
845
+ console.log(` uptime: ${uptimeStr}`);
846
+ console.log(` clients: ${status.clientCount}`);
847
+ console.log(` windows: ${status.windowCount}`);
848
+ console.log(` sessions: ${status.tmuxSessionCount}`);
849
+ console.log(` version: ${status.version}`);
850
+ } catch {
851
+ console.log("\x1b[90m○\x1b[0m Daemon not running (start with: lattices app)");
852
+ }
853
+ }
854
+
855
+ async function windowsCommand(jsonFlag: boolean): Promise<void> {
856
+ await withDaemon(async ({ daemonCall }) => {
857
+ const windows = await daemonCall("windows.list") as any[];
858
+ if (jsonFlag) {
859
+ console.log(JSON.stringify(windows, null, 2));
860
+ return;
861
+ }
862
+ if (!windows.length) {
863
+ console.log("No windows tracked.");
864
+ return;
865
+ }
866
+ console.log(`Windows (${windows.length}):\n`);
867
+ for (const w of windows) {
868
+ const session = w.latticesSession ? ` \x1b[36m[lattices:${w.latticesSession}]\x1b[0m` : "";
869
+ const layer = w.layerTag ? ` \x1b[33m[layer:${w.layerTag}]\x1b[0m` : "";
870
+ const spaces = w.spaceIds.length ? ` space:${w.spaceIds.join(",")}` : "";
871
+ console.log(` \x1b[1m${w.app}\x1b[0m wid:${w.wid}${spaces}${session}${layer}`);
872
+ console.log(` "${w.title}"`);
873
+ console.log(` ${Math.round(w.frame.w)}×${Math.round(w.frame.h)} at (${Math.round(w.frame.x)},${Math.round(w.frame.y)})`);
874
+ console.log();
875
+ }
876
+ });
877
+ }
878
+
879
+ async function windowAssignCommand(wid?: string, layerId?: string): Promise<void> {
880
+ if (!wid || !layerId) {
881
+ console.log("Usage: lattices window assign <wid> <layer-id>");
882
+ return;
883
+ }
884
+ await withDaemon(async ({ daemonCall }) => {
885
+ await daemonCall("window.assignLayer", { wid: parseInt(wid), layer: layerId });
886
+ console.log(`Tagged wid:${wid} → layer:${layerId}`);
887
+ });
888
+ }
889
+
890
+ async function windowLayerMapCommand(jsonFlag: boolean): Promise<void> {
891
+ await withDaemon(async ({ daemonCall }) => {
892
+ const map = await daemonCall("window.layerMap") as any;
893
+ if (jsonFlag) {
894
+ console.log(JSON.stringify(map, null, 2));
895
+ return;
896
+ }
897
+ const entries = Object.entries(map);
898
+ if (!entries.length) {
899
+ console.log("No layer tags assigned.");
900
+ return;
901
+ }
902
+ console.log("Window → Layer map:\n");
903
+ for (const [wid, layer] of entries) {
904
+ console.log(` wid:${wid} → ${layer}`);
905
+ }
906
+ });
907
+ }
908
+
909
+ async function focusCommand(session?: string): Promise<void> {
910
+ if (!session) {
911
+ console.log("Usage: lattices focus <session-name>");
912
+ return;
913
+ }
914
+ await withDaemon(async ({ daemonCall }) => {
915
+ await daemonCall("window.focus", { session });
916
+ console.log(`Focused: ${session}`);
917
+ });
918
+ }
919
+
920
+ function receiptLine(receipt: any): string {
921
+ const id = receipt?.action?.id || "action";
922
+ const session = receipt?.session || receipt?.target?.session || "?";
923
+ const wid = receipt?.wid ?? receipt?.target?.wid ?? "?";
924
+ const status = receipt?.status || "?";
925
+ const verified = receipt?.verified === true ? "true" : "false";
926
+ const resolution = receipt?.targetResolution || "?";
927
+ return ` ${id} session=${session} wid=${wid} status=${status} verified=${verified} resolution=${resolution}`;
928
+ }
929
+
930
+ async function placementSmokeCommand(rawArgs: string[] = []): Promise<void> {
931
+ const pauseMs = Number(parseFlagValue(rawArgs, "pause") || 1200);
932
+ const positional = nonFlagArgs(rawArgs);
933
+
934
+ await withDaemon(async ({ daemonCall }) => {
935
+ let sessions = positional.slice(0, 2);
936
+ if (sessions.length < 2) {
937
+ const tmuxSessions = await daemonCall("tmux.sessions") as any[];
938
+ sessions = tmuxSessions
939
+ .map(s => s?.name)
940
+ .filter((name: unknown): name is string => typeof name === "string" && name.startsWith("lattices-place-"))
941
+ .slice(0, 2);
942
+ }
943
+
944
+ if (sessions.length < 2) {
945
+ console.log("Need two named sessions. Usage: lattices dev placement-smoke <session-a> <session-b>");
946
+ console.log("Tip: launch two small lattices fixture projects first, then rerun this command.");
947
+ return;
948
+ }
949
+
950
+ const [a, b] = sessions;
951
+ console.log(`Placement smoke: ${a} + ${b}`);
952
+
953
+ for (const session of sessions) {
954
+ const resolved = await daemonCall("window.resolve", {
955
+ target: { kind: "session", session },
956
+ placement: "left",
957
+ }) as any;
958
+ console.log(` resolve ${session}: wid=${resolved.wid ?? "?"} app=${resolved.app ?? "?"} resolution=${resolved.targetResolution ?? "?"}`);
959
+ }
960
+
961
+ const beats = [
962
+ {
963
+ label: "beat 1: halves",
964
+ actions: [
965
+ { id: "a-left-half", type: "window.place", target: { kind: "session", session: a }, args: { placement: "left" } },
966
+ { id: "b-right-half", type: "window.place", target: { kind: "session", session: b }, args: { placement: "right" } },
967
+ ],
968
+ },
969
+ {
970
+ label: "beat 2: 4x4 corners",
971
+ actions: [
972
+ { id: "a-top-left-4x4", type: "window.place", target: { kind: "session", session: a }, args: { placement: "grid:4x4:0,0" } },
973
+ { id: "b-bottom-right-4x4", type: "window.place", target: { kind: "session", session: b }, args: { placement: "grid:4x4:3,3" } },
974
+ ],
975
+ },
976
+ {
977
+ label: "beat 3: workbench",
978
+ actions: [
979
+ {
980
+ id: "a-workbench-left",
981
+ type: "window.place",
982
+ target: { kind: "session", session: a },
983
+ args: { placement: { kind: "fractions", x: 0.02, y: 0.05, w: 0.62, h: 0.9 } },
984
+ },
985
+ {
986
+ id: "b-console-right",
987
+ type: "window.place",
988
+ target: { kind: "session", session: b },
989
+ args: { placement: { kind: "fractions", x: 0.67, y: 0.12, w: 0.3, h: 0.76 } },
990
+ },
991
+ ],
992
+ },
993
+ ];
994
+
995
+ for (const beat of beats) {
996
+ console.log(`\n${beat.label}`);
997
+ const result = await daemonCall("actions.execute", {
998
+ source: "placement-smoke",
999
+ actions: beat.actions,
1000
+ }, 15000) as any;
1001
+ console.log(` batch=${result.status || "?"} request=${result.requestId || "?"}`);
1002
+ for (const receipt of result.receipts || []) {
1003
+ console.log(receiptLine(receipt));
1004
+ }
1005
+ await pause(pauseMs);
1006
+ }
1007
+
1008
+ const focused = await daemonCall("window.focus", { session: a }, 5000) as any;
1009
+ console.log(`\nfocus ${a}: ok=${focused.ok === true} wid=${focused.wid ?? "?"} raised=${focused.raised === true}`);
1010
+ });
1011
+ }
1012
+
1013
+ async function sessionsCommand(jsonFlag: boolean): Promise<void> {
1014
+ await withDaemon(async ({ daemonCall }) => {
1015
+ const sessions = await daemonCall("tmux.sessions") as any[];
1016
+ if (jsonFlag) {
1017
+ console.log(JSON.stringify(sessions, null, 2));
1018
+ return;
1019
+ }
1020
+ if (!sessions.length) {
1021
+ console.log("No active sessions.");
1022
+ return;
1023
+ }
1024
+ console.log(`Sessions (${sessions.length}):\n`);
1025
+ for (const s of sessions) {
1026
+ const windows = s.windowCount || s.windows || "?";
1027
+ console.log(` \x1b[1m${s.name}\x1b[0m (${windows} windows)`);
1028
+ }
1029
+ });
1030
+ }
1031
+
1032
+ async function terminalsCommand(rawArgs: string[] = []): Promise<void> {
1033
+ await withDaemon(async ({ daemonCall }) => {
1034
+ const jsonFlag = hasFlag(rawArgs, "json");
1035
+ const refresh = hasFlag(rawArgs, "refresh");
1036
+ const terminals = await daemonCall("terminals.list", { refresh }, refresh ? 15000 : undefined) as any[];
1037
+
1038
+ if (jsonFlag) {
1039
+ console.log(JSON.stringify(terminals, null, 2));
1040
+ return;
1041
+ }
1042
+ if (!terminals.length) {
1043
+ console.log("No terminal instances found.");
1044
+ return;
1045
+ }
1046
+
1047
+ console.log(`Terminals (${terminals.length}):\n`);
1048
+ for (const terminal of terminals) {
1049
+ const app = terminal.app || "terminal";
1050
+ const wid = terminal.windowId ? ` wid=${terminal.windowId}` : "";
1051
+ const cwd = terminal.cwd ? ` cwd=${terminal.cwd}` : "";
1052
+ const session = terminal.tmuxSession ? ` session=${terminal.tmuxSession}` : "";
1053
+ const claude = terminal.hasClaude ? " claude" : "";
1054
+ console.log(` ${app} ${terminal.tty}${wid}${session}${claude}`);
1055
+ if (terminal.displayName) console.log(` ${terminal.displayName}`);
1056
+ if (cwd) console.log(` ${cwd.trim()}`);
1057
+ }
1058
+ });
1059
+ }
1060
+
1061
+ async function computerCommand(subcommand?: string, ...rawArgs: string[]): Promise<void> {
1062
+ const sub = subcommand || "demo-terminal";
1063
+ const jsonFlag = hasFlag(rawArgs, "json");
1064
+ const aliases: Record<string, string> = {
1065
+ "demo-terminal": "computer.demoTerminal",
1066
+ "terminal-demo": "computer.demoTerminal",
1067
+ "term-demo": "computer.demoTerminal",
1068
+ "demo-scout": "computer.demoScout",
1069
+ "scout-demo": "computer.demoScout",
1070
+ "scout": "computer.demoScout",
1071
+ "prepare": "computer.prepare",
1072
+ "observe": "computer.prepare",
1073
+ "stage": "computer.prepare",
1074
+ "launch": "computer.launchApp",
1075
+ "launch-app": "computer.launchApp",
1076
+ "app": "computer.launchApp",
1077
+ "focus": "computer.focusWindow",
1078
+ "focus-window": "computer.focusWindow",
1079
+ "click": "computer.click",
1080
+ "mouse-click": "computer.click",
1081
+ "cursor": "computer.showCursor",
1082
+ "show-cursor": "computer.showCursor",
1083
+ "mouse-cursor": "computer.showCursor",
1084
+ "magic-cursor": "computer.magicCursor",
1085
+ "ghost-cursor": "computer.magicCursor",
1086
+ "move-cursor": "computer.magicCursor",
1087
+ "magic-scout": "computer.magicCursor",
1088
+ "scout-magic": "computer.magicCursor",
1089
+ "type": "computer.typeText",
1090
+ "type-text": "computer.typeText",
1091
+ "typetext": "computer.typeText",
1092
+ "type-window": "computer.typeWindowText",
1093
+ "type-app": "computer.typeWindowText",
1094
+ "app-type": "computer.typeWindowText",
1095
+ };
1096
+ const method = aliases[sub];
1097
+
1098
+ if (!method) {
1099
+ console.log(`lattices computer — run bounded computer-use actions
1100
+
1101
+ Usage:
1102
+ lattices computer prepare [--json] [--text "hello"]
1103
+ lattices computer focus-window [--json] [--wid id] [--app name]
1104
+ lattices computer launch-app Scout [--json]
1105
+ lattices computer type-window --app Scout --text "hello" [--x-ratio .5 --y-ratio .86] [--execute]
1106
+ lattices computer click --app Scout --x-ratio .5 --y-ratio .86 --treatment execute
1107
+ lattices computer click --app Scout --x-ratio .74 --y-ratio .95 --transport ax --ax-label Send --execute
1108
+ lattices cua click --app Scout --x-ratio .74 --y-ratio .95 --transport ax --ax-label Send --execute
1109
+ lattices computer magic-scout "draft text" --execute
1110
+ lattices computer scout [message] [--treatment present|execute] [--send]
1111
+ lattices computer cursor [--json] [--style marker] [--shape arrow] [--size tiny] [--trail thread]
1112
+ lattices computer type-text --text "hello" [--json] [--enter]
1113
+ lattices computer demo-terminal [--json] [--dry-run]
1114
+ lattices computer demo-terminal --text "hello" [--wid id] [--tty tty] [--iterm-session-id id] [--app iTerm2]
1115
+
1116
+ Common flags:
1117
+ --treatment observe|stage|present|execute
1118
+ --style spotlight|pulse|marker
1119
+ --shape arrow|needle|petal|shard|chevron|facet|wedge|prism|notch|kite
1120
+ --angle-deg -16..16
1121
+ --size tiny|small|regular|large
1122
+ --trail thread|ribbon|spark|comet|route|none
1123
+ --motion glide|snap|float|rush|crawl|accelerate|teleport|spring|magnet|slingshot
1124
+ --trajectory straight|soft|arc|swoop|overshoot
1125
+ --glow none|soft|halo|comet
1126
+ --idle still|breathe|wiggle|orbit|hover|nod|drift|shimmer|blink|tremble
1127
+ --edge none|pulse|ripple|tick|reticle|blink|spark|underline|echo|scan|pin
1128
+ --caption auto
1129
+ --caption-title "Spring reticle" --caption-body "AX text follows the cursor"
1130
+ --caption-tags "shape arrow,motion spring,edge reticle"
1131
+ --caption-placement top-left|top-right|bottom-left|bottom-right|top-center|center|near-cursor
1132
+ --caption-x-ratio 0.04 --caption-y-ratio 0.08
1133
+ --caption-lead-ms 650 --caption-sound engage
1134
+ --typewriter --type-interval-ms 18
1135
+ --transport auto|tmux|iterm|pasteboard
1136
+ --transport ax|pointer for app clicks
1137
+ --ax-label Send --no-focus
1138
+ --x-ratio 0..1 --y-ratio 0..1
1139
+ --from-x-ratio 0..1 --from-y-ratio 0..1
1140
+ --send
1141
+ --no-capture
1142
+ `);
1143
+ return;
1144
+ }
1145
+
1146
+ const params: Record<string, unknown> = { source: "cli" };
1147
+ const magicScout = sub === "magic-scout" || sub === "scout-magic";
1148
+ const positional = nonFlagArgs(rawArgs);
1149
+ let text = parseFlagValue(rawArgs, "text");
1150
+ const tty = parseFlagValue(rawArgs, "tty");
1151
+ const app = parseFlagValue(rawArgs, "app");
1152
+ const name = parseFlagValue(rawArgs, "name");
1153
+ const bundleId = parseFlagValue(rawArgs, "bundleId") || parseFlagValue(rawArgs, "bundle-id") || parseFlagValue(rawArgs, "bundleIdentifier");
1154
+ const path = parseFlagValue(rawArgs, "path") || parseFlagValue(rawArgs, "appPath") || parseFlagValue(rawArgs, "app-path");
1155
+ const wid = parseFlagValue(rawArgs, "wid");
1156
+ const terminalSessionId = parseFlagValue(rawArgs, "terminalSessionId")
1157
+ || parseFlagValue(rawArgs, "terminal-session-id")
1158
+ || parseFlagValue(rawArgs, "itermSessionId")
1159
+ || parseFlagValue(rawArgs, "iterm-session-id");
1160
+ const session = parseFlagValue(rawArgs, "session");
1161
+ const title = parseFlagValue(rawArgs, "title");
1162
+ const treatment = parseFlagValue(rawArgs, "treatment") || parseFlagValue(rawArgs, "mode") || parseFlagValue(rawArgs, "phase");
1163
+ const transport = parseFlagValue(rawArgs, "transport");
1164
+ const capture = parseFlagValue(rawArgs, "capture");
1165
+ const x = parseFlagValue(rawArgs, "x");
1166
+ const y = parseFlagValue(rawArgs, "y");
1167
+ const fromX = parseFlagValue(rawArgs, "fromX") || parseFlagValue(rawArgs, "from-x") || parseFlagValue(rawArgs, "startX") || parseFlagValue(rawArgs, "start-x");
1168
+ const fromY = parseFlagValue(rawArgs, "fromY") || parseFlagValue(rawArgs, "from-y") || parseFlagValue(rawArgs, "startY") || parseFlagValue(rawArgs, "start-y");
1169
+ const xRatio = parseFlagValue(rawArgs, "xRatio") || parseFlagValue(rawArgs, "x-ratio") || parseFlagValue(rawArgs, "relativeX") || parseFlagValue(rawArgs, "relative-x") || parseFlagValue(rawArgs, "windowX") || parseFlagValue(rawArgs, "window-x");
1170
+ const yRatio = parseFlagValue(rawArgs, "yRatio") || parseFlagValue(rawArgs, "y-ratio") || parseFlagValue(rawArgs, "relativeY") || parseFlagValue(rawArgs, "relative-y") || parseFlagValue(rawArgs, "windowY") || parseFlagValue(rawArgs, "window-y");
1171
+ const fromXRatio = parseFlagValue(rawArgs, "fromXRatio") || parseFlagValue(rawArgs, "from-x-ratio") || parseFlagValue(rawArgs, "startXRatio") || parseFlagValue(rawArgs, "start-x-ratio");
1172
+ const fromYRatio = parseFlagValue(rawArgs, "fromYRatio") || parseFlagValue(rawArgs, "from-y-ratio") || parseFlagValue(rawArgs, "startYRatio") || parseFlagValue(rawArgs, "start-y-ratio");
1173
+ const button = parseFlagValue(rawArgs, "button");
1174
+ const axLabel = parseFlagValue(rawArgs, "axLabel") || parseFlagValue(rawArgs, "ax-label") || parseFlagValue(rawArgs, "targetText") || parseFlagValue(rawArgs, "target-text");
1175
+ const appearance = parseFlagValue(rawArgs, "appearance") || parseFlagValue(rawArgs, "style") || parseFlagValue(rawArgs, "cursor-style") || parseFlagValue(rawArgs, "cursorStyle");
1176
+ const shape = parseFlagValue(rawArgs, "shape") || parseFlagValue(rawArgs, "marker-shape") || parseFlagValue(rawArgs, "markerShape") || parseFlagValue(rawArgs, "cursor-shape") || parseFlagValue(rawArgs, "cursorShape");
1177
+ const angleDeg = parseFlagValue(rawArgs, "angleDeg") || parseFlagValue(rawArgs, "angle-deg") || parseFlagValue(rawArgs, "rotationDeg") || parseFlagValue(rawArgs, "rotation-deg") || parseFlagValue(rawArgs, "rotation") || parseFlagValue(rawArgs, "angle");
1178
+ const size = parseFlagValue(rawArgs, "size") || parseFlagValue(rawArgs, "marker-size") || parseFlagValue(rawArgs, "markerSize") || parseFlagValue(rawArgs, "cursor-size") || parseFlagValue(rawArgs, "cursorSize");
1179
+ const color = parseFlagValue(rawArgs, "color");
1180
+ const durationMs = parseFlagValue(rawArgs, "durationMs") || parseFlagValue(rawArgs, "duration-ms");
1181
+ const typeIntervalMs = parseFlagValue(rawArgs, "typeIntervalMs")
1182
+ || parseFlagValue(rawArgs, "type-interval-ms")
1183
+ || parseFlagValue(rawArgs, "typingIntervalMs")
1184
+ || parseFlagValue(rawArgs, "typing-interval-ms");
1185
+ const label = parseFlagValue(rawArgs, "label");
1186
+ const caption = parseFlagValue(rawArgs, "caption")
1187
+ || parseFlagValue(rawArgs, "treatmentLabel")
1188
+ || parseFlagValue(rawArgs, "treatment-label")
1189
+ || parseFlagValue(rawArgs, "variant");
1190
+ const captionTitle = parseFlagValue(rawArgs, "captionTitle") || parseFlagValue(rawArgs, "caption-title");
1191
+ const captionBody = parseFlagValue(rawArgs, "captionBody")
1192
+ || parseFlagValue(rawArgs, "caption-body")
1193
+ || parseFlagValue(rawArgs, "captionDetail")
1194
+ || parseFlagValue(rawArgs, "caption-detail");
1195
+ const captionTags = parseFlagValue(rawArgs, "captionTags") || parseFlagValue(rawArgs, "caption-tags");
1196
+ const captionMode = parseFlagValue(rawArgs, "captionMode") || parseFlagValue(rawArgs, "caption-mode");
1197
+ const captionEyebrow = parseFlagValue(rawArgs, "captionEyebrow") || parseFlagValue(rawArgs, "caption-eyebrow");
1198
+ const captionLeadMs = parseFlagValue(rawArgs, "captionLeadMs") || parseFlagValue(rawArgs, "caption-lead-ms");
1199
+ const captionSound = parseFlagValue(rawArgs, "captionSound") || parseFlagValue(rawArgs, "caption-sound");
1200
+ const captionPlacement = parseFlagValue(rawArgs, "captionPlacement") || parseFlagValue(rawArgs, "caption-placement");
1201
+ const captionMargin = parseFlagValue(rawArgs, "captionMargin") || parseFlagValue(rawArgs, "caption-margin");
1202
+ const captionX = parseFlagValue(rawArgs, "captionX") || parseFlagValue(rawArgs, "caption-x");
1203
+ const captionY = parseFlagValue(rawArgs, "captionY") || parseFlagValue(rawArgs, "caption-y");
1204
+ const captionXRatio = parseFlagValue(rawArgs, "captionXRatio") || parseFlagValue(rawArgs, "caption-x-ratio") || parseFlagValue(rawArgs, "captionLeftRatio") || parseFlagValue(rawArgs, "caption-left-ratio");
1205
+ const captionYRatio = parseFlagValue(rawArgs, "captionYRatio") || parseFlagValue(rawArgs, "caption-y-ratio") || parseFlagValue(rawArgs, "captionTopRatio") || parseFlagValue(rawArgs, "caption-top-ratio");
1206
+ const sound = parseFlagValue(rawArgs, "sound") || parseFlagValue(rawArgs, "sfx");
1207
+ const trail = parseFlagValue(rawArgs, "trail") || parseFlagValue(rawArgs, "effect");
1208
+ const pathStyle = parseFlagValue(rawArgs, "pathStyle") || parseFlagValue(rawArgs, "path-style");
1209
+ const motion = parseFlagValue(rawArgs, "motion") || parseFlagValue(rawArgs, "easing") || parseFlagValue(rawArgs, "velocity");
1210
+ const trajectory = parseFlagValue(rawArgs, "trajectory") || parseFlagValue(rawArgs, "curve") || parseFlagValue(rawArgs, "arc");
1211
+ const glow = parseFlagValue(rawArgs, "glow") || parseFlagValue(rawArgs, "bloom");
1212
+ const idle = parseFlagValue(rawArgs, "idle") || parseFlagValue(rawArgs, "settle") || parseFlagValue(rawArgs, "presence");
1213
+ const edge = parseFlagValue(rawArgs, "edge") || parseFlagValue(rawArgs, "edgeEffect") || parseFlagValue(rawArgs, "edge-effect") || parseFlagValue(rawArgs, "arrival");
1214
+
1215
+ if (!app && !name && method === "computer.launchApp" && positional[0]) {
1216
+ params.app = positional[0];
1217
+ }
1218
+ if (magicScout && !app && !name) {
1219
+ params.app = "Scout";
1220
+ }
1221
+ if (!text && (method === "computer.typeWindowText" || method === "computer.demoScout" || method === "computer.magicCursor")) {
1222
+ const targetApp = String(params.app || app || name || "");
1223
+ const messageOffset = targetApp && positional[0] === targetApp ? 1 : 0;
1224
+ const positionalText = positional.slice(messageOffset).join(" ").trim();
1225
+ if (positionalText) text = positionalText;
1226
+ }
1227
+ if (method === "computer.click" && !x && !y && positional.length >= 2) {
1228
+ const px = Number(positional[0]);
1229
+ const py = Number(positional[1]);
1230
+ if (Number.isFinite(px) && Number.isFinite(py)) {
1231
+ params.x = px;
1232
+ params.y = py;
1233
+ }
1234
+ }
1235
+
1236
+ if (text) params.text = text;
1237
+ if (tty) params.tty = tty;
1238
+ if (app) params.app = app;
1239
+ if (name) params.name = name;
1240
+ if (bundleId) params.bundleId = bundleId;
1241
+ if (path) params.path = path;
1242
+ if (wid && Number.isFinite(Number(wid))) params.wid = Number(wid);
1243
+ if (terminalSessionId) params.terminalSessionId = terminalSessionId;
1244
+ if (session) params.session = session;
1245
+ if (title) params.title = title;
1246
+ if (treatment) params.treatment = treatment;
1247
+ if (transport) params.transport = transport;
1248
+ if (x && Number.isFinite(Number(x))) params.x = Number(x);
1249
+ if (y && Number.isFinite(Number(y))) params.y = Number(y);
1250
+ if (fromX && Number.isFinite(Number(fromX))) params.fromX = Number(fromX);
1251
+ if (fromY && Number.isFinite(Number(fromY))) params.fromY = Number(fromY);
1252
+ if (xRatio && Number.isFinite(Number(xRatio))) params.xRatio = Number(xRatio);
1253
+ if (yRatio && Number.isFinite(Number(yRatio))) params.yRatio = Number(yRatio);
1254
+ if (fromXRatio && Number.isFinite(Number(fromXRatio))) params.fromXRatio = Number(fromXRatio);
1255
+ if (fromYRatio && Number.isFinite(Number(fromYRatio))) params.fromYRatio = Number(fromYRatio);
1256
+ if (magicScout && params.xRatio === undefined) params.xRatio = 0.5;
1257
+ if (magicScout && params.yRatio === undefined) params.yRatio = 0.86;
1258
+ if (button) params.button = button;
1259
+ if (axLabel) params.axLabel = axLabel;
1260
+ if (appearance) params.appearance = appearance;
1261
+ if (shape) params.shape = shape;
1262
+ if (angleDeg && Number.isFinite(Number(angleDeg))) params.angleDeg = Number(angleDeg);
1263
+ if (size) params.size = size;
1264
+ if (color) params.color = color;
1265
+ if (durationMs && Number.isFinite(Number(durationMs))) params.durationMs = Number(durationMs);
1266
+ if (typeIntervalMs && Number.isFinite(Number(typeIntervalMs))) params.typeIntervalMs = Number(typeIntervalMs);
1267
+ if (label) params.label = label;
1268
+ if (caption) params.caption = caption;
1269
+ if (captionTitle) params.captionTitle = captionTitle;
1270
+ if (captionBody) params.captionBody = captionBody;
1271
+ if (captionTags) params.captionTags = captionTags;
1272
+ if (captionMode) params.captionMode = captionMode;
1273
+ if (captionEyebrow) params.captionEyebrow = captionEyebrow;
1274
+ if (captionLeadMs && Number.isFinite(Number(captionLeadMs))) params.captionLeadMs = Number(captionLeadMs);
1275
+ if (captionSound) params.captionSound = captionSound;
1276
+ if (captionPlacement) params.captionPlacement = captionPlacement;
1277
+ if (captionMargin && Number.isFinite(Number(captionMargin))) params.captionMargin = Number(captionMargin);
1278
+ if (captionX && Number.isFinite(Number(captionX))) params.captionX = Number(captionX);
1279
+ if (captionY && Number.isFinite(Number(captionY))) params.captionY = Number(captionY);
1280
+ if (captionXRatio && Number.isFinite(Number(captionXRatio))) params.captionXRatio = Number(captionXRatio);
1281
+ if (captionYRatio && Number.isFinite(Number(captionYRatio))) params.captionYRatio = Number(captionYRatio);
1282
+ if (sound) params.sound = sound;
1283
+ if (trail) params.trail = trail;
1284
+ if (pathStyle) params.pathStyle = pathStyle;
1285
+ if (motion) params.motion = motion;
1286
+ if (trajectory) params.trajectory = trajectory;
1287
+ if (glow) params.glow = glow;
1288
+ if (idle) params.idle = idle;
1289
+ if (edge) params.edge = edge;
1290
+ if (capture === "false" || capture === "0") params.capture = false;
1291
+ if (hasFlag(rawArgs, "no-capture") || hasFlag(rawArgs, "noCapture")) params.capture = false;
1292
+ if (hasFlag(rawArgs, "no-focus") || hasFlag(rawArgs, "noFocus") || hasFlag(rawArgs, "nofocus")) params.noFocus = true;
1293
+ if (hasFlag(rawArgs, "dry-run") || hasFlag(rawArgs, "dryRun")) params.dryRun = true;
1294
+ if (hasFlag(rawArgs, "enter")) params.enter = true;
1295
+ if (hasFlag(rawArgs, "send")) params.send = true;
1296
+ if (hasFlag(rawArgs, "append")) params.append = true;
1297
+ if (hasFlag(rawArgs, "show-caption") || hasFlag(rawArgs, "showCaption")) params.showCaption = true;
1298
+ if (hasFlag(rawArgs, "no-caption-selections") || hasFlag(rawArgs, "noCaptionSelections")) params.captionSelections = false;
1299
+ if (hasFlag(rawArgs, "typewriter") || hasFlag(rawArgs, "typing")) params.typewriter = true;
1300
+ if (hasFlag(rawArgs, "execute")) params.treatment = "execute";
1301
+ if (hasFlag(rawArgs, "present")) params.treatment = "present";
1302
+ if (hasFlag(rawArgs, "stage")) params.treatment = "stage";
1303
+ if (hasFlag(rawArgs, "observe")) params.treatment = "observe";
1304
+ if (hasFlag(rawArgs, "click")) params.click = true;
1305
+
1306
+ await withDaemon(async ({ daemonCall }) => {
1307
+ let result: any;
1308
+ if (method === "computer.click" || method === "computer.magicCursor") {
1309
+ const cua = await import("./cua.ts");
1310
+ result = method === "computer.click"
1311
+ ? await cua.click(params as any)
1312
+ : await cua.magicCursor(params as any);
1313
+ } else {
1314
+ result = await daemonCall(method, params, 30000) as any;
1315
+ }
1316
+ if (jsonFlag) {
1317
+ console.log(JSON.stringify(result, null, 2));
1318
+ return;
1319
+ }
1320
+
1321
+ const selected = result.selected || {};
1322
+ const terminal = selected.terminal || {};
1323
+ const target = result.target || terminal;
1324
+ const run = result.run || {};
1325
+ console.log(`${result.action || sub} ${result.treatment ? `(${result.treatment})` : ""}`);
1326
+ if (result.cursor) {
1327
+ console.log(" target: cursor");
1328
+ } else {
1329
+ console.log(` target: ${target.app || result.app || "terminal"} ${terminal.tty || ""}${target.windowId || target.wid ? ` wid:${target.windowId || target.wid}` : ""}`);
1330
+ }
1331
+ if (result.cursor) console.log(` cursor: (${Math.round(result.cursor.x)}, ${Math.round(result.cursor.y)})`);
1332
+ if (result.from) console.log(` from: (${Math.round(result.from.x)}, ${Math.round(result.from.y)})`);
1333
+ console.log(` run: ${run.id || "?"}`);
1334
+ if (typeof result.launched === "boolean") console.log(` launched: ${result.launched}`);
1335
+ if (typeof result.focused === "boolean") console.log(` focused: ${result.focused}`);
1336
+ if (typeof result.clicked === "boolean") console.log(` clicked: ${result.clicked}`);
1337
+ if (typeof result.shown === "boolean") console.log(` shown: ${result.shown}`);
1338
+ if (result.button) console.log(` button: ${result.button}`);
1339
+ if (result.appearance?.style) console.log(` appearance: ${result.appearance.style}${result.appearance.color ? ` ${result.appearance.color}` : ""}${result.appearance.shape ? ` shape:${result.appearance.shape}` : ""}${result.appearance.angleDeg !== undefined ? ` angle:${result.appearance.angleDeg}` : ""}${result.appearance.size ? ` size:${result.appearance.size}` : ""}`);
1340
+ if (result.typedText !== undefined) console.log(` typed: ${result.dryRun ? "dry run" : JSON.stringify(result.typedText || "")}`);
1341
+ if (result.transport) console.log(` transport: ${result.transport}`);
1342
+ if (result.beforeArtifact?.path) console.log(` before: ${result.beforeArtifact.path}`);
1343
+ if (result.afterArtifact?.path) console.log(` after: ${result.afterArtifact.path}`);
1344
+ });
1345
+ }
1346
+
1347
+ async function voiceCommand(subcommand?: string, ...rest: string[]): Promise<void> {
1348
+ if (subcommand !== "status" && subcommand !== "simulate" && subcommand !== "sim" && subcommand !== "intents") {
1349
+ console.log("Usage: lattices voice <subcommand>\n");
1350
+ console.log(" status Show voice provider status");
1351
+ console.log(" simulate Parse and execute a voice command");
1352
+ console.log(" intents List all available intents");
1353
+ console.log("\nExamples:");
1354
+ console.log(' lattices voice simulate "tile this left"');
1355
+ console.log(' lattices voice simulate "focus chrome" --dry-run');
1356
+ return;
1357
+ }
1358
+
1359
+ if (subcommand === "simulate" || subcommand === "sim") {
1360
+ const text = rest.join(" ");
1361
+ if (!text) {
1362
+ console.log("Usage: lattices voice simulate <text>");
1363
+ return;
1364
+ }
1365
+ }
1366
+
1367
+ await withDaemon(async ({ daemonCall }) => {
1368
+ switch (subcommand) {
1369
+ case "status": {
1370
+ const status = await daemonCall("voice.status") as any;
1371
+ console.log(`Provider: ${status.provider}`);
1372
+ console.log(`Available: ${status.available}`);
1373
+ console.log(`Listening: ${status.listening}`);
1374
+ if (status.lastTranscript) console.log(`Last: "${status.lastTranscript}"`);
1375
+ break;
1376
+ }
1377
+ case "simulate":
1378
+ case "sim": {
1379
+ const text = rest.join(" ");
1380
+ const execute = !rest.includes("--dry-run");
1381
+ const dryFlag = rest.includes("--dry-run");
1382
+ const cleanText = dryFlag ? rest.filter(r => r !== "--dry-run").join(" ") : text;
1383
+ const result = await daemonCall("voice.simulate", { text: cleanText, execute }, 15000) as any;
1384
+ if (!result.parsed) {
1385
+ console.log(`\x1b[33mNo match:\x1b[0m "${cleanText}"`);
1386
+ return;
1387
+ }
1388
+ const slots = Object.entries(result.slots || {}).map(([k,v]) => `${k}: ${v}`).join(", ");
1389
+ const conf = result.confidence ? ` (${(result.confidence * 100).toFixed(0)}%)` : "";
1390
+ console.log(`\x1b[36m${result.intent}\x1b[0m${slots ? ` ${slots}` : ""}${conf}`);
1391
+ if (result.executed) {
1392
+ console.log(`\x1b[32mExecuted\x1b[0m`);
1393
+ } else if (result.error) {
1394
+ console.log(`\x1b[31mError:\x1b[0m ${result.error}`);
1395
+ }
1396
+ break;
1397
+ }
1398
+ case "intents": {
1399
+ const intents = await daemonCall("intents.list") as any[];
1400
+ for (const intent of intents) {
1401
+ const slots = intent.slots.map((s: any) => `${s.name}:${s.type}${s.required ? "*" : ""}`).join(", ");
1402
+ console.log(` \x1b[1m${intent.intent}\x1b[0m ${intent.description}`);
1403
+ if (slots) console.log(` slots: ${slots}`);
1404
+ console.log(` e.g. "${intent.examples[0]}"`);
1405
+ console.log();
1406
+ }
1407
+ break;
1408
+ }
1409
+ }
1410
+ });
1411
+ }
1412
+
1413
+ async function assistantCommand(subcommand?: string, ...rest: string[]): Promise<void> {
1414
+ if (subcommand !== "plan") {
1415
+ console.log("Usage: lattices assistant plan <text> [--json]");
1416
+ return;
1417
+ }
1418
+
1419
+ const jsonOut = rest.includes("--json");
1420
+ const text = rest.filter((arg) => arg !== "--json").join(" ").trim();
1421
+ if (!text) {
1422
+ console.log("Usage: lattices assistant plan <text> [--json]");
1423
+ return;
1424
+ }
1425
+
1426
+ const { tryLocalAssistantPlan } = await import("./assistant-intelligence.ts");
1427
+ const result = tryLocalAssistantPlan(text) ?? {
1428
+ actions: [],
1429
+ spoken: "No local TS plan matched.",
1430
+ _meta: { source: "local-rule", matched: false },
1431
+ };
1432
+
1433
+ if (jsonOut) {
1434
+ console.log(JSON.stringify(result, null, 2));
1435
+ return;
1436
+ }
1437
+
1438
+ console.log(result.spoken);
1439
+ }
1440
+
1441
+ async function callCommand(method?: string, ...rest: string[]): Promise<void> {
1442
+ if (!method) {
1443
+ console.log("Usage: lattices call <method> [params-json]");
1444
+ console.log("\nExamples:");
1445
+ console.log(" lattices call daemon.status");
1446
+ console.log(" lattices call api.schema");
1447
+ console.log(' lattices call window.place \'{"session":"vox","placement":"left"}\'');
1448
+ return;
1449
+ }
1450
+ await withDaemon(async ({ daemonCall }) => {
1451
+ const params = rest[0] ? JSON.parse(rest[0]) : null;
1452
+ const result = await daemonCall(method, params, 15000);
1453
+ console.log(JSON.stringify(result, null, 2));
1454
+ });
1455
+ }
1456
+
1457
+ interface AppActorAsset {
1458
+ id: string;
1459
+ appName: string;
1460
+ appPath: string;
1461
+ bundleIdentifier?: string;
1462
+ iconPath: string;
1463
+ assetDir: string;
1464
+ }
1465
+
1466
+ function plistValue(plistPath: string, key: string): string | undefined {
1467
+ const value = runQuiet(`/usr/libexec/PlistBuddy -c 'Print :${esc(key)}' '${esc(plistPath)}' 2>/dev/null`);
1468
+ return value?.trim() || undefined;
1469
+ }
1470
+
1471
+ function resolveApplication(appQuery: string): string | undefined {
1472
+ const directPath = appQuery.endsWith(".app") ? resolve(appQuery) : undefined;
1473
+ if (directPath && existsSync(directPath)) return directPath.replace(/\/$/, "");
1474
+
1475
+ const script = `POSIX path of (path to application "${appleScriptString(appQuery.replace(/\.app$/i, ""))}")`;
1476
+ const fromLaunchServices = runQuiet(`osascript -e '${esc(script)}' 2>/dev/null`);
1477
+ if (fromLaunchServices) return fromLaunchServices.trim().replace(/\/$/, "");
1478
+
1479
+ const appName = appQuery.endsWith(".app") ? appQuery : `${appQuery}.app`;
1480
+ const fromFind = runQuiet(
1481
+ `find /Applications /System/Applications '${esc(resolve(homedir(), "Applications"))}' -maxdepth 5 -iname '${esc(appName)}' -print -quit 2>/dev/null`
1482
+ );
1483
+ return fromFind?.trim().replace(/\/$/, "") || undefined;
1484
+ }
1485
+
1486
+ function resolveApplicationByBundleIdentifier(bundleIdentifier: string): string | undefined {
1487
+ const script = `POSIX path of (path to application id "${appleScriptString(bundleIdentifier)}")`;
1488
+ const fromLaunchServices = runQuiet(`osascript -e '${esc(script)}' 2>/dev/null`);
1489
+ return fromLaunchServices?.trim().replace(/\/$/, "") || undefined;
1490
+ }
1491
+
1492
+ function iconPathForApplication(appPath: string): string | undefined {
1493
+ const resourcesDir = resolve(appPath, "Contents", "Resources");
1494
+ const infoPlist = resolve(appPath, "Contents", "Info.plist");
1495
+ const iconFile = plistValue(infoPlist, "CFBundleIconFile");
1496
+ const candidates: string[] = [];
1497
+ if (iconFile) {
1498
+ candidates.push(resolve(resourcesDir, iconFile));
1499
+ if (!/\.[a-z0-9]+$/i.test(iconFile)) {
1500
+ candidates.push(resolve(resourcesDir, `${iconFile}.icns`));
1501
+ }
1502
+ }
1503
+ candidates.push(
1504
+ resolve(resourcesDir, "AppIcon.icns"),
1505
+ resolve(resourcesDir, "icon.icns"),
1506
+ resolve(resourcesDir, "electron.icns")
1507
+ );
1508
+ for (const candidate of candidates) {
1509
+ if (existsSync(candidate)) return candidate;
1510
+ }
1511
+ const firstIcns = runQuiet(`find '${esc(resourcesDir)}' -maxdepth 1 -iname '*.icns' -print -quit 2>/dev/null`);
1512
+ return firstIcns?.trim() || undefined;
1513
+ }
1514
+
1515
+ function ensureAppActorAsset(appQuery: string): AppActorAsset {
1516
+ const appPath = resolveApplication(appQuery);
1517
+ if (!appPath) {
1518
+ throw new Error(`Could not find application: ${appQuery}`);
1519
+ }
1520
+
1521
+ const appName = basename(appPath, ".app");
1522
+ const iconPath = iconPathForApplication(appPath);
1523
+ if (!iconPath) {
1524
+ throw new Error(`Could not find an icon resource in ${appPath}`);
1525
+ }
1526
+
1527
+ const id = `${slugify(appName)}-icon`;
1528
+ const assetDir = resolve(homedir(), ".codex", "pets", id);
1529
+ const spritesheetPath = resolve(assetDir, "spritesheet.png");
1530
+ mkdirSync(assetDir, { recursive: true });
1531
+ run(`sips -s format png -Z 192 '${esc(iconPath)}' --out '${esc(spritesheetPath)}' >/dev/null`);
1532
+
1533
+ const metadata = {
1534
+ id,
1535
+ displayName: `${appName} Icon`,
1536
+ description: `A one-frame overlay actor made from the ${appName} application icon.`,
1537
+ spritesheetPath: "spritesheet.png",
1538
+ states: {
1539
+ idle: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1540
+ thinking: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1541
+ working: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1542
+ listening: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1543
+ waiting: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1544
+ ready: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1545
+ },
1546
+ };
1547
+ writeFileSync(resolve(assetDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`);
1548
+
1549
+ const bundleIdentifier = plistValue(resolve(appPath, "Contents", "Info.plist"), "CFBundleIdentifier");
1550
+ return { id, appName, appPath, bundleIdentifier, iconPath, assetDir };
1551
+ }
1552
+
1553
+ function ensureIconActorAsset(idSeed: string, displayName: string, iconPath: string): string {
1554
+ if (!existsSync(iconPath)) {
1555
+ throw new Error(`HUD icon does not exist: ${iconPath}`);
1556
+ }
1557
+
1558
+ const id = `${slugify(idSeed)}-hud-icon`;
1559
+ const assetDir = resolve(homedir(), ".codex", "pets", id);
1560
+ const spritesheetPath = resolve(assetDir, "spritesheet.png");
1561
+ mkdirSync(assetDir, { recursive: true });
1562
+ run(`sips -s format png -Z 192 '${esc(iconPath)}' --out '${esc(spritesheetPath)}' >/dev/null`);
1563
+
1564
+ const metadata = {
1565
+ id,
1566
+ displayName: `${displayName} HUD Icon`,
1567
+ description: `A one-frame overlay actor icon for the ${displayName} HUD.`,
1568
+ spritesheetPath: "spritesheet.png",
1569
+ states: {
1570
+ idle: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1571
+ thinking: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1572
+ working: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1573
+ listening: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1574
+ waiting: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1575
+ ready: { row: 0, frames: 1, frameWidth: 192, frameHeight: 192 },
1576
+ },
1577
+ };
1578
+ writeFileSync(resolve(assetDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`);
1579
+ return id;
1580
+ }
1581
+
1582
+ function actorUsage(): void {
1583
+ console.log(`Usage:
1584
+ lattices actor app <app-name> [message] [--state=idle] [--x=520 --y=340] [--show-label]
1585
+ lattices actor switcher [app-name ...] [--x=420 --y=220 --gap=270] [--show-label]
1586
+ lattices actor hud <actor-id> <url> [--hud-width=360 --hud-height=240]
1587
+ lattices actor show|hide|toggle|status
1588
+
1589
+ Examples:
1590
+ lattices actor app Codex "Building the release"
1591
+ lattices actor app Talkie "Hover for latest state" --hud-url=http://localhost:5173
1592
+ lattices actor hud switch-talkie http://localhost:5173
1593
+ lattices actor switcher Codex Talkie
1594
+ lattices actor toggle
1595
+ lattices actor switcher "Google Chrome" Codex Talkie --show-label --scale=0.8
1596
+ `);
1597
+ }
1598
+
1599
+ async function actorCommand(sub?: string, ...rest: string[]): Promise<void> {
1600
+ if (sub === "app") {
1601
+ await actorAppCommand(rest);
1602
+ return;
1603
+ }
1604
+ if (sub === "switcher") {
1605
+ await actorSwitcherCommand(rest);
1606
+ return;
1607
+ }
1608
+ if (sub === "hud") {
1609
+ await actorHUDCommand(rest);
1610
+ return;
1611
+ }
1612
+ if (sub === "show" || sub === "hide" || sub === "toggle" || sub === "status") {
1613
+ await actorVisibilityCommand(sub, rest);
1614
+ return;
1615
+ }
1616
+ actorUsage();
1617
+ }
1618
+
1619
+ function actorHUDOptions(rest: string[]): Record<string, unknown> {
1620
+ const hudUrl = parseFlagValue(rest, "hud-url") || parseFlagValue(rest, "hudUrl");
1621
+ const hudHTML = parseFlagValue(rest, "hud-html") || parseFlagValue(rest, "hudHTML") || parseFlagValue(rest, "hudHtml");
1622
+ const hudTitle = parseFlagValue(rest, "hud-title") || parseFlagValue(rest, "hudTitle");
1623
+ const hudWidth = parseFlagValue(rest, "hud-width") || parseFlagValue(rest, "hudWidth") || parseFlagValue(rest, "width");
1624
+ const hudHeight = parseFlagValue(rest, "hud-height") || parseFlagValue(rest, "hudHeight") || parseFlagValue(rest, "height");
1625
+ return {
1626
+ ...(hudUrl ? { hudUrl } : {}),
1627
+ ...(hudHTML ? { hudHTML } : {}),
1628
+ ...(hudTitle ? { hudTitle } : {}),
1629
+ ...(hudWidth ? { hudWidth: Number(hudWidth) } : {}),
1630
+ ...(hudHeight ? { hudHeight: Number(hudHeight) } : {}),
1631
+ };
1632
+ }
1633
+
1634
+ function shouldHideActorLabel(rest: string[]): boolean {
1635
+ if (hasFlag(rest, "show-label") || hasFlag(rest, "showLabel")) return false;
1636
+ return true;
1637
+ }
1638
+
1639
+ async function actorHUDCommand(rest: string[]): Promise<void> {
1640
+ const positional = nonFlagArgs(rest);
1641
+ const id = positional[0];
1642
+ if (!id) {
1643
+ actorUsage();
1644
+ return;
1645
+ }
1646
+
1647
+ const url = positional[1];
1648
+ const clear = hasFlag(rest, "clear");
1649
+ await withDaemon(async ({ daemonCall }) => {
1650
+ const result = await daemonCall("overlay.actor.hud", {
1651
+ id,
1652
+ clear,
1653
+ ...(url && !clear ? { hudUrl: url } : {}),
1654
+ ...actorHUDOptions(rest),
1655
+ }, 15000) as any;
1656
+
1657
+ if (hasFlag(rest, "json")) {
1658
+ console.log(JSON.stringify(result, null, 2));
1659
+ } else if (clear) {
1660
+ console.log(`Cleared HUD for ${id}.`);
1661
+ } else {
1662
+ console.log(`Attached hover HUD to ${id}.`);
1663
+ }
1664
+ });
1665
+ }
1666
+
1667
+ async function actorVisibilityCommand(action: string, rest: string[]): Promise<void> {
1668
+ await withDaemon(async ({ daemonCall }) => {
1669
+ const result = await daemonCall("overlay.actor.visibility", {
1670
+ action,
1671
+ feedback: !hasFlag(rest, "quiet") && action !== "status",
1672
+ }, 15000) as any;
1673
+
1674
+ if (hasFlag(rest, "json")) {
1675
+ console.log(JSON.stringify(result, null, 2));
1676
+ return;
1677
+ }
1678
+
1679
+ const state = result.visible ? "shown" : "hidden";
1680
+ const count = Number(result.actorCount ?? 0);
1681
+ console.log(`Actor layer ${state} (${count} actor${count === 1 ? "" : "s"}).`);
1682
+ });
1683
+ }
1684
+
1685
+ async function actorAppCommand(rest: string[]): Promise<void> {
1686
+ const positional = nonFlagArgs(rest);
1687
+ const appQuery = positional[0];
1688
+ if (!appQuery) {
1689
+ actorUsage();
1690
+ return;
1691
+ }
1692
+ const message = positional.slice(1).join(" ") || `Tap to switch to ${appQuery}.`;
1693
+ await withDaemon(async ({ daemonCall }) => {
1694
+ const asset = ensureAppActorAsset(appQuery);
1695
+ const id = parseFlagValue(rest, "id") || `app-${slugify(asset.appName)}`;
1696
+ const state = parseFlagValue(rest, "state") || "idle";
1697
+ const ttlMs = Number(parseFlagValue(rest, "ttl") || parseFlagValue(rest, "ttlMs") || 0);
1698
+ const x = Number(parseFlagValue(rest, "x") || 520);
1699
+ const y = Number(parseFlagValue(rest, "y") || 340);
1700
+ const placement = parseFlagValue(rest, "placement") || "point";
1701
+ const style = parseFlagValue(rest, "style") || "playful";
1702
+ const dismissible = hasFlag(rest, "dismissible");
1703
+ const labelHidden = shouldHideActorLabel(rest);
1704
+ const closeOnActivate = hasFlag(rest, "close-on-activate") || hasFlag(rest, "closeOnActivate");
1705
+ const scale = Number(parseFlagValue(rest, "scale") || 1);
1706
+
1707
+ const result = await daemonCall("overlay.actor.publish", {
1708
+ id,
1709
+ renderer: "sprite",
1710
+ asset: asset.id,
1711
+ state,
1712
+ name: parseFlagValue(rest, "name") || asset.appName,
1713
+ message,
1714
+ placement,
1715
+ x,
1716
+ y,
1717
+ style,
1718
+ ttlMs,
1719
+ dismissible,
1720
+ labelHidden,
1721
+ closeOnActivate,
1722
+ scale,
1723
+ ...actorHUDOptions(rest),
1724
+ targetApp: asset.appName,
1725
+ targetBundleId: asset.bundleIdentifier,
1726
+ targetAppPath: asset.appPath,
1727
+ }, 15000) as any;
1728
+
1729
+ if (!hasFlag(rest, "no-move")) {
1730
+ await daemonCall("overlay.actor.moveTo", {
1731
+ id,
1732
+ x: x + 40,
1733
+ y: y + 50,
1734
+ durationMs: 700,
1735
+ easing: "spring",
1736
+ }, 15000);
1737
+ }
1738
+
1739
+ if (hasFlag(rest, "json")) {
1740
+ console.log(JSON.stringify({ ...result, asset: asset.id, appPath: asset.appPath }, null, 2));
1741
+ } else {
1742
+ console.log(`Published ${asset.appName} actor (${id}). Click it to switch to ${asset.appName}.`);
1743
+ }
1744
+ });
1745
+ }
1746
+
1747
+ async function actorSwitcherCommand(rest: string[]): Promise<void> {
1748
+ const appNames = nonFlagArgs(rest);
1749
+ const apps = appNames.length ? appNames : ["Codex", "Talkie"];
1750
+ await withDaemon(async ({ daemonCall }) => {
1751
+ const startX = Number(parseFlagValue(rest, "x") || 420);
1752
+ const y = Number(parseFlagValue(rest, "y") || 220);
1753
+ const gap = Number(parseFlagValue(rest, "gap") || 270);
1754
+ const ttlMs = Number(parseFlagValue(rest, "ttl") || parseFlagValue(rest, "ttlMs") || 0);
1755
+ const style = parseFlagValue(rest, "style") || "info";
1756
+ const dismissible = hasFlag(rest, "dismissible");
1757
+ const labelHidden = shouldHideActorLabel(rest);
1758
+ const closeOnActivate = hasFlag(rest, "close-on-activate") || hasFlag(rest, "closeOnActivate");
1759
+ const scale = Number(parseFlagValue(rest, "scale") || 1);
1760
+ const results: any[] = [];
1761
+
1762
+ for (let i = 0; i < apps.length; i++) {
1763
+ const asset = ensureAppActorAsset(apps[i]);
1764
+ const id = `switch-${slugify(asset.appName)}`;
1765
+ const x = startX + i * gap;
1766
+ const result = await daemonCall("overlay.actor.publish", {
1767
+ id,
1768
+ renderer: "sprite",
1769
+ asset: asset.id,
1770
+ state: "ready",
1771
+ name: asset.appName,
1772
+ message: `Tap to switch to ${asset.appName}.`,
1773
+ placement: "point",
1774
+ x,
1775
+ y,
1776
+ style,
1777
+ ttlMs,
1778
+ dismissible,
1779
+ labelHidden,
1780
+ closeOnActivate,
1781
+ scale,
1782
+ ...actorHUDOptions(rest),
1783
+ targetApp: asset.appName,
1784
+ targetBundleId: asset.bundleIdentifier,
1785
+ targetAppPath: asset.appPath,
1786
+ }, 15000) as any;
1787
+ results.push({ ...result, asset: asset.id, appPath: asset.appPath });
1788
+ await daemonCall("overlay.actor.moveTo", {
1789
+ id,
1790
+ x: x + 28,
1791
+ y: y + 36,
1792
+ durationMs: 650,
1793
+ easing: "spring",
1794
+ }, 15000);
1795
+ }
1796
+
1797
+ if (hasFlag(rest, "json")) {
1798
+ console.log(JSON.stringify(results, null, 2));
1799
+ } else {
1800
+ console.log(`Published app switcher for ${apps.join(", ")}.`);
1801
+ }
1802
+ });
1803
+ }
1804
+
1805
+ type HUDPathField = string | {
1806
+ path?: string;
1807
+ format?: string;
1808
+ schema?: string;
1809
+ presentation?: string;
1810
+ title?: string;
1811
+ description?: string;
1812
+ pollMs?: number;
1813
+ };
1814
+
1815
+ interface HUDManifest {
1816
+ version?: number;
1817
+ manifestVersion?: number;
1818
+ id?: string;
1819
+ name?: string;
1820
+ bundleId?: string;
1821
+ bundleIdentifier?: string;
1822
+ app?: string;
1823
+ appPath?: string;
1824
+ icon?: string;
1825
+ entry?: string;
1826
+ readAccess?: string | string[];
1827
+ state?: HUDPathField;
1828
+ events?: HUDPathField | HUDPathField[];
1829
+ log?: HUDPathField;
1830
+ logs?: HUDPathField[];
1831
+ sources?: HUDPathField[] | Record<string, HUDPathField>;
1832
+ surface?: {
1833
+ width?: number;
1834
+ height?: number;
1835
+ title?: string;
1836
+ transparent?: boolean;
1837
+ };
1838
+ actor?: {
1839
+ id?: string;
1840
+ message?: string;
1841
+ state?: string;
1842
+ x?: number;
1843
+ y?: number;
1844
+ placement?: string;
1845
+ style?: string;
1846
+ scale?: number;
1847
+ labelHidden?: boolean;
1848
+ closeOnActivate?: boolean;
1849
+ click?: string | { type?: string };
1850
+ };
1851
+ }
1852
+
1853
+ interface ResolvedHUDManifest {
1854
+ manifestPath: string;
1855
+ rootDir: string;
1856
+ manifest: HUDManifest;
1857
+ id: string;
1858
+ name: string;
1859
+ entry: string;
1860
+ iconPath?: string;
1861
+ appPath?: string;
1862
+ bundleIdentifier?: string;
1863
+ readAccessPath?: string;
1864
+ }
1865
+
1866
+ interface HUDRegistryEntry {
1867
+ id: string;
1868
+ name?: string;
1869
+ bundleIdentifier?: string;
1870
+ manifestPath: string;
1871
+ registeredAt: string;
1872
+ lastPublishedAt?: string;
1873
+ }
1874
+
1875
+ interface HUDRegistry {
1876
+ version: 1;
1877
+ entries: HUDRegistryEntry[];
1878
+ }
1879
+
1880
+ function hudUsage(): void {
1881
+ console.log(`Usage:
1882
+ lattices hud register [manifest] [--publish] Register .lattices/hud/manifest.json
1883
+ lattices hud publish [manifest-or-id] Publish one HUD actor now
1884
+ lattices hud sync Publish all registered HUD actors
1885
+ lattices hud list List registered HUDs
1886
+ lattices hud discover [root] [--register] Find HUD manifests under a folder
1887
+
1888
+ Manifest:
1889
+ .lattices/hud/manifest.json
1890
+
1891
+ Examples:
1892
+ lattices hud register .lattices/hud/manifest.json --publish
1893
+ lattices hud publish talkie --x=520 --y=340
1894
+ lattices hud sync
1895
+ `);
1896
+ }
1897
+
1898
+ function hudRegistryPath(): string {
1899
+ return resolve(homedir(), ".lattices", "huds.json");
1900
+ }
1901
+
1902
+ function readHUDRegistry(): HUDRegistry {
1903
+ const path = hudRegistryPath();
1904
+ if (!existsSync(path)) return { version: 1, entries: [] };
1905
+ try {
1906
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<HUDRegistry>;
1907
+ return {
1908
+ version: 1,
1909
+ entries: Array.isArray(parsed.entries) ? parsed.entries : [],
1910
+ };
1911
+ } catch (e: unknown) {
1912
+ throw new Error(`Invalid HUD registry ${path}: ${(e as Error).message}`);
1913
+ }
1914
+ }
1915
+
1916
+ function writeHUDRegistry(registry: HUDRegistry): void {
1917
+ const path = hudRegistryPath();
1918
+ mkdirSync(dirname(path), { recursive: true });
1919
+ writeFileSync(path, `${JSON.stringify(registry, null, 2)}\n`);
1920
+ }
1921
+
1922
+ function isDirectory(path: string): boolean {
1923
+ try {
1924
+ return statSync(path).isDirectory();
1925
+ } catch {
1926
+ return false;
1927
+ }
1928
+ }
1929
+
1930
+ function isURLLike(value: string): boolean {
1931
+ return /^[a-z][a-z0-9+.-]*:/i.test(value);
1932
+ }
1933
+
1934
+ function resolveHUDPath(rootDir: string, value: HUDPathField | undefined, fallback?: string): string | undefined {
1935
+ const raw = typeof value === "string" ? value : value?.path;
1936
+ const path = raw || fallback;
1937
+ if (!path) return undefined;
1938
+ if (isURLLike(path)) return path;
1939
+ if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
1940
+ return isAbsolute(path) ? path : resolve(rootDir, path);
1941
+ }
1942
+
1943
+ function resolveHUDReadAccess(rootDir: string, manifest: HUDManifest, rest: string[] = []): string {
1944
+ const flagValue = parseFlagValue(rest, "read-access") || parseFlagValue(rest, "readAccess");
1945
+ const declared = flagValue
1946
+ ?? (Array.isArray(manifest.readAccess) ? manifest.readAccess[0] : manifest.readAccess);
1947
+ if (!declared) return rootDir;
1948
+ if (isURLLike(declared)) return rootDir;
1949
+ if (declared.startsWith("~/")) return resolve(homedir(), declared.slice(2));
1950
+ return isAbsolute(declared) ? declared : resolve(rootDir, declared);
1951
+ }
1952
+
1953
+ function resolveHUDManifestInput(input?: string): string {
1954
+ if (!input) {
1955
+ const defaultPath = resolve(process.cwd(), ".lattices", "hud", "manifest.json");
1956
+ if (existsSync(defaultPath)) return defaultPath;
1957
+ throw new Error("No manifest provided and .lattices/hud/manifest.json was not found.");
1958
+ }
1959
+
1960
+ const candidate = resolve(input);
1961
+ if (existsSync(candidate)) {
1962
+ return isDirectory(candidate) ? resolve(candidate, "manifest.json") : candidate;
1963
+ }
1964
+
1965
+ const registry = readHUDRegistry();
1966
+ const entry = registry.entries.find((item) => item.id === input);
1967
+ if (entry) return entry.manifestPath;
1968
+
1969
+ throw new Error(`HUD manifest or registered id not found: ${input}`);
1970
+ }
1971
+
1972
+ function readHUDManifest(input?: string): ResolvedHUDManifest {
1973
+ const manifestPath = resolveHUDManifestInput(input);
1974
+ if (!existsSync(manifestPath)) {
1975
+ throw new Error(`HUD manifest does not exist: ${manifestPath}`);
1976
+ }
1977
+
1978
+ const rootDir = dirname(manifestPath);
1979
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as HUDManifest;
1980
+ const id = manifest.actor?.id || manifest.id;
1981
+ if (!id) throw new Error(`HUD manifest is missing id: ${manifestPath}`);
1982
+
1983
+ const name = manifest.name || id;
1984
+ const entry = resolveHUDPath(rootDir, manifest.entry, "./index.html");
1985
+ if (!entry) throw new Error(`HUD manifest is missing entry: ${manifestPath}`);
1986
+ if (!isURLLike(entry) && !existsSync(entry)) {
1987
+ throw new Error(`HUD entry does not exist: ${entry}`);
1988
+ }
1989
+
1990
+ const iconPath = resolveHUDPath(rootDir, manifest.icon);
1991
+ const appPath = resolveHUDPath(rootDir, manifest.appPath)
1992
+ ?? (manifest.bundleId || manifest.bundleIdentifier
1993
+ ? resolveApplicationByBundleIdentifier(manifest.bundleId || manifest.bundleIdentifier || "")
1994
+ : undefined)
1995
+ ?? (manifest.app ? resolveApplication(manifest.app) : undefined);
1996
+ const bundleIdentifier = manifest.bundleId
1997
+ ?? manifest.bundleIdentifier
1998
+ ?? (appPath ? plistValue(resolve(appPath, "Contents", "Info.plist"), "CFBundleIdentifier") : undefined);
1999
+
2000
+ return {
2001
+ manifestPath,
2002
+ rootDir,
2003
+ manifest,
2004
+ id,
2005
+ name,
2006
+ entry,
2007
+ iconPath: iconPath && !isURLLike(iconPath) ? iconPath : undefined,
2008
+ appPath: appPath && !isURLLike(appPath) ? appPath : undefined,
2009
+ bundleIdentifier,
2010
+ readAccessPath: resolveHUDReadAccess(rootDir, manifest),
2011
+ };
2012
+ }
2013
+
2014
+ function numberFlag(rest: string[], name: string, fallback: number): number {
2015
+ const raw = parseFlagValue(rest, name);
2016
+ if (!raw) return fallback;
2017
+ const value = Number(raw);
2018
+ return Number.isFinite(value) ? value : fallback;
2019
+ }
2020
+
2021
+ function numberFlagAny(rest: string[], names: string[], fallback: number): number {
2022
+ for (const name of names) {
2023
+ const raw = parseFlagValue(rest, name);
2024
+ if (!raw) continue;
2025
+ const value = Number(raw);
2026
+ if (Number.isFinite(value)) return value;
2027
+ }
2028
+ return fallback;
2029
+ }
2030
+
2031
+ function hudActorAsset(resolved: ResolvedHUDManifest): string | undefined {
2032
+ if (resolved.iconPath) {
2033
+ return ensureIconActorAsset(resolved.id, resolved.name, resolved.iconPath);
2034
+ }
2035
+
2036
+ const appQuery = resolved.appPath || resolved.manifest.app;
2037
+ if (!appQuery) return undefined;
2038
+
2039
+ try {
2040
+ return ensureAppActorAsset(appQuery).id;
2041
+ } catch {
2042
+ return undefined;
2043
+ }
2044
+ }
2045
+
2046
+ function hudClickType(manifest: HUDManifest): string {
2047
+ const click = manifest.actor?.click;
2048
+ if (!click) return "activateApp";
2049
+ return typeof click === "string" ? click : click.type || "activateApp";
2050
+ }
2051
+
2052
+ function hudPublishPayload(resolved: ResolvedHUDManifest, rest: string[], index = 0): Record<string, unknown> {
2053
+ const manifest = resolved.manifest;
2054
+ const actor = manifest.actor ?? {};
2055
+ const surface = manifest.surface ?? {};
2056
+ const targetEnabled = hudClickType(manifest) !== "none";
2057
+ const asset = hudActorAsset(resolved);
2058
+ const x = numberFlag(rest, "x", actor.x ?? 420 + index * 112);
2059
+ const y = numberFlag(rest, "y", actor.y ?? 220);
2060
+
2061
+ return {
2062
+ id: resolved.id,
2063
+ renderer: "sprite",
2064
+ ...(asset ? { asset } : {}),
2065
+ state: parseFlagValue(rest, "state") || actor.state || "ready",
2066
+ name: parseFlagValue(rest, "name") || resolved.name,
2067
+ message: actor.message || `Hover for ${resolved.name} status.`,
2068
+ placement: parseFlagValue(rest, "placement") || actor.placement || "point",
2069
+ x,
2070
+ y,
2071
+ style: parseFlagValue(rest, "style") || actor.style || "info",
2072
+ labelHidden: actor.labelHidden ?? true,
2073
+ closeOnActivate: actor.closeOnActivate ?? false,
2074
+ scale: numberFlag(rest, "scale", actor.scale ?? 1),
2075
+ hudUrl: resolved.entry,
2076
+ hudTitle: surface.title || resolved.name,
2077
+ hudWidth: numberFlagAny(rest, ["hud-width", "hudWidth", "width"], surface.width ?? 380),
2078
+ hudHeight: numberFlagAny(rest, ["hud-height", "hudHeight", "height"], surface.height ?? 260),
2079
+ hudReadAccess: resolveHUDReadAccess(resolved.rootDir, manifest, rest),
2080
+ ...(targetEnabled && resolved.bundleIdentifier ? { targetBundleId: resolved.bundleIdentifier } : {}),
2081
+ ...(targetEnabled && resolved.appPath ? { targetAppPath: resolved.appPath } : {}),
2082
+ ...(targetEnabled && manifest.app ? { targetApp: manifest.app } : {}),
2083
+ };
2084
+ }
2085
+
2086
+ function upsertHUDRegistryEntry(resolved: ResolvedHUDManifest, published = false): HUDRegistryEntry {
2087
+ const registry = readHUDRegistry();
2088
+ const now = new Date().toISOString();
2089
+ const existing = registry.entries.find((entry) => entry.id === resolved.id);
2090
+ const next: HUDRegistryEntry = {
2091
+ id: resolved.id,
2092
+ name: resolved.name,
2093
+ bundleIdentifier: resolved.bundleIdentifier,
2094
+ manifestPath: resolved.manifestPath,
2095
+ registeredAt: existing?.registeredAt ?? now,
2096
+ lastPublishedAt: published ? now : existing?.lastPublishedAt,
2097
+ };
2098
+ registry.entries = [
2099
+ next,
2100
+ ...registry.entries.filter((entry) => entry.id !== resolved.id),
2101
+ ].sort((a, b) => a.id.localeCompare(b.id));
2102
+ writeHUDRegistry(registry);
2103
+ return next;
2104
+ }
2105
+
2106
+ async function publishHUDManifest(resolved: ResolvedHUDManifest, rest: string[], index = 0): Promise<Record<string, unknown>> {
2107
+ return withDaemon(async ({ daemonCall }) => {
2108
+ const payload = hudPublishPayload(resolved, rest, index);
2109
+ const result = await daemonCall("overlay.actor.publish", payload, 15000) as Record<string, unknown>;
2110
+ if (!hasFlag(rest, "no-move")) {
2111
+ await daemonCall("overlay.actor.moveTo", {
2112
+ id: resolved.id,
2113
+ x: Number(payload.x) + 24,
2114
+ y: Number(payload.y) + 30,
2115
+ durationMs: 600,
2116
+ easing: "spring",
2117
+ }, 15000);
2118
+ }
2119
+ upsertHUDRegistryEntry(resolved, true);
2120
+ return result;
2121
+ });
2122
+ }
2123
+
2124
+ async function hudRegisterCommand(rest: string[]): Promise<void> {
2125
+ const manifestArg = nonFlagArgs(rest)[0] || parseFlagValue(rest, "manifest");
2126
+ const resolved = readHUDManifest(manifestArg);
2127
+ const entry = upsertHUDRegistryEntry(resolved, false);
2128
+
2129
+ if (hasFlag(rest, "publish")) {
2130
+ await publishHUDManifest(resolved, rest);
2131
+ }
2132
+
2133
+ const published = hasFlag(rest, "publish");
2134
+ if (hasFlag(rest, "json")) {
2135
+ console.log(JSON.stringify(entry, null, 2));
2136
+ } else {
2137
+ console.log(`${published ? "Registered and published" : "Registered"} HUD ${resolved.id} -> ${resolved.manifestPath}`);
2138
+ }
2139
+ }
2140
+
2141
+ async function hudPublishCommand(rest: string[]): Promise<void> {
2142
+ const manifestArg = nonFlagArgs(rest)[0] || parseFlagValue(rest, "manifest");
2143
+ const resolved = readHUDManifest(manifestArg);
2144
+ const result = await publishHUDManifest(resolved, rest);
2145
+
2146
+ if (hasFlag(rest, "json")) {
2147
+ console.log(JSON.stringify({ ...result, manifestPath: resolved.manifestPath }, null, 2));
2148
+ } else {
2149
+ console.log(`Published HUD actor ${resolved.id}. Hover it for ${resolved.name}.`);
2150
+ }
2151
+ }
2152
+
2153
+ async function hudSyncCommand(rest: string[]): Promise<void> {
2154
+ const registry = readHUDRegistry();
2155
+ const results: Record<string, unknown>[] = [];
2156
+ for (let i = 0; i < registry.entries.length; i++) {
2157
+ const resolved = readHUDManifest(registry.entries[i].id);
2158
+ results.push(await publishHUDManifest(resolved, rest, i));
2159
+ }
2160
+
2161
+ if (hasFlag(rest, "json")) {
2162
+ console.log(JSON.stringify(results, null, 2));
2163
+ } else {
2164
+ console.log(`Published ${results.length} registered HUD actor${results.length === 1 ? "" : "s"}.`);
2165
+ }
2166
+ }
2167
+
2168
+ function hudListCommand(rest: string[]): void {
2169
+ const registry = readHUDRegistry();
2170
+ if (hasFlag(rest, "json")) {
2171
+ console.log(JSON.stringify(registry, null, 2));
2172
+ return;
2173
+ }
2174
+ if (!registry.entries.length) {
2175
+ console.log("No registered HUDs. Run lattices hud register .lattices/hud/manifest.json");
2176
+ return;
2177
+ }
2178
+ console.log("Registered HUDs:\n");
2179
+ for (const entry of registry.entries) {
2180
+ console.log(` ${entry.id}${entry.name ? ` (${entry.name})` : ""}`);
2181
+ console.log(` manifest: ${entry.manifestPath}`);
2182
+ if (entry.bundleIdentifier) console.log(` bundle: ${entry.bundleIdentifier}`);
2183
+ if (entry.lastPublishedAt) console.log(` shown: ${entry.lastPublishedAt}`);
2184
+ console.log();
2185
+ }
2186
+ }
2187
+
2188
+ function hudDiscoverCommand(rest: string[]): void {
2189
+ const root = resolve(nonFlagArgs(rest)[0] || parseFlagValue(rest, "root") || process.cwd());
2190
+ const maxDepth = Number(parseFlagValue(rest, "max-depth") || parseFlagValue(rest, "maxDepth") || 6);
2191
+ const out = runQuiet(`find '${esc(root)}' -maxdepth ${maxDepth} -path '*/.lattices/hud/manifest.json' -print 2>/dev/null`);
2192
+ const manifests = out ? out.split("\n").filter(Boolean) : [];
2193
+
2194
+ if (hasFlag(rest, "register")) {
2195
+ for (const manifestPath of manifests) {
2196
+ upsertHUDRegistryEntry(readHUDManifest(manifestPath), false);
2197
+ }
2198
+ }
2199
+
2200
+ if (hasFlag(rest, "json")) {
2201
+ console.log(JSON.stringify(manifests, null, 2));
2202
+ return;
2203
+ }
2204
+
2205
+ if (!manifests.length) {
2206
+ console.log(`No HUD manifests found under ${root}`);
2207
+ return;
2208
+ }
2209
+ for (const manifestPath of manifests) console.log(manifestPath);
2210
+ if (hasFlag(rest, "register")) {
2211
+ console.log(`\nRegistered ${manifests.length} HUD manifest${manifests.length === 1 ? "" : "s"}.`);
2212
+ }
2213
+ }
2214
+
2215
+ async function hudCommand(sub?: string, ...rest: string[]): Promise<void> {
2216
+ try {
2217
+ switch (sub) {
2218
+ case "register":
2219
+ await hudRegisterCommand(rest);
2220
+ return;
2221
+ case "publish":
2222
+ case "show":
2223
+ await hudPublishCommand(rest);
2224
+ return;
2225
+ case "sync":
2226
+ await hudSyncCommand(rest);
2227
+ return;
2228
+ case "list":
2229
+ case "ls":
2230
+ hudListCommand(rest);
2231
+ return;
2232
+ case "discover":
2233
+ hudDiscoverCommand(rest);
2234
+ return;
2235
+ default:
2236
+ hudUsage();
2237
+ }
2238
+ } catch (e: unknown) {
2239
+ console.log(`Error: ${(e as Error).message}`);
2240
+ }
2241
+ }
2242
+
2243
+ async function diagCommand(limit?: string): Promise<void> {
2244
+ await withDaemon(async ({ daemonCall }) => {
2245
+ const result = await daemonCall("diagnostics.list", { limit: parseInt(limit || "", 10) || 40 }) as any;
2246
+ if (!result.entries || !result.entries.length) {
2247
+ console.log("No diagnostic entries.");
2248
+ return;
2249
+ }
2250
+ for (const entry of result.entries) {
2251
+ const icon = entry.level === "success" ? "\x1b[32m✓\x1b[0m" :
2252
+ entry.level === "warning" ? "\x1b[33m⚠\x1b[0m" :
2253
+ entry.level === "error" ? "\x1b[31m✗\x1b[0m" : "›";
2254
+ console.log(` \x1b[90m${entry.time}\x1b[0m ${icon} ${entry.message}`);
2255
+ }
2256
+ });
2257
+ }
2258
+
2259
+ async function distributeCommand(rawArgs: string[] = []): Promise<void> {
2260
+ const request = parseSpaceOptimizeArgs(rawArgs, "visible");
2261
+ await optimizeWindowsCommand(request, "Distributed");
2262
+ }
2263
+
2264
+ async function tileFamilyCommand(rawArgs: string[]): Promise<void> {
2265
+ const request = parseSpaceOptimizeArgs(rawArgs, "active-app");
2266
+ await optimizeWindowsCommand(request, "Smart-tiled");
2267
+ }
2268
+
2269
+ async function daemonLsCommand(): Promise<boolean> {
2270
+ try {
2271
+ const { daemonCall, isDaemonRunning } = await getDaemonClient();
2272
+ if (!(await isDaemonRunning())) return false;
2273
+ const sessions = await daemonCall("tmux.sessions") as any[];
2274
+ if (!sessions.length) {
2275
+ console.log("No active sessions.");
2276
+ return true;
2277
+ }
2278
+
2279
+ // Annotate sessions with workspace group info
2280
+ const ws = readWorkspaceConfig();
2281
+ const sessionGroupMap = new Map<string, { group: string; tab: string }>();
2282
+ if (ws?.groups) {
2283
+ for (const g of ws.groups) {
2284
+ for (const tab of g.tabs || []) {
2285
+ const tabSession = toSessionName(resolve(tab.path));
2286
+ sessionGroupMap.set(tabSession, {
2287
+ group: g.label || g.id,
2288
+ tab: tab.label || basename(tab.path),
2289
+ });
2290
+ }
2291
+ }
2292
+ }
2293
+
2294
+ console.log("Sessions:\n");
2295
+ for (const s of sessions) {
2296
+ const info = sessionGroupMap.get(s.name);
2297
+ const groupTag = info ? ` \x1b[36m[${info.group}: ${info.tab}]\x1b[0m` : "";
2298
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
2299
+ console.log(` ${s.name} (${s.windowCount} windows)${attachTag}${groupTag}`);
2300
+ }
2301
+ return true;
2302
+ } catch {
2303
+ return false;
2304
+ }
2305
+ }
2306
+
2307
+ async function daemonStatusInventory(): Promise<boolean> {
2308
+ try {
2309
+ const { daemonCall, isDaemonRunning } = await getDaemonClient();
2310
+ if (!(await isDaemonRunning())) return false;
2311
+ const inv = await daemonCall("tmux.inventory") as any;
2312
+
2313
+ // Build managed session name set
2314
+ const managed = new Map<string, string>();
2315
+ const ws = readWorkspaceConfig();
2316
+ if (ws?.groups) {
2317
+ for (const g of ws.groups) {
2318
+ for (const tab of g.tabs || []) {
2319
+ const name = toSessionName(resolve(tab.path));
2320
+ const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
2321
+ managed.set(name, label);
2322
+ }
2323
+ }
2324
+ }
2325
+ for (const s of inv.all) {
2326
+ if (!managed.has(s.name)) {
2327
+ // Check if it matches a scanned project (via daemon)
2328
+ const projects = await daemonCall("projects.list") as any[];
2329
+ for (const p of projects) {
2330
+ managed.set(p.sessionName, p.name);
2331
+ }
2332
+ break;
2333
+ }
2334
+ }
2335
+
2336
+ const managedSessions = inv.all.filter((s: any) => managed.has(s.name));
2337
+ const orphanSessions = inv.orphans;
2338
+
2339
+ if (managedSessions.length > 0) {
2340
+ console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
2341
+ for (const s of managedSessions) {
2342
+ const label = managed.get(s.name) || s.name;
2343
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
2344
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
2345
+ for (const p of s.panes) {
2346
+ console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
2347
+ }
2348
+ console.log();
2349
+ }
2350
+ } else {
2351
+ console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
2352
+ }
2353
+
2354
+ if (orphanSessions.length > 0) {
2355
+ console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
2356
+ for (const s of orphanSessions) {
2357
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
2358
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windowCount} window${s.windowCount === 1 ? "" : "s"})${attachTag}`);
2359
+ for (const p of s.panes) {
2360
+ console.log(` ${p.title || "pane"}: ${p.currentCommand}`);
2361
+ }
2362
+ console.log();
2363
+ }
2364
+ } else {
2365
+ console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
2366
+ }
2367
+ return true;
2368
+ } catch {
2369
+ return false;
2370
+ }
2371
+ }
2372
+
2373
+ // ── OCR commands ──────────────────────────────────────────────────────
2374
+
2375
+ async function scanCommand(sub?: string, ...rest: string[]): Promise<void> {
2376
+ if (!sub || sub === "snapshot" || sub === "ls" || sub === "--full" || sub === "-f" || sub === "--json") {
2377
+ const full = sub === "--full" || sub === "-f" || rest.includes("--full") || rest.includes("-f");
2378
+ const json = sub === "--json" || rest.includes("--json");
2379
+ await withDaemon(async ({ daemonCall }) => {
2380
+ const results = await daemonCall("ocr.snapshot", null, 5000) as any[];
2381
+ if (!results.length) {
2382
+ console.log("No scan results yet. The first scan runs ~60s after launch.");
2383
+ return;
2384
+ }
2385
+ if (json) {
2386
+ console.log(JSON.stringify(results, null, 2));
2387
+ return;
2388
+ }
2389
+ console.log(`\x1b[1mScan\x1b[0m (${results.length} windows)\n`);
2390
+ for (const r of results) {
2391
+ const age = Math.round((Date.now() / 1000) - r.timestamp);
2392
+ const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.floor(age / 60)}m ago` : `${Math.floor(age / 3600)}h ago`;
2393
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
2394
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
2395
+ console.log(` \x1b[1m${r.app}\x1b[0m wid:${r.wid} ${src} \x1b[90m${ageStr}\x1b[0m`);
2396
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
2397
+ if (lines.length) {
2398
+ if (full) {
2399
+ for (const line of lines) {
2400
+ console.log(` \x1b[90m${line}\x1b[0m`);
2401
+ }
2402
+ } else {
2403
+ const maxPreview = 5;
2404
+ const preview = lines.slice(0, maxPreview).map((l: string) => l.length > 100 ? l.slice(0, 97) + "..." : l);
2405
+ for (const line of preview) {
2406
+ console.log(` \x1b[90m${line}\x1b[0m`);
2407
+ }
2408
+ if (lines.length > maxPreview) {
2409
+ console.log(` \x1b[90m… ${lines.length - maxPreview} more lines\x1b[0m`);
2410
+ }
2411
+ }
2412
+ } else {
2413
+ console.log(` \x1b[90m(no text detected)\x1b[0m`);
2414
+ }
2415
+ console.log();
2416
+ }
2417
+ });
2418
+ return;
2419
+ }
2420
+
2421
+ if (sub === "search") {
2422
+ const query = rest.join(" ");
2423
+ if (!query) {
2424
+ console.log("Usage: lattices scan search <query>");
2425
+ return;
2426
+ }
2427
+ await withDaemon(async ({ daemonCall }) => {
2428
+ const results = await daemonCall("ocr.search", { query }, 5000) as any[];
2429
+ if (!results.length) {
2430
+ console.log(`No matches for "${query}".`);
2431
+ return;
2432
+ }
2433
+ console.log(`\x1b[1mSearch\x1b[0m "${query}" (${results.length} matches)\n`);
2434
+ for (const r of results) {
2435
+ const snippet = r.snippet || r.fullText?.slice(0, 120) || "";
2436
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
2437
+ console.log(` ${src} \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
2438
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
2439
+ console.log(` ${snippet}`);
2440
+ console.log();
2441
+ }
2442
+ });
2443
+ return;
2444
+ }
2445
+
2446
+ if (sub === "recent" || sub === "log") {
2447
+ const full = rest.includes("--full") || rest.includes("-f");
2448
+ const numArg = rest.find(a => !a.startsWith("-"));
2449
+ const limit = parseInt(numArg || "", 10) || 20;
2450
+ await withDaemon(async ({ daemonCall }) => {
2451
+ const results = await daemonCall("ocr.recent", { limit }, 5000) as any[];
2452
+ if (!results.length) {
2453
+ console.log("No history yet. The first scan runs ~60s after launch.");
2454
+ return;
2455
+ }
2456
+ console.log(`\x1b[1mRecent\x1b[0m (${results.length} entries)\n`);
2457
+ for (const r of results) {
2458
+ const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
2459
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
2460
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
2461
+ console.log(` \x1b[90m${ts}\x1b[0m ${src} \x1b[1m${r.app}\x1b[0m wid:${r.wid}`);
2462
+ console.log(` \x1b[36m"${r.title || "(untitled)"}"\x1b[0m`);
2463
+ if (full) {
2464
+ for (const line of lines) {
2465
+ console.log(` \x1b[90m${line}\x1b[0m`);
2466
+ }
2467
+ } else {
2468
+ const maxPreview = 5;
2469
+ const preview = lines.slice(0, maxPreview).map((l: string) => l.length > 100 ? l.slice(0, 97) + "..." : l);
2470
+ for (const line of preview) {
2471
+ console.log(` \x1b[90m${line}\x1b[0m`);
2472
+ }
2473
+ if (lines.length > maxPreview) {
2474
+ console.log(` \x1b[90m… ${lines.length - maxPreview} more lines\x1b[0m`);
2475
+ }
2476
+ }
2477
+ console.log();
2478
+ }
2479
+ });
2480
+ return;
2481
+ }
2482
+
2483
+ if (sub === "deep" || sub === "now" || sub === "scan") {
2484
+ await withDaemon(async ({ daemonCall }) => {
2485
+ console.log("Triggering deep scan (Vision OCR)...");
2486
+ await daemonCall("ocr.scan", null, 30000);
2487
+ console.log("Done.");
2488
+ });
2489
+ return;
2490
+ }
2491
+
2492
+ if (sub === "history") {
2493
+ const wid = parseInt(rest[0], 10);
2494
+ if (isNaN(wid)) {
2495
+ console.log("Usage: lattices scan history <wid>");
2496
+ return;
2497
+ }
2498
+ await withDaemon(async ({ daemonCall }) => {
2499
+ const results = await daemonCall("ocr.history", { wid }, 5000) as any[];
2500
+ if (!results.length) {
2501
+ console.log(`No history for wid:${wid}.`);
2502
+ return;
2503
+ }
2504
+ console.log(`\x1b[1mHistory\x1b[0m wid:${wid} (${results.length} entries)\n`);
2505
+ for (const r of results) {
2506
+ const ts = new Date(r.timestamp * 1000).toLocaleTimeString();
2507
+ const src = r.source === "accessibility" ? "\x1b[33mAX\x1b[0m" : "\x1b[35mOCR\x1b[0m";
2508
+ const lines = (r.fullText || "").split("\n").filter(Boolean);
2509
+ const preview = lines.slice(0, 2).map((l: string) => l.length > 80 ? l.slice(0, 77) + "..." : l);
2510
+ console.log(` \x1b[90m${ts}\x1b[0m ${src} \x1b[1m${r.app}\x1b[0m — "${r.title}"`);
2511
+ for (const line of preview) {
2512
+ console.log(` \x1b[90m${line}\x1b[0m`);
2513
+ }
2514
+ console.log();
2515
+ }
2516
+ });
2517
+ return;
2518
+ }
2519
+
2520
+ // Unknown subcommand
2521
+ console.log(`lattices scan — Screen text recognition
2522
+
2523
+ Usage:
2524
+ lattices scan Show text from all visible windows
2525
+ lattices scan --full Full text dump
2526
+ lattices scan --json JSON output
2527
+ lattices scan search <q> Full-text search across scanned windows
2528
+ lattices scan recent [n] Show recent scans chronologically (default 20)
2529
+ lattices scan deep Trigger a deep Vision OCR scan
2530
+ lattices scan history <wid> Show scan timeline for a window
2531
+ `);
2532
+ }
2533
+
2534
+ function printUsage(): void {
2535
+ console.log(`lattices — workspace launcher for sessions, windows, layers, and the menu bar app
2536
+
2537
+ Usage:
2538
+ lattices Show workspace status and common commands
2539
+ lattices start Start or reattach the current directory's workspace
2540
+ lattices init Generate .lattices.json config for this project
2541
+ lattices ls List active sessions
2542
+ lattices status Show managed vs unmanaged session inventory
2543
+ lattices kill [name] Kill a session (defaults to current project)
2544
+ lattices sync Reconcile session to match declared config
2545
+ lattices restart [pane] Restart a pane's process (by name or index)
2546
+ lattices group [id] List tab groups or launch/attach a group
2547
+ lattices groups List all tab groups with status
2548
+ lattices tab <group> [tab] Switch tab within a group (by label or index)
2549
+ lattices search <query> Search windows by title, app, session, OCR
2550
+ lattices search <q> --deep Deep search: index + live terminal inspection
2551
+ lattices search <q> --wid Print matching window IDs only (pipeable)
2552
+ lattices search <q> --json JSON output
2553
+ lattices place <query> [pos] Deep search + focus + tile (default: bottom-right)
2554
+ lattices focus <session> Raise a session's window
2555
+ lattices windows [--json] List all desktop windows (daemon required)
2556
+ lattices sessions [--json] List active sessions via daemon
2557
+ lattices terminals [--json] [--refresh]
2558
+ List synthesized terminal instances
2559
+ lattices capture window [wid] Save a screenshot run artifact
2560
+ lattices capture record window [wid] Record a window/visible region as a .mov artifact
2561
+ lattices capture record-command --app Scout -- <cmd>
2562
+ Record a target while running an action command
2563
+ lattices capture stop <run-id> Stop a running capture recording
2564
+ lattices runs [id] [--json] List recent runs or inspect one run
2565
+ lattices computer prepare Resolve/stage a safe terminal action
2566
+ lattices computer focus-window Focus and verify a target window
2567
+ lattices computer launch-app Launch/focus a normal macOS app
2568
+ lattices computer type-window Type into a normal app window
2569
+ lattices computer click Stage or post a window-relative click
2570
+ lattices cua click CLI alias for the CUA SDK click action
2571
+ lattices computer scout Scout warm-up run for memo/demo recording
2572
+ lattices computer cursor Show a recorded cursor appearance
2573
+ lattices computer type-text Type text into a safe terminal target
2574
+ lattices computer demo-terminal Record/focus/type a safe terminal demo
2575
+ lattices tile <position> Tile the frontmost window (left, right, top, etc.)
2576
+ lattices tile family [app] [region] Smart-grid the frontmost app family, or a named app
2577
+ lattices distribute [app] [region] Smart-grid visible windows or just one app (daemon required)
2578
+ lattices layer [name|index] List layers or switch by name/index (daemon required)
2579
+ lattices layer create <name> [wid:N ...] [--json '<specs>'] Create a session layer
2580
+ lattices layer snap [name] Snapshot visible windows into a session layer
2581
+ lattices layer session [n] List or switch session layers (runtime, no restart)
2582
+ lattices layer delete <name> Delete a session layer
2583
+ lattices layer clear Clear all session layers
2584
+ lattices voice status Voice provider status
2585
+ lattices voice simulate <t> Parse and execute a voice command
2586
+ lattices voice intents List all available intents
2587
+ lattices actor app <app> [message] Show a clickable app-icon actor
2588
+ lattices actor switcher [apps...] Show a clickable app switcher row
2589
+ lattices actor hud <id> <url> Attach a hover web HUD to an actor
2590
+ lattices actor toggle Hide/show the sticky actor layer
2591
+ lattices hud register [manifest] Register a .lattices/hud/manifest.json
2592
+ lattices hud publish [id|manifest] Publish a registered/static HUD actor
2593
+ lattices assistant plan <t> Preview the TS assistant planner
2594
+ lattices call <method> [p] Raw daemon API call (params as JSON)
2595
+ lattices scan Show text from all visible windows
2596
+ lattices scan --full Full text dump
2597
+ lattices scan search <q> Full-text search across scanned windows
2598
+ lattices scan recent [n] Show recent scans chronologically
2599
+ lattices scan deep Trigger a deep Vision OCR scan
2600
+ lattices scan history <wid> Scan timeline for a specific window
2601
+ lattices dev Run dev server (auto-detected)
2602
+ lattices dev build Build the project (swift/node/rust/go/make)
2603
+ lattices dev restart Build + restart (swift app) or just build
2604
+ lattices dev placement-smoke [a] [b] Move two named sessions through verified placements
2605
+ lattices dev type Print detected project type
2606
+ lattices mouse Find mouse — sonar pulse at cursor position
2607
+ lattices mouse summon Summon mouse to screen center
2608
+ lattices daemon status Show daemon status
2609
+ lattices logs [limit] Show activity log entries (aliases: log, activity, diag)
2610
+ lattices app Launch the menu bar companion app
2611
+ lattices app update Download the latest menu bar app and relaunch
2612
+ lattices app build Rebuild the menu bar app
2613
+ lattices app restart Rebuild and relaunch the menu bar app
2614
+ lattices app quit Stop the menu bar app
2615
+ lattices help Show this help
2616
+
2617
+ Config (.lattices.json):
2618
+ Place in your project root to customize the layout:
2619
+
2620
+ {
2621
+ "ensure": true,
2622
+ "panes": [
2623
+ { "name": "shell", "size": 60 },
2624
+ { "name": "server", "cmd": "pnpm dev" },
2625
+ { "name": "tests", "cmd": "pnpm test --watch" }
2626
+ ]
2627
+ }
2628
+
2629
+ size Width % for the first pane (default: 60)
2630
+ cmd Command to run in the pane
2631
+ name Label (for your reference)
2632
+ ensure Auto-restart exited commands on reattach
2633
+ prefill Type commands into idle panes on reattach (you hit Enter)
2634
+
2635
+ Recovery:
2636
+ lattices sync Recreates missing panes, restores commands, fixes layout.
2637
+ Use when a pane was killed and you want to get back to the
2638
+ declared state without killing the whole session.
2639
+
2640
+ lattices restart Kills the process in a pane and re-runs its declared command.
2641
+ Accepts a pane name or 0-based index (default: 0 / first pane).
2642
+ Examples: lattices restart (restarts the first pane)
2643
+ lattices restart server (restarts "server" by name)
2644
+ lattices restart 1 (restarts pane at index 1)
2645
+
2646
+ Layouts:
2647
+ 1 pane → single full-width (default when no dev server detected)
2648
+ 2 panes → side-by-side split
2649
+ 3+ panes → main-vertical (first pane left, rest stacked right)
2650
+
2651
+ ┌────────────────────┐ ┌──────────┬─────────┐ ┌──────────┬─────────┐
2652
+ │ shell │ │ shell │ server │ │ shell │ server │
2653
+ │ │ │ (60%) │ (40%) │ │ (60%) ├─────────┤
2654
+ └────────────────────┘ └──────────┴─────────┘ │ │ tests │
2655
+ └──────────┴─────────┘
2656
+ `);
2657
+ }
2658
+
2659
+ function printHome(): void {
2660
+ const dir = process.cwd();
2661
+ const sessionName = toSessionName(dir);
2662
+ const config = readConfig(dir);
2663
+ const panes = resolvePanes(dir);
2664
+ const tmuxReady = hasTmux();
2665
+ const sessionRunning = tmuxReady && sessionExists(sessionName);
2666
+ const appRunning = runQuiet("pgrep -x Lattices >/dev/null 2>&1 && echo yes") === "yes";
2667
+
2668
+ console.log(`lattices — let's get you situated
2669
+
2670
+ Current directory:
2671
+ ${dir}
2672
+
2673
+ Workspace:
2674
+ session ${sessionName}
2675
+ config ${config ? ".lattices.json" : "none yet"}
2676
+ panes ${panes.map((p) => p.name || "pane").join(", ")}
2677
+ sessions ${tmuxReady ? (sessionRunning ? "running" : "ready") : "missing"}
2678
+ app ${appRunning ? "running" : "not running"}
2679
+
2680
+ Common commands:
2681
+ lattices start Start or reattach this directory's workspace
2682
+ lattices init Create a .lattices.json for this project
2683
+ lattices app Launch the menu bar app
2684
+ lattices ls List active sessions
2685
+ lattices help Show the full command reference
2686
+ `);
2687
+
2688
+ if (!tmuxReady) {
2689
+ console.log("tmux is not installed. Run: brew install tmux");
2690
+ }
2691
+ }
2692
+
2693
+ function initConfig(): void {
2694
+ const dir = process.cwd();
2695
+ const configPath = resolve(dir, ".lattices.json");
2696
+
2697
+ if (existsSync(configPath)) {
2698
+ console.log(".lattices.json already exists.");
2699
+ return;
2700
+ }
2701
+
2702
+ const panes = defaultPanes(dir);
2703
+ const config = {
2704
+ ensure: true,
2705
+ panes,
2706
+ };
2707
+
2708
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
2709
+ console.log("Created .lattices.json");
2710
+ console.log(JSON.stringify(config, null, 2));
2711
+ }
2712
+
2713
+ function listSessions(): void {
2714
+ const out = runQuiet(
2715
+ "tmux list-sessions -F '#{session_name} (#{session_windows} windows, created #{session_created_string})'"
2716
+ );
2717
+ if (!out) {
2718
+ console.log("No active sessions.");
2719
+ return;
2720
+ }
2721
+
2722
+ // Annotate sessions that belong to tab groups
2723
+ const ws = readWorkspaceConfig();
2724
+ const sessionGroupMap = new Map<string, { group: string; tab: string }>();
2725
+ if (ws?.groups) {
2726
+ for (const g of ws.groups) {
2727
+ for (const tab of g.tabs || []) {
2728
+ const tabSession = toSessionName(resolve(tab.path));
2729
+ sessionGroupMap.set(tabSession, {
2730
+ group: g.label || g.id,
2731
+ tab: tab.label || basename(tab.path),
2732
+ });
2733
+ }
2734
+ }
2735
+ }
2736
+
2737
+ const lines = out.split("\n").map((line: string) => {
2738
+ const sessionName = line.split(" ")[0];
2739
+ const info = sessionGroupMap.get(sessionName);
2740
+ return info
2741
+ ? `${line} \x1b[36m[${info.group}: ${info.tab}]\x1b[0m`
2742
+ : line;
2743
+ });
2744
+
2745
+ console.log("Sessions:\n");
2746
+ console.log(lines.join("\n"));
2747
+ }
2748
+
2749
+ function killSession(name?: string): void {
2750
+ if (!name) name = toSessionName(process.cwd());
2751
+ if (!sessionExists(name)) {
2752
+ console.log(`No session "${name}".`);
2753
+ return;
2754
+ }
2755
+ run(`tmux kill-session -t "${name}"`);
2756
+ console.log(`Killed "${name}".`);
2757
+ }
2758
+
2759
+ // ── Window tiling ────────────────────────────────────────────────────
2760
+
2761
+ interface ScreenBounds {
2762
+ x: number;
2763
+ y: number;
2764
+ w: number;
2765
+ h: number;
2766
+ }
2767
+
2768
+ function getScreenBounds(): ScreenBounds {
2769
+ // Get the visible area (excludes menu bar and dock) in AppleScript coordinates (top-left origin)
2770
+ const script = `
2771
+ tell application "Finder"
2772
+ set db to bounds of window of desktop
2773
+ end tell
2774
+ -- db = {left, top, right, bottom} of usable desktop
2775
+ return (item 1 of db) & "," & (item 2 of db) & "," & (item 3 of db) & "," & (item 4 of db)`;
2776
+ const out = runQuiet(`osascript -e '${esc(script)}'`);
2777
+ if (!out) return { x: 0, y: 25, w: 1920, h: 1055 };
2778
+ const [x, y, right, bottom] = out.split(",").map(s => parseInt(s.trim()));
2779
+ return { x, y, w: right - x, h: bottom - y };
2780
+ }
2781
+
2782
+ // Presets return AppleScript bounds: [left, top, right, bottom] within the visible area
2783
+ const tilePresets: Record<string, (s: ScreenBounds) => number[]> = {
2784
+ "left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
2785
+ "left-half": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h],
2786
+ "right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
2787
+ "right-half": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h],
2788
+ "top": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
2789
+ "top-half": (s) => [s.x, s.y, s.x + s.w, s.y + s.h / 2],
2790
+ "bottom": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
2791
+ "bottom-half": (s) => [s.x, s.y + s.h / 2, s.x + s.w, s.y + s.h],
2792
+ "top-left": (s) => [s.x, s.y, s.x + s.w / 2, s.y + s.h / 2],
2793
+ "top-right": (s) => [s.x + s.w / 2, s.y, s.x + s.w, s.y + s.h / 2],
2794
+ "bottom-left": (s) => [s.x, s.y + s.h / 2, s.x + s.w / 2, s.y + s.h],
2795
+ "bottom-right": (s) => [s.x + s.w / 2, s.y + s.h / 2, s.x + s.w, s.y + s.h],
2796
+ "maximize": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
2797
+ "max": (s) => [s.x, s.y, s.x + s.w, s.y + s.h],
2798
+ "center": (s) => {
2799
+ const mw = Math.round(s.w * 0.7);
2800
+ const mh = Math.round(s.h * 0.8);
2801
+ const mx = s.x + Math.round((s.w - mw) / 2);
2802
+ const my = s.y + Math.round((s.h - mh) / 2);
2803
+ return [mx, my, mx + mw, my + mh];
2804
+ },
2805
+ "left-third": (s) => [s.x, s.y, s.x + Math.round(s.w * 0.333), s.y + s.h],
2806
+ "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],
2807
+ "right-third": (s) => [s.x + Math.round(s.w * 0.667), s.y, s.x + s.w, s.y + s.h],
2808
+ };
2809
+
2810
+ type SpaceOptimizeScope = "visible" | "active-app" | "app";
2811
+
2812
+ interface SpaceOptimizeRequest {
2813
+ scope: SpaceOptimizeScope;
2814
+ app?: string;
2815
+ region?: string;
2816
+ }
2817
+
2818
+ function isPlacementToken(value?: string): boolean {
2819
+ if (!value) return false;
2820
+ const normalized = value.toLowerCase();
2821
+ return normalized in tilePresets || /^(?:grid:)?\d+x\d+:\d+,\d+(?:-\d+,\d+)?$/i.test(normalized);
2822
+ }
2823
+
2824
+ function parseSpaceOptimizeArgs(rawArgs: string[], defaultScope: SpaceOptimizeScope): SpaceOptimizeRequest {
2825
+ const parts = rawArgs.filter(Boolean);
2826
+ if (!parts.length) return { scope: defaultScope };
2827
+
2828
+ const last = parts[parts.length - 1];
2829
+ const region = isPlacementToken(last) ? last : undefined;
2830
+ const appParts = region ? parts.slice(0, -1) : parts;
2831
+ const app = appParts.length ? appParts.join(" ") : undefined;
2832
+
2833
+ if (app) return { scope: "app", app, region };
2834
+ return { scope: defaultScope, region };
2835
+ }
2836
+
2837
+ function formatOptimizeTarget(request: SpaceOptimizeRequest): string {
2838
+ if (request.app) return `"${request.app}"`;
2839
+ return request.scope === "active-app" ? "the frontmost app" : "all visible windows";
2840
+ }
2841
+
2842
+ async function optimizeWindowsCommand(
2843
+ request: SpaceOptimizeRequest,
2844
+ successVerb: string
2845
+ ): Promise<void> {
2846
+ await withDaemon(async ({ daemonCall }) => {
2847
+ const params: Record<string, unknown> = {
2848
+ scope: request.scope,
2849
+ strategy: "balanced",
2850
+ };
2851
+ if (request.app) params.app = request.app;
2852
+ if (request.region) params.region = request.region;
2853
+
2854
+ const result = await daemonCall("space.optimize", params) as any;
2855
+ const count = result?.windowCount ?? 0;
2856
+ const target = formatOptimizeTarget(request);
2857
+ const regionSuffix = request.region ? ` in the ${request.region} region` : "";
2858
+
2859
+ if (count === 0) {
2860
+ console.log(`No eligible windows found for ${target}${regionSuffix}.`);
2861
+ return;
2862
+ }
2863
+
2864
+ console.log(
2865
+ `${successVerb} ${count} window${count === 1 ? "" : "s"} for ${target}${regionSuffix}.`
2866
+ );
2867
+ });
2868
+ }
2869
+
2870
+ function gridTileBounds(position: string, screen: ScreenBounds): number[] | null {
2871
+ const match = position.toLowerCase().match(/^(grid:)?(\d+)x(\d+):(\d+),(\d+)(?:-(\d+),(\d+))?$/);
2872
+ if (!match) return null;
2873
+
2874
+ const oneBased = !match[1];
2875
+ const columns = Number(match[2]);
2876
+ const rows = Number(match[3]);
2877
+ let c0 = Number(match[4]);
2878
+ let r0 = Number(match[5]);
2879
+ let c1 = match[6] === undefined ? c0 : Number(match[6]);
2880
+ let r1 = match[7] === undefined ? r0 : Number(match[7]);
2881
+ if (oneBased) {
2882
+ c0 -= 1;
2883
+ r0 -= 1;
2884
+ c1 -= 1;
2885
+ r1 -= 1;
2886
+ }
2887
+ const leftCell = Math.min(c0, c1);
2888
+ const rightCell = Math.max(c0, c1);
2889
+ const topCell = Math.min(r0, r1);
2890
+ const bottomCell = Math.max(r0, r1);
2891
+
2892
+ if (
2893
+ columns <= 0 || rows <= 0 ||
2894
+ leftCell < 0 || topCell < 0 ||
2895
+ rightCell >= columns || bottomCell >= rows
2896
+ ) {
2897
+ return null;
2898
+ }
2899
+
2900
+ const cellW = screen.w / columns;
2901
+ const cellH = screen.h / rows;
2902
+ return [
2903
+ screen.x + leftCell * cellW,
2904
+ screen.y + topCell * cellH,
2905
+ screen.x + (rightCell + 1) * cellW,
2906
+ screen.y + (bottomCell + 1) * cellH,
2907
+ ];
2908
+ }
2909
+
2910
+ function tileWindow(position: string): void {
2911
+ const normalized = position.toLowerCase();
2912
+ const screen = getScreenBounds();
2913
+ const bounds = tilePresets[normalized]?.(screen) ?? gridTileBounds(normalized, screen);
2914
+ if (!bounds) {
2915
+ console.log(`Unknown position: ${position}`);
2916
+ console.log(`Available: ${Object.keys(tilePresets).filter(k => !k.includes("-half") && k !== "max").join(", ")}, grid:CxR:c,r (0-based), CxR:c,r (1-based)`);
2917
+ return;
2918
+ }
2919
+ const [x1, y1, x2, y2] = bounds.map(Math.round);
2920
+ const script = `
2921
+ tell application "System Events"
2922
+ set frontApp to name of first application process whose frontmost is true
2923
+ end tell
2924
+ tell application frontApp
2925
+ set bounds of front window to {${x1}, ${y1}, ${x2}, ${y2}}
2926
+ end tell`;
2927
+ runQuiet(`osascript -e '${esc(script)}'`);
2928
+ console.log(`Tiled → ${normalized}`);
2929
+ }
2930
+
2931
+ function createOrAttach(): void {
2932
+ const dir = process.cwd();
2933
+ const name = toSessionName(dir);
2934
+
2935
+ if (sessionExists(name)) {
2936
+ console.log(`Reattaching to "${name}"...`);
2937
+ const config = readConfig(dir);
2938
+ if (config?.ensure) {
2939
+ restoreCommands(name, dir, "ensure");
2940
+ } else if (config?.prefill) {
2941
+ restoreCommands(name, dir, "prefill");
2942
+ }
2943
+ attach(name);
2944
+ return;
2945
+ }
2946
+
2947
+ console.log(`Creating "${name}"...`);
2948
+ createSession(dir);
2949
+ attach(name);
2950
+ }
2951
+
2952
+ function attach(name: string): void {
2953
+ if (isInsideTmux()) {
2954
+ execSync(`tmux switch-client -t "${name}"`, { stdio: "inherit" });
2955
+ } else {
2956
+ execSync(`tmux attach -t "${name}"`, { stdio: "inherit" });
2957
+ }
2958
+ }
2959
+
2960
+ // ── Status / Inventory ───────────────────────────────────────────────
2961
+
2962
+ function statusInventory(): void {
2963
+ // Query all tmux sessions
2964
+ const sessionsRaw = runQuiet(
2965
+ 'tmux list-sessions -F "#{session_name}\t#{session_windows}\t#{session_attached}"'
2966
+ );
2967
+ if (!sessionsRaw) {
2968
+ console.log("No active sessions.");
2969
+ return;
2970
+ }
2971
+
2972
+ // Query all panes
2973
+ const panesRaw = runQuiet(
2974
+ 'tmux list-panes -a -F "#{session_name}\t#{pane_title}\t#{pane_current_command}"'
2975
+ );
2976
+
2977
+ // Parse panes grouped by session
2978
+ const panesBySession = new Map<string, { title: string; cmd: string }[]>();
2979
+ if (panesRaw) {
2980
+ for (const line of panesRaw.split("\n").filter(Boolean)) {
2981
+ const [sess, title, cmd] = line.split("\t");
2982
+ if (!panesBySession.has(sess)) panesBySession.set(sess, []);
2983
+ panesBySession.get(sess)!.push({ title, cmd });
2984
+ }
2985
+ }
2986
+
2987
+ // Build managed session name set
2988
+ const managed = new Map<string, string>(); // name -> label
2989
+
2990
+ // From workspace groups
2991
+ const ws = readWorkspaceConfig();
2992
+ if (ws?.groups) {
2993
+ for (const g of ws.groups) {
2994
+ for (const tab of g.tabs || []) {
2995
+ const name = toSessionName(resolve(tab.path));
2996
+ const label = `${g.label || g.id}: ${tab.label || basename(tab.path)}`;
2997
+ managed.set(name, label);
2998
+ }
2999
+ }
3000
+ }
3001
+
3002
+ // From scanning .lattices.json files
3003
+ const scanRoot =
3004
+ process.env.LATTICE_SCAN_ROOT ||
3005
+ resolve(homedir(), "dev");
3006
+ const findResult = runQuiet(
3007
+ `find "${scanRoot}" -name .lattices.json -maxdepth 3 -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null`
3008
+ );
3009
+ if (findResult) {
3010
+ for (const configPath of findResult.split("\n").filter(Boolean)) {
3011
+ const dir = resolve(configPath, "..");
3012
+ const name = toSessionName(dir);
3013
+ if (!managed.has(name)) {
3014
+ managed.set(name, basename(dir));
3015
+ }
3016
+ }
3017
+ }
3018
+
3019
+ // Parse sessions and classify
3020
+ const sessions = sessionsRaw.split("\n").filter(Boolean).map((line: string) => {
3021
+ const [name, windows, attached] = line.split("\t");
3022
+ return { name, windows: parseInt(windows) || 1, attached: attached !== "0" };
3023
+ });
3024
+
3025
+ const managedSessions = sessions.filter((s) => managed.has(s.name));
3026
+ const orphanSessions = sessions.filter((s) => !managed.has(s.name));
3027
+
3028
+ // Print managed
3029
+ if (managedSessions.length > 0) {
3030
+ console.log(`\x1b[32m●\x1b[0m Managed Sessions (${managedSessions.length})\n`);
3031
+ for (const s of managedSessions) {
3032
+ const label = managed.get(s.name);
3033
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
3034
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag} \x1b[36m[${label}]\x1b[0m`);
3035
+ const panes = panesBySession.get(s.name) || [];
3036
+ for (const p of panes) {
3037
+ const name = p.title || "pane";
3038
+ console.log(` ${name}: ${p.cmd}`);
3039
+ }
3040
+ console.log();
3041
+ }
3042
+ } else {
3043
+ console.log("\x1b[90m○\x1b[0m No managed sessions running.\n");
3044
+ }
3045
+
3046
+ // Print orphans
3047
+ if (orphanSessions.length > 0) {
3048
+ console.log(`\x1b[33m○\x1b[0m Unmanaged Sessions (${orphanSessions.length})\n`);
3049
+ for (const s of orphanSessions) {
3050
+ const attachTag = s.attached ? " \x1b[33m[attached]\x1b[0m" : "";
3051
+ console.log(` \x1b[1m${s.name}\x1b[0m (${s.windows} window${s.windows === 1 ? "" : "s"})${attachTag}`);
3052
+ const panes = panesBySession.get(s.name) || [];
3053
+ for (const p of panes) {
3054
+ const name = p.title || "pane";
3055
+ console.log(` ${name}: ${p.cmd}`);
3056
+ }
3057
+ console.log();
3058
+ }
3059
+ } else {
3060
+ console.log("\x1b[90m○\x1b[0m No unmanaged sessions.\n");
3061
+ }
3062
+ }
3063
+
3064
+ // ── Main ─────────────────────────────────────────────────────────────
3065
+
3066
+ requireTmux(command);
3067
+
3068
+ switch (command) {
3069
+ case undefined:
3070
+ printHome();
3071
+ break;
3072
+ case "start":
3073
+ case "tmux":
3074
+ createOrAttach();
3075
+ break;
3076
+ case "init":
3077
+ initConfig();
3078
+ break;
3079
+ case "ls":
3080
+ case "list":
3081
+ // Try daemon first, fall back to direct tmux
3082
+ if (!(await daemonLsCommand())) {
3083
+ listSessions();
3084
+ }
3085
+ break;
3086
+ case "kill":
3087
+ case "rm":
3088
+ killSession(args[1]);
3089
+ break;
3090
+ case "sync":
3091
+ case "reconcile":
3092
+ syncSession();
3093
+ break;
3094
+ case "restart":
3095
+ case "respawn":
3096
+ restartPane(args[1]);
3097
+ break;
3098
+ case "group":
3099
+ groupCommand(args[1]);
3100
+ break;
3101
+ case "groups":
3102
+ listGroups();
3103
+ break;
3104
+ case "tab":
3105
+ tabCommand(args[1], args[2]);
3106
+ break;
3107
+ case "status":
3108
+ case "inventory":
3109
+ // Try daemon first, fall back to direct tmux
3110
+ if (!(await daemonStatusInventory())) {
3111
+ statusInventory();
3112
+ }
3113
+ break;
3114
+ case "distribute":
3115
+ await distributeCommand(args.slice(1));
3116
+ break;
3117
+ case "tile":
3118
+ case "t":
3119
+ if (args[1] === "family" || args[1] === "app") {
3120
+ await tileFamilyCommand(args.slice(2));
3121
+ } else if (args[1] === "all") {
3122
+ await distributeCommand(args.slice(2));
3123
+ } else if (args[1]) {
3124
+ tileWindow(args[1]);
3125
+ } else {
3126
+ console.log("Usage:");
3127
+ console.log(" lattices tile <position>");
3128
+ console.log(" lattices tile family [app-name] [region]");
3129
+ console.log(" lattices tile all [app-name] [region]\n");
3130
+ console.log("Examples:");
3131
+ console.log(" lattices tile left");
3132
+ console.log(" lattices tile family");
3133
+ console.log(" lattices tile family right");
3134
+ console.log(" lattices tile family iTerm2");
3135
+ console.log(" lattices tile all Google Chrome left\n");
3136
+ console.log("Positions: left, right, top, bottom, top-left, top-right,");
3137
+ console.log(" bottom-left, bottom-right, maximize, center,");
3138
+ console.log(" left-third, center-third, right-third");
3139
+ }
3140
+ break;
3141
+ case "windows":
3142
+ await windowsCommand(args[1] === "--json");
3143
+ break;
3144
+ case "window":
3145
+ if (args[1] === "assign") {
3146
+ await windowAssignCommand(args[2], args[3]);
3147
+ } else if (args[1] === "map") {
3148
+ await windowLayerMapCommand(args[2] === "--json");
3149
+ } else {
3150
+ console.log("Usage:");
3151
+ console.log(" lattices window assign <wid> <layer-id> Tag a window to a layer");
3152
+ console.log(" lattices window map [--json] Show all layer tags");
3153
+ }
3154
+ break;
3155
+ case "search":
3156
+ case "s":
3157
+ await searchCommand(args[1], new Set(args.slice(2)), args.slice(2));
3158
+ break;
3159
+ case "focus":
3160
+ await focusCommand(args[1]);
3161
+ break;
3162
+ case "place":
3163
+ await placeCommand(args[1], args[2]);
3164
+ break;
3165
+ case "sessions":
3166
+ await sessionsCommand(args[1] === "--json");
3167
+ break;
3168
+ case "terminals":
3169
+ await terminalsCommand(args.slice(1));
3170
+ break;
3171
+ case "capture":
3172
+ await captureCommand(args[1], ...args.slice(2));
3173
+ break;
3174
+ case "runs":
3175
+ await runsCommand(args.slice(1));
3176
+ break;
3177
+ case "run":
3178
+ await runsCommand(args.slice(1));
3179
+ break;
3180
+ case "computer":
3181
+ await computerCommand(args[1], ...args.slice(2));
3182
+ break;
3183
+ case "cua":
3184
+ await computerCommand(args[1], ...args.slice(2));
3185
+ break;
3186
+ case "voice":
3187
+ await voiceCommand(args[1], ...args.slice(2));
3188
+ break;
3189
+ case "actor":
3190
+ case "actors":
3191
+ await actorCommand(args[1], ...args.slice(2));
3192
+ break;
3193
+ case "hud":
3194
+ case "huds":
3195
+ await hudCommand(args[1], ...args.slice(2));
3196
+ break;
3197
+ case "assistant":
3198
+ await assistantCommand(args[1], ...args.slice(2));
3199
+ break;
3200
+ case "call":
3201
+ await callCommand(args[1], ...args.slice(2));
3202
+ break;
3203
+ case "layer":
3204
+ case "layers":
3205
+ await layerCommand(args[1], ...args.slice(2));
3206
+ break;
3207
+ case "diag":
3208
+ case "diagnostics":
3209
+ case "log":
3210
+ case "logs":
3211
+ case "activity":
3212
+ await diagCommand(args[1]);
3213
+ break;
3214
+ case "scan":
3215
+ case "ocr":
3216
+ await scanCommand(args[1], ...args.slice(2));
3217
+ break;
3218
+ case "mouse":
3219
+ await mouseCommand(args[1]);
3220
+ break;
3221
+ case "daemon":
3222
+ if (args[1] === "status") {
3223
+ await daemonStatusCommand();
3224
+ } else {
3225
+ console.log("Usage: lattices daemon status");
3226
+ }
3227
+ break;
3228
+ case "dev":
3229
+ await devCommand(args[1], ...args.slice(2));
3230
+ break;
3231
+ case "app": {
3232
+ const { execFileSync } = await import("node:child_process");
3233
+ const dir = process.cwd();
3234
+ const first = args[1];
3235
+ const appSubcommand = first && !first.startsWith("-") ? first : "launch";
3236
+ const appFlags = first && !first.startsWith("-") ? args.slice(2) : args.slice(1);
3237
+ const devAppCommands = new Set(["launch", "start", "build", "restart", "quit", "stop"]);
3238
+
3239
+ if (detectProjectType(dir) === "lattices-app" && devAppCommands.has(appSubcommand)) {
3240
+ console.log("Using local dev app bundle so macOS permissions stay attached across rebuilds.");
3241
+ await forwardToLatticesDevHelper(dir, appSubcommand, appFlags);
3242
+ break;
3243
+ }
3244
+
3245
+ // Forward release/package app commands to lattices-app script.
3246
+ const appScript = resolve(import.meta.dir, "lattices-app.ts");
3247
+ try {
3248
+ execFileSync("bun", [appScript, ...args.slice(1)], { stdio: "inherit" });
3249
+ } catch { /* exit code forwarded */ }
3250
+ break;
3251
+ }
3252
+ case "-h":
3253
+ case "--help":
3254
+ case "help":
3255
+ printUsage();
3256
+ break;
3257
+ default:
3258
+ console.log(`Unknown command: ${command}`);
3259
+ console.log("Run `lattices help` for the full command reference.");
3260
+ }