@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.
- package/LICENSE +21 -0
- package/README.md +172 -86
- package/apps/mac/Info.plist +43 -0
- package/apps/mac/Lattices.app/Contents/Info.plist +43 -0
- package/apps/mac/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/apps/mac/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/apps/mac/Lattices.app/Contents/Resources/docs/assistant-knowledge.md +130 -0
- package/apps/mac/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/apps/mac/Lattices.app/Contents/_CodeSignature/CodeResources +150 -0
- package/apps/mac/Lattices.entitlements +21 -0
- package/apps/mac/Resources/Pets/assistant-spark/pet.json +62 -0
- package/apps/mac/Resources/Pets/assistant-spark/spritesheet.webp +0 -0
- package/apps/mac/Resources/Pets/scout-ranger/pet.json +6 -0
- package/apps/mac/Resources/Pets/scout-ranger/spritesheet.webp +0 -0
- package/apps/mac/Resources/tap.wav +0 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/assistant-intelligence.ts +912 -0
- package/bin/cli/capture.ts +252 -0
- package/bin/cli/daemon.ts +22 -0
- package/bin/cli/helpers.ts +105 -0
- package/bin/cli/layer.ts +178 -0
- package/bin/cli/runs.ts +43 -0
- package/bin/cli/search.ts +141 -0
- package/bin/cli/session.ts +32 -0
- package/bin/client.ts +17 -0
- package/bin/cua.ts +26 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +96 -0
- package/bin/handsoff-worker.ts +531 -0
- package/bin/infer.ts +424 -0
- package/bin/keychain.ts +75 -0
- package/bin/lattices-app.ts +655 -0
- package/bin/lattices-build +125 -0
- package/bin/lattices-build-env.ts +77 -0
- package/bin/lattices-dev +362 -0
- package/bin/lattices.ts +3260 -0
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -0
- package/docs/agents.md +233 -0
- package/docs/ai-chat-ux-review.md +416 -0
- package/docs/api.md +1041 -47
- package/docs/app.md +96 -13
- package/docs/assistant-knowledge.md +130 -0
- package/docs/companion-deck.md +209 -0
- package/docs/component-extraction-roadmap.md +392 -0
- package/docs/concepts.md +13 -12
- package/docs/config.md +83 -10
- package/docs/gesture-customization-proposal.md +520 -0
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/hyperspace-grid-snappiness.md +210 -0
- package/docs/layers.md +176 -28
- package/docs/mouse-gestures.md +244 -0
- package/docs/ocr.md +21 -9
- package/docs/overview.md +42 -23
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +382 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/proposals/LAT-001-gesture-visual-customization.md +522 -0
- package/docs/proposals/LAT-002-shared-overlay-canvas.md +353 -0
- package/docs/proposals/LAT-003-menu-bar-controller-architecture.md +291 -0
- package/docs/proposals/LAT-004-interactive-overlay-actors.md +534 -0
- package/docs/proposals/LAT-005-action-runtime-product-spine.md +914 -0
- package/docs/proposals/LAT-006-followup-gaps.md +103 -0
- package/docs/proposals/LAT-006-runs-and-capture-in-lattices.md +566 -0
- package/docs/proposals/LAT-007-unified-app-shell.md +128 -0
- package/docs/quickstart.md +8 -12
- package/docs/reference/dewey.config.ts +74 -0
- package/docs/reference/install-agent.md +79 -0
- package/docs/release.md +172 -0
- package/docs/repo-structure.md +100 -0
- package/docs/terminal-kit.md +87 -0
- package/docs/tiling-reference.md +224 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice-error-model.md +73 -0
- package/docs/voice.md +221 -0
- package/package.json +69 -16
- package/packages/npm/sdk/cua.d.mts +1 -0
- package/packages/npm/sdk/cua.d.ts +188 -0
- package/packages/npm/sdk/cua.mjs +376 -0
- package/app/Lattices.app/Contents/Info.plist +0 -24
- package/app/Package.swift +0 -13
- package/app/Sources/ActionRow.swift +0 -61
- package/app/Sources/App.swift +0 -10
- package/app/Sources/AppDelegate.swift +0 -234
- package/app/Sources/AppShellView.swift +0 -62
- package/app/Sources/AppTypeClassifier.swift +0 -70
- package/app/Sources/AppWindowShell.swift +0 -63
- package/app/Sources/CheatSheetHUD.swift +0 -332
- package/app/Sources/CommandModeState.swift +0 -1362
- package/app/Sources/CommandModeView.swift +0 -1405
- package/app/Sources/CommandModeWindow.swift +0 -192
- package/app/Sources/CommandPaletteView.swift +0 -307
- package/app/Sources/CommandPaletteWindow.swift +0 -134
- package/app/Sources/DaemonProtocol.swift +0 -101
- package/app/Sources/DaemonServer.swift +0 -414
- package/app/Sources/DesktopModel.swift +0 -121
- package/app/Sources/DesktopModelTypes.swift +0 -71
- package/app/Sources/DiagnosticLog.swift +0 -271
- package/app/Sources/EventBus.swift +0 -30
- package/app/Sources/HotkeyManager.swift +0 -250
- package/app/Sources/HotkeyStore.swift +0 -338
- package/app/Sources/InventoryManager.swift +0 -35
- package/app/Sources/InventoryPath.swift +0 -43
- package/app/Sources/KeyRecorderView.swift +0 -210
- package/app/Sources/LatticesApi.swift +0 -1125
- package/app/Sources/MainView.swift +0 -467
- package/app/Sources/MainWindow.swift +0 -83
- package/app/Sources/OcrModel.swift +0 -309
- package/app/Sources/OcrStore.swift +0 -295
- package/app/Sources/OmniSearchState.swift +0 -283
- package/app/Sources/OmniSearchView.swift +0 -288
- package/app/Sources/OmniSearchWindow.swift +0 -105
- package/app/Sources/OrphanRow.swift +0 -129
- package/app/Sources/PaletteCommand.swift +0 -419
- package/app/Sources/PermissionChecker.swift +0 -125
- package/app/Sources/Preferences.swift +0 -92
- package/app/Sources/ProcessModel.swift +0 -199
- package/app/Sources/ProcessQuery.swift +0 -151
- package/app/Sources/Project.swift +0 -28
- package/app/Sources/ProjectRow.swift +0 -368
- package/app/Sources/ProjectScanner.swift +0 -121
- package/app/Sources/ScreenMapState.swift +0 -2387
- package/app/Sources/ScreenMapView.swift +0 -2820
- package/app/Sources/ScreenMapWindowController.swift +0 -89
- package/app/Sources/SessionManager.swift +0 -72
- package/app/Sources/SettingsView.swift +0 -1053
- package/app/Sources/SettingsWindow.swift +0 -20
- package/app/Sources/TabGroupRow.swift +0 -178
- package/app/Sources/Terminal.swift +0 -259
- package/app/Sources/TerminalQuery.swift +0 -156
- package/app/Sources/TerminalSynthesizer.swift +0 -200
- package/app/Sources/Theme.swift +0 -163
- package/app/Sources/TilePickerView.swift +0 -209
- package/app/Sources/TmuxModel.swift +0 -53
- package/app/Sources/TmuxQuery.swift +0 -81
- package/app/Sources/WindowTiler.swift +0 -1755
- package/app/Sources/WorkspaceManager.swift +0 -434
- package/bin/lattices-app.js +0 -221
- package/bin/lattices.js +0 -1418
package/bin/lattices.ts
ADDED
|
@@ -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
|
+
}
|