@creativeintelligence/abbie 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/dev.js +1 -49
  2. package/bin/run.js +42 -49
  3. package/dist/cli/commands/project/add.d.ts +0 -1
  4. package/dist/cli/commands/project/add.js +16 -52
  5. package/dist/cli/commands/project/list.js +13 -93
  6. package/dist/cli/commands/project/remove.d.ts +0 -2
  7. package/dist/cli/commands/project/remove.js +11 -28
  8. package/dist/cli/commands/session/list.js +3 -12
  9. package/dist/cli/commands/session/mark-done.js +1 -7
  10. package/dist/cli/commands/session/start.d.ts +0 -1
  11. package/dist/cli/commands/session/start.js +5 -7
  12. package/dist/lib/active-sessions.d.ts +0 -12
  13. package/dist/lib/active-sessions.js +6 -175
  14. package/dist/lib/project-path.d.ts +6 -0
  15. package/dist/lib/project-path.js +21 -0
  16. package/dist/lib.d.ts +1 -2
  17. package/dist/lib.js +2 -4
  18. package/oclif.manifest.json +2569 -6368
  19. package/package.json +21 -10
  20. package/dist/cli/commands/backlog/add.d.ts +0 -22
  21. package/dist/cli/commands/backlog/add.js +0 -65
  22. package/dist/cli/commands/backlog/claim.d.ts +0 -19
  23. package/dist/cli/commands/backlog/claim.js +0 -45
  24. package/dist/cli/commands/backlog/complete.d.ts +0 -18
  25. package/dist/cli/commands/backlog/complete.js +0 -42
  26. package/dist/cli/commands/backlog/list.d.ts +0 -20
  27. package/dist/cli/commands/backlog/list.js +0 -91
  28. package/dist/cli/commands/backlog/pick.d.ts +0 -18
  29. package/dist/cli/commands/backlog/pick.js +0 -42
  30. package/dist/cli/commands/backlog/sync.d.ts +0 -24
  31. package/dist/cli/commands/backlog/sync.js +0 -109
  32. package/dist/cli/commands/daemon.d.ts +0 -56
  33. package/dist/cli/commands/daemon.js +0 -1465
  34. package/dist/cli/commands/docs/lint.d.ts +0 -18
  35. package/dist/cli/commands/docs/lint.js +0 -82
  36. package/dist/cli/commands/docs/sync.d.ts +0 -19
  37. package/dist/cli/commands/docs/sync.js +0 -76
  38. package/dist/cli/commands/gc.d.ts +0 -29
  39. package/dist/cli/commands/gc.js +0 -211
  40. package/dist/cli/commands/index.d.ts +0 -36
  41. package/dist/cli/commands/index.js +0 -228
  42. package/dist/cli/commands/panes/broker.d.ts +0 -17
  43. package/dist/cli/commands/panes/broker.js +0 -57
  44. package/dist/cli/commands/panes/pipe-sink.d.ts +0 -17
  45. package/dist/cli/commands/panes/pipe-sink.js +0 -90
  46. package/dist/cli/commands/panes/snapshot.d.ts +0 -20
  47. package/dist/cli/commands/panes/snapshot.js +0 -125
  48. package/dist/cli/commands/preview/init.d.ts +0 -25
  49. package/dist/cli/commands/preview/init.js +0 -159
  50. package/dist/cli/commands/preview/sync.d.ts +0 -23
  51. package/dist/cli/commands/preview/sync.js +0 -144
  52. package/dist/cli/commands/preview/watch.d.ts +0 -24
  53. package/dist/cli/commands/preview/watch.js +0 -153
  54. package/dist/cli/commands/resource/acquire.d.ts +0 -21
  55. package/dist/cli/commands/resource/acquire.js +0 -90
  56. package/dist/cli/commands/resource/list.d.ts +0 -15
  57. package/dist/cli/commands/resource/list.js +0 -61
  58. package/dist/cli/commands/resource/release.d.ts +0 -18
  59. package/dist/cli/commands/resource/release.js +0 -50
  60. package/dist/cli/commands/resource/wait.d.ts +0 -21
  61. package/dist/cli/commands/resource/wait.js +0 -73
  62. package/dist/cli/commands/session/view.d.ts +0 -24
  63. package/dist/cli/commands/session/view.js +0 -145
  64. package/dist/cli/commands/start.d.ts +0 -37
  65. package/dist/cli/commands/start.js +0 -234
  66. package/dist/cli/commands/triage/claim.d.ts +0 -23
  67. package/dist/cli/commands/triage/claim.js +0 -186
  68. package/dist/cli/commands/triage/list.d.ts +0 -22
  69. package/dist/cli/commands/triage/list.js +0 -112
  70. package/dist/cli/commands/triage/next.d.ts +0 -18
  71. package/dist/cli/commands/triage/next.js +0 -63
  72. package/dist/cli/commands/triage/pull.d.ts +0 -19
  73. package/dist/cli/commands/triage/pull.js +0 -82
  74. package/dist/cli/commands/triage/stats.d.ts +0 -16
  75. package/dist/cli/commands/triage/stats.js +0 -69
  76. package/dist/cli/commands/tunnel/list.d.ts +0 -16
  77. package/dist/cli/commands/tunnel/list.js +0 -98
  78. package/dist/cli/commands/tunnel/start.d.ts +0 -24
  79. package/dist/cli/commands/tunnel/start.js +0 -107
  80. package/dist/cli/commands/tunnel/stop.d.ts +0 -20
  81. package/dist/cli/commands/tunnel/stop.js +0 -90
  82. package/dist/cli/commands/tunnel/url.d.ts +0 -21
  83. package/dist/cli/commands/tunnel/url.js +0 -70
  84. package/dist/cli/commands/windows/context.d.ts +0 -18
  85. package/dist/cli/commands/windows/context.js +0 -326
  86. package/dist/cli/commands/windows/focus.d.ts +0 -17
  87. package/dist/cli/commands/windows/focus.js +0 -103
  88. package/dist/cli/commands/windows/list.d.ts +0 -21
  89. package/dist/cli/commands/windows/list.js +0 -172
  90. package/dist/cli/commands/windows/map.d.ts +0 -17
  91. package/dist/cli/commands/windows/map.js +0 -168
  92. package/dist/cli/commands/windows/read.d.ts +0 -21
  93. package/dist/cli/commands/windows/read.js +0 -241
  94. package/dist/cli/commands/windows/search.d.ts +0 -24
  95. package/dist/cli/commands/windows/search.js +0 -171
  96. package/dist/cli/commands/windows/show.d.ts +0 -19
  97. package/dist/cli/commands/windows/show.js +0 -165
  98. package/dist/cli/commands/windows/watch.d.ts +0 -19
  99. package/dist/cli/commands/windows/watch.js +0 -241
  100. package/dist/lib/managed-session.d.ts +0 -27
  101. package/dist/lib/managed-session.js +0 -105
  102. package/dist/lib/panes/broker.d.ts +0 -130
  103. package/dist/lib/panes/broker.js +0 -97
  104. package/dist/lib/panes/index.d.ts +0 -2
  105. package/dist/lib/panes/index.js +0 -1
  106. package/dist/lib/panes/server.d.ts +0 -17
  107. package/dist/lib/panes/server.js +0 -308
  108. package/dist/lib/preview/manager.d.ts +0 -77
  109. package/dist/lib/preview/manager.js +0 -369
  110. package/dist/lib/preview/schema.d.ts +0 -2
  111. package/dist/lib/preview/schema.js +0 -32
  112. package/dist/lib/preview/sprite.d.ts +0 -85
  113. package/dist/lib/preview/sprite.js +0 -321
  114. package/dist/lib/preview/watcher.d.ts +0 -63
  115. package/dist/lib/preview/watcher.js +0 -185
  116. package/dist/lib/project-identity.d.ts +0 -16
  117. package/dist/lib/project-identity.js +0 -75
  118. package/dist/lib/tmux/bridge.d.ts +0 -133
  119. package/dist/lib/tmux/bridge.js +0 -315
  120. package/dist/lib/tmux/context.d.ts +0 -82
  121. package/dist/lib/tmux/context.js +0 -239
  122. package/dist/lib/tmux/index.d.ts +0 -8
  123. package/dist/lib/tmux/index.js +0 -11
  124. package/dist/lib/tmux/map.d.ts +0 -57
  125. package/dist/lib/tmux/map.js +0 -198
  126. package/dist/lib/tmux/panes.d.ts +0 -27
  127. package/dist/lib/tmux/panes.js +0 -151
  128. package/dist/lib/tmux/redaction.d.ts +0 -57
  129. package/dist/lib/tmux/redaction.js +0 -152
  130. package/dist/lib/web/analytics.d.ts +0 -63
  131. package/dist/lib/web/analytics.js +0 -168
  132. package/dist/lib/web/server.d.ts +0 -26
  133. package/dist/lib/web/server.js +0 -697
  134. package/dist/lib/web/tmux-bridge.d.ts +0 -7
  135. package/dist/lib/web/tmux-bridge.js +0 -7
  136. package/dist/lib/windows/index.d.ts +0 -3
  137. package/dist/lib/windows/index.js +0 -2
  138. package/dist/lib/windows/inventory.d.ts +0 -21
  139. package/dist/lib/windows/inventory.js +0 -263
  140. package/dist/lib/windows/types.d.ts +0 -46
  141. package/dist/lib/windows/types.js +0 -1
@@ -1,1465 +0,0 @@
1
- import { execSync, spawn as spawnProcess } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
4
- import { homedir, hostname } from "node:os";
5
- import { join } from "node:path";
6
- import { api, closeClients, getHttpClient, isConvexConfigured, subscribe } from "@creativeintelligence/sdk/convex";
7
- import { Flags } from "@oclif/core";
8
- import { getActiveSessionManager } from "../../lib/active-sessions.js";
9
- import { discoverProjects, loadConfig } from "../../lib/config-loader.js";
10
- import { getDeviceId } from "../../lib/device.js";
11
- import * as nvim from "../../lib/nvim/remote.js";
12
- import { capturePane, isRunning, isSensitiveCommand, redactContent, sendKeys, } from "../../lib/tmux/index.js";
13
- import { AGENTS } from "../../lib/types.js";
14
- import * as windows from "../../lib/windows/index.js";
15
- import { ConfigSyncRunner } from "../../lib/config-sync/index.js";
16
- import { ContentSyncRunner } from "../../lib/content-sync/index.js";
17
- import { BaseCommand } from "../base-command.js";
18
- function sleep(ms, signal) {
19
- return new Promise((resolve) => {
20
- if (signal.aborted)
21
- return resolve();
22
- const timer = setTimeout(resolve, ms);
23
- signal.addEventListener("abort", () => {
24
- clearTimeout(timer);
25
- resolve();
26
- }, { once: true });
27
- });
28
- }
29
- function truncate(value, max) {
30
- if (value.length <= max)
31
- return value;
32
- return `${value.slice(0, max - 1)}…`;
33
- }
34
- function isSessionNotFoundError(err, sessionId) {
35
- const message = err instanceof Error ? err.message : String(err);
36
- return (message.includes(`Session not found: ${sessionId}`) || message.includes("Session not found"));
37
- }
38
- /** Best-effort git rev-parse HEAD in a directory. Returns undefined if not a git repo. */
39
- function getHeadSha(cwd) {
40
- try {
41
- return execSync("git rev-parse HEAD", { cwd, timeout: 3000, stdio: "pipe" }).toString().trim();
42
- }
43
- catch {
44
- return undefined;
45
- }
46
- }
47
- /** Best-effort git diff --name-status between two commits. Returns parsed file changes. */
48
- function getGitDiff(cwd, fromSha, toSha) {
49
- try {
50
- const to = toSha ?? "HEAD";
51
- const raw = execSync(`git diff --name-status ${fromSha}..${to}`, {
52
- cwd,
53
- timeout: 5000,
54
- stdio: "pipe",
55
- })
56
- .toString()
57
- .trim();
58
- if (!raw)
59
- return [];
60
- return raw.split("\n").map((line) => {
61
- const [status, ...fileParts] = line.split("\t");
62
- return { status: status || "M", file: fileParts.join("\t") };
63
- });
64
- }
65
- catch {
66
- return [];
67
- }
68
- }
69
- export default class DaemonCommand extends BaseCommand {
70
- static description = "Run daemon to sync local state to Convex";
71
- static hidden = false;
72
- static flags = {
73
- ...BaseCommand.baseFlags,
74
- interval: Flags.integer({
75
- description: "Sync interval in seconds",
76
- default: 5,
77
- }),
78
- "tmux-session": Flags.string({
79
- description: 'Tmux session to sync (default: ABBIE_TMUX_SESSION or "abbie"). Use --all-sessions to sync everything.',
80
- }),
81
- "all-sessions": Flags.boolean({
82
- description: "Sync all tmux sessions (legacy behavior; can be noisy)",
83
- default: false,
84
- }),
85
- "windows-only": Flags.boolean({
86
- description: "Only sync windows, not sessions",
87
- default: false,
88
- }),
89
- redact: Flags.string({
90
- description: "Redaction mode for pane content published to Convex",
91
- options: ["none", "balanced", "strict"],
92
- default: "balanced",
93
- }),
94
- "enable-input": Flags.boolean({
95
- description: "Enable processing remote terminal input events from Convex (DANGEROUS without a token)",
96
- default: false,
97
- }),
98
- "input-token": Flags.string({
99
- description: "Shared secret token required to accept terminal input events (recommended; otherwise input is ignored)",
100
- }),
101
- };
102
- async execute() {
103
- const { flags } = await this.parse(DaemonCommand);
104
- this.parsedFlags = flags;
105
- if (!isConvexConfigured()) {
106
- this.error("Convex not configured. Run `abbie login` to authenticate, or set CONVEX_URL env var.");
107
- }
108
- const intervalSec = flags.interval;
109
- if (!Number.isInteger(intervalSec) || intervalSec <= 0) {
110
- this.error("--interval must be a positive integer (seconds)");
111
- }
112
- const deviceId = getDeviceId();
113
- const tmuxSessionFilter = flags["all-sessions"]
114
- ? undefined
115
- : flags["tmux-session"] || process.env.ABBIE_TMUX_SESSION || "abbie";
116
- const stopController = new AbortController();
117
- const startedAt = new Date().toISOString();
118
- const sessionSyncState = new Map();
119
- let lastWindowsHash = null;
120
- const paneHashByPaneId = new Map();
121
- const bufferContentHashByKey = new Map(); // "paneId:bufnr" -> hash
122
- const inventoryByPaneId = new Map();
123
- const inputEnabled = Boolean(flags["enable-input"]);
124
- const inputToken = flags["input-token"] || process.env.AGENTS_INPUT_TOKEN || null;
125
- const inputState = {
126
- received: 0,
127
- sent: 0,
128
- skipped: 0,
129
- error: null,
130
- };
131
- const inputUnsubscribers = [];
132
- const seenInputEventIds = new Set();
133
- let processingInput = false;
134
- const pendingInputEvents = [];
135
- const enqueueInput = (id, payload) => {
136
- if (seenInputEventIds.has(id))
137
- return;
138
- seenInputEventIds.add(id);
139
- // Bound memory.
140
- if (seenInputEventIds.size > 500) {
141
- const first = seenInputEventIds.values().next().value;
142
- if (first)
143
- seenInputEventIds.delete(first);
144
- }
145
- pendingInputEvents.push({ id, payload });
146
- inputState.received++;
147
- };
148
- const sendToTmux = async (payload) => {
149
- const target = inventoryByPaneId.get(payload.paneId);
150
- if (!target) {
151
- inputState.skipped++;
152
- return;
153
- }
154
- // Split on newlines and send Enter for each newline boundary.
155
- const parts = payload.data.split(/\r\n|\r|\n/);
156
- for (let i = 0; i < parts.length; i++) {
157
- const part = parts[i];
158
- if (part.length > 0) {
159
- await sendKeys(target.window, target.pane, part, {
160
- session: target.session,
161
- literal: true,
162
- });
163
- }
164
- if (i < parts.length - 1) {
165
- // newline boundary
166
- await sendKeys(target.window, target.pane, "Enter", { session: target.session });
167
- }
168
- }
169
- inputState.sent++;
170
- };
171
- const processInputQueue = async () => {
172
- if (processingInput)
173
- return;
174
- processingInput = true;
175
- try {
176
- while (pendingInputEvents.length > 0 && !stopController.signal.aborted) {
177
- const next = pendingInputEvents.shift();
178
- if (!next)
179
- break;
180
- await sendToTmux(next.payload);
181
- }
182
- }
183
- catch (err) {
184
- inputState.error = err instanceof Error ? err.message : String(err);
185
- }
186
- finally {
187
- processingInput = false;
188
- }
189
- };
190
- const requestStop = (signal) => {
191
- if (stopController.signal.aborted)
192
- return;
193
- this.logInfo(`Daemon stopping... (${signal})`);
194
- stopController.abort();
195
- };
196
- const onSigint = () => requestStop("SIGINT");
197
- const onSigterm = () => requestStop("SIGTERM");
198
- process.on("SIGINT", onSigint);
199
- process.on("SIGTERM", onSigterm);
200
- let cmdQueueWorker = null;
201
- let sandboxCmdWorker = null;
202
- // Sync subscription state (declared outside try for finally cleanup)
203
- let configSyncUnsub = null;
204
- let contentSyncUnsub = null;
205
- try {
206
- this.logInfo(`Daemon started (deviceId=${deviceId}, interval=${intervalSec}s)`);
207
- await this.syncDeviceHeartbeat(deviceId);
208
- if (inputEnabled) {
209
- if (!inputToken) {
210
- this.logWarn("Remote input enabled but no token configured; will ignore terminal input events. Set --input-token or AGENTS_INPUT_TOKEN.");
211
- }
212
- else {
213
- this.logInfo("Remote input enabled (token gated).");
214
- }
215
- // Subscribe to a small tail of recent terminal input events. This is a temporary bus that
216
- // avoids adding new Convex API surface before auth is wired up.
217
- const unsubscribe = subscribe(api.events.listRecent, { eventType: "terminal.input", limit: 50 }, (events) => {
218
- // listRecent returns newest-first; process oldest-first.
219
- for (const evt of [...events].reverse()) {
220
- const id = evt._id;
221
- const eventId = typeof id === "string" ? id : null;
222
- if (!eventId)
223
- continue;
224
- const payload = evt.payload;
225
- if (!payload || typeof payload !== "object")
226
- continue;
227
- const p = payload;
228
- if (p.deviceId !== deviceId)
229
- continue;
230
- if (typeof p.paneId !== "string")
231
- continue;
232
- if (typeof p.data !== "string")
233
- continue;
234
- // Token gate: if inputToken configured, only accept matching events.
235
- if (inputToken && p.token !== inputToken)
236
- continue;
237
- // If token is not configured, ignore everything (fail closed).
238
- if (!inputToken)
239
- continue;
240
- enqueueInput(eventId, {
241
- deviceId: p.deviceId,
242
- paneId: p.paneId,
243
- data: p.data,
244
- token: p.token,
245
- });
246
- }
247
- void processInputQueue();
248
- });
249
- inputUnsubscribers.push(unsubscribe);
250
- }
251
- // Command queue worker - always enabled.
252
- // Process commands enqueued by the chat agent (e.g., "abbie session start").
253
- const cmdQueueState = {
254
- processed: 0,
255
- failed: 0,
256
- error: null,
257
- inFlight: false,
258
- };
259
- cmdQueueWorker = this.runCommandQueueWorker(deviceId, cmdQueueState, stopController.signal);
260
- this.logInfo("Command queue worker active");
261
- // Sandbox command worker state — processes sandbox commands from Convex.
262
- // Launched on first successful desktop heartbeat (needs connectionId).
263
- const sandboxCmdState = {
264
- processed: 0,
265
- failed: 0,
266
- error: null,
267
- };
268
- // Config sync — subscribes to Convex for MCP connections, writes agent config files.
269
- // Fires instantly on change via Convex reactive subscription.
270
- let configSyncRunner = null;
271
- let configSyncInFlight = false;
272
- let lastConfigSyncResult = undefined;
273
- // Content sync — subscribes to Convex for rules/skills/hooks, writes to ~/.abbie/.
274
- // Fires instantly on change via Convex reactive subscription.
275
- let contentSyncRunner = null;
276
- let contentSyncInFlight = false;
277
- let lastContentSyncResult = undefined;
278
- // Set up decrypt function for apiKey values (best-effort)
279
- let configSyncDecrypt;
280
- try {
281
- const { decryptIfEncrypted } = await import("@lnittman/convex-encryption");
282
- configSyncDecrypt = async (v) => {
283
- const result = await decryptIfEncrypted(v);
284
- return result ?? v;
285
- };
286
- }
287
- catch {
288
- // Encryption package not available — pass through
289
- }
290
- while (!stopController.signal.aborted) {
291
- const tickAt = new Date().toISOString();
292
- const tick = {
293
- at: tickAt,
294
- tmuxRunning: false,
295
- windows: { synced: false, count: 0 },
296
- panes: { enabled: true, synced: false, count: 0, redacted: 0 },
297
- input: {
298
- enabled: inputEnabled,
299
- received: inputState.received,
300
- sent: inputState.sent,
301
- skipped: inputState.skipped,
302
- ...(inputState.error ? { error: inputState.error } : {}),
303
- },
304
- commandQueue: {
305
- enabled: true,
306
- processed: cmdQueueState.processed,
307
- failed: cmdQueueState.failed,
308
- pending: cmdQueueState.inFlight ? 1 : 0,
309
- ...(cmdQueueState.error ? { error: cmdQueueState.error } : {}),
310
- },
311
- bufferContent: { enabled: true, synced: false, count: 0 },
312
- sessions: { enabled: !flags["windows-only"], created: 0, updated: 0 },
313
- projects: { enabled: true, synced: false, count: 0 },
314
- heartbeat: { ok: false },
315
- desktopHeartbeat: { ok: false },
316
- sandboxCommands: {
317
- enabled: this.desktopConnectionId !== null,
318
- processed: sandboxCmdState.processed,
319
- failed: sandboxCmdState.failed,
320
- activeServers: this.sandboxServerProcesses.size,
321
- ...(sandboxCmdState.error ? { error: sandboxCmdState.error } : {}),
322
- },
323
- };
324
- // Device heartbeat (best-effort)
325
- try {
326
- await this.syncDeviceHeartbeat(deviceId);
327
- tick.heartbeat.ok = true;
328
- }
329
- catch (err) {
330
- tick.heartbeat.ok = false;
331
- tick.heartbeat.error = err instanceof Error ? err.message : String(err);
332
- this.logWarn(`Device heartbeat failed: ${tick.heartbeat.error}`);
333
- }
334
- // tmux windows
335
- try {
336
- tick.tmuxRunning = await isRunning();
337
- }
338
- catch (err) {
339
- tick.tmuxRunning = false;
340
- tick.windows.error = err instanceof Error ? err.message : String(err);
341
- this.logWarn(`tmux check failed: ${tick.windows.error}`);
342
- }
343
- // Windows sync: always clear on transition to not-running, otherwise only sync on changes.
344
- try {
345
- const shouldClear = !tick.tmuxRunning && lastWindowsHash !== "[]";
346
- const shouldInventory = tick.tmuxRunning;
347
- if (shouldInventory) {
348
- const inventory = await windows.inventory({
349
- session: tmuxSessionFilter,
350
- includeNvim: true,
351
- includeAgent: true,
352
- includeBuffers: true,
353
- });
354
- // Update paneId -> window mapping for input.
355
- inventoryByPaneId.clear();
356
- for (const state of inventory) {
357
- inventoryByPaneId.set(state.window.paneId, state.window);
358
- }
359
- const syncResult = await this.syncWindows(deviceId, {
360
- lastHash: lastWindowsHash,
361
- inventory,
362
- });
363
- tick.windows.synced = syncResult.synced;
364
- tick.windows.count = syncResult.count;
365
- lastWindowsHash = syncResult.hash;
366
- if (syncResult.synced) {
367
- this.logInfo(`Synced ${syncResult.count} windows`);
368
- }
369
- // Pane snapshots: sync every tick while tmux is running.
370
- try {
371
- const panesResult = await this.syncPanes(deviceId, inventory, paneHashByPaneId, flags.redact);
372
- tick.panes.synced = panesResult.synced;
373
- tick.panes.count = panesResult.count;
374
- tick.panes.redacted = panesResult.redacted;
375
- }
376
- catch (err) {
377
- tick.panes.error = err instanceof Error ? err.message : String(err);
378
- this.logWarn(`Panes sync failed: ${tick.panes.error}`);
379
- }
380
- // Nvim buffer content: sync terminal buffer scrollback via nvim RPC
381
- try {
382
- const bufResult = await this.syncNvimBufferContent(deviceId, inventory, bufferContentHashByKey);
383
- tick.bufferContent.synced = bufResult.synced;
384
- tick.bufferContent.count = bufResult.count;
385
- if (bufResult.synced) {
386
- this.logInfo(`Synced ${bufResult.count} nvim buffers`);
387
- }
388
- }
389
- catch (err) {
390
- tick.bufferContent.error = err instanceof Error ? err.message : String(err);
391
- this.logWarn(`Buffer content sync failed: ${tick.bufferContent.error}`);
392
- }
393
- }
394
- else if (shouldClear) {
395
- const syncResult = await this.syncWindows(deviceId, { clear: true });
396
- tick.windows.synced = syncResult.synced;
397
- tick.windows.count = syncResult.count;
398
- lastWindowsHash = syncResult.hash;
399
- if (syncResult.synced) {
400
- this.logInfo("tmux not running; cleared windows");
401
- }
402
- // Best-effort: clear panes when tmux stops.
403
- try {
404
- await getHttpClient().mutation(api.panes.clearByDevice, { deviceId });
405
- paneHashByPaneId.clear();
406
- tick.panes.synced = true;
407
- tick.panes.count = 0;
408
- }
409
- catch (err) {
410
- tick.panes.error = err instanceof Error ? err.message : String(err);
411
- this.logWarn(`Panes clear failed: ${tick.panes.error}`);
412
- }
413
- }
414
- }
415
- catch (err) {
416
- tick.windows.error = err instanceof Error ? err.message : String(err);
417
- this.logWarn(`Windows sync failed: ${tick.windows.error}`);
418
- }
419
- // Session sync (best-effort)
420
- if (!flags["windows-only"]) {
421
- try {
422
- const sessionResult = await this.syncSessions(deviceId, sessionSyncState);
423
- tick.sessions.created = sessionResult.created;
424
- tick.sessions.updated = sessionResult.updated;
425
- if (sessionResult.created || sessionResult.updated) {
426
- this.logInfo(`Synced sessions (created=${sessionResult.created}, updated=${sessionResult.updated})`);
427
- }
428
- }
429
- catch (err) {
430
- tick.sessions.error = err instanceof Error ? err.message : String(err);
431
- this.logWarn(`Session sync failed: ${tick.sessions.error}`);
432
- }
433
- }
434
- // Projects sync (Convex → local cache)
435
- try {
436
- const projectsResult = await this.syncProjects(deviceId);
437
- tick.projects.synced = projectsResult.synced;
438
- tick.projects.count = projectsResult.count;
439
- if (projectsResult.synced) {
440
- this.logInfo(`Synced ${projectsResult.count} projects to local cache`);
441
- }
442
- }
443
- catch (err) {
444
- tick.projects.error = err instanceof Error ? err.message : String(err);
445
- // Don't warn every tick - user might not be logged in
446
- }
447
- // Config sync + Content sync — set up Convex subscriptions once when clerkId is available.
448
- // Subscriptions fire instantly on change (no polling). Runners are initialized lazily.
449
- if (this.clerkId && !configSyncUnsub) {
450
- const clerkId = this.clerkId;
451
- // Initialize config sync runner
452
- configSyncRunner = new ConfigSyncRunner({
453
- fetchSnapshot: async () => {
454
- return await getHttpClient().query(api.configSync.getSnapshot, { clerkId });
455
- },
456
- decrypt: configSyncDecrypt,
457
- });
458
- this.logInfo("Config sync: subscription active");
459
- // Subscribe — fires immediately with current state, then on every change
460
- configSyncUnsub = subscribe(api.configSync.getSnapshot, { clerkId }, (snapshot) => {
461
- if (configSyncInFlight)
462
- return;
463
- configSyncInFlight = true;
464
- configSyncRunner.tick({ snapshot }).then((result) => {
465
- lastConfigSyncResult = {
466
- ok: result.ok,
467
- reason: result.reason,
468
- agents: result.changes,
469
- };
470
- if (result.changes.length > 0) {
471
- const updated = result.changes.filter((c) => c.action === "updated");
472
- if (updated.length > 0) {
473
- this.logInfo(`Config sync: ${updated.map((c) => c.agent).join(", ")} updated`);
474
- }
475
- }
476
- }).catch((err) => {
477
- lastConfigSyncResult = {
478
- ok: false,
479
- reason: "error",
480
- agents: [],
481
- error: err instanceof Error ? err.message : String(err),
482
- };
483
- }).finally(() => {
484
- configSyncInFlight = false;
485
- });
486
- });
487
- // Initialize content sync runner
488
- contentSyncRunner = new ContentSyncRunner({
489
- fetchSnapshot: async () => {
490
- return await getHttpClient().query(api.contentSync.getSnapshot, { clerkId });
491
- },
492
- });
493
- this.logInfo("Content sync: subscription active");
494
- // Subscribe
495
- contentSyncUnsub = subscribe(api.contentSync.getSnapshot, { clerkId }, (snapshot) => {
496
- if (contentSyncInFlight)
497
- return;
498
- contentSyncInFlight = true;
499
- contentSyncRunner.tick({ snapshot }).then((result) => {
500
- lastContentSyncResult = {
501
- ok: result.ok,
502
- reason: result.reason,
503
- changes: result.changes,
504
- };
505
- if (result.changes.length > 0) {
506
- const actionable = result.changes.filter((c) => c.action !== "unchanged");
507
- if (actionable.length > 0) {
508
- this.logInfo(`Content sync: ${actionable.map((c) => `${c.type}/${c.name}:${c.action}`).join(", ")}`);
509
- }
510
- }
511
- }).catch((err) => {
512
- lastContentSyncResult = {
513
- ok: false,
514
- reason: "error",
515
- changes: [],
516
- error: err instanceof Error ? err.message : String(err),
517
- };
518
- }).finally(() => {
519
- contentSyncInFlight = false;
520
- });
521
- });
522
- }
523
- // Report last subscription sync results in tick
524
- if (lastConfigSyncResult) {
525
- tick.configSync = lastConfigSyncResult;
526
- }
527
- if (lastContentSyncResult) {
528
- tick.contentSync = lastContentSyncResult;
529
- }
530
- // Desktop provider heartbeat bridge (best-effort)
531
- try {
532
- await this.syncDesktopHeartbeat(deviceId);
533
- if (this.desktopProviderRegistered) {
534
- tick.desktopHeartbeat.ok = true;
535
- }
536
- }
537
- catch (err) {
538
- tick.desktopHeartbeat.ok = false;
539
- tick.desktopHeartbeat.error = err instanceof Error ? err.message : String(err);
540
- this.logWarn(`Desktop heartbeat failed: ${tick.desktopHeartbeat.error}`);
541
- }
542
- // Launch sandbox command worker once we have a connectionId
543
- if (this.desktopConnectionId && !sandboxCmdWorker) {
544
- sandboxCmdWorker = this.runSandboxCommandWorker(this.desktopConnectionId, sandboxCmdState, stopController.signal);
545
- this.logInfo("Sandbox command worker active");
546
- }
547
- // Update tick with live sandbox state
548
- tick.sandboxCommands.enabled = this.desktopConnectionId !== null;
549
- tick.sandboxCommands.processed = sandboxCmdState.processed;
550
- tick.sandboxCommands.failed = sandboxCmdState.failed;
551
- tick.sandboxCommands.activeServers = this.sandboxServerProcesses.size;
552
- if (this.ndjsonEnabled()) {
553
- this.outputNdjson([tick]);
554
- }
555
- await sleep(intervalSec * 1000, stopController.signal);
556
- }
557
- }
558
- finally {
559
- process.off("SIGINT", onSigint);
560
- process.off("SIGTERM", onSigterm);
561
- stopController.abort();
562
- try {
563
- if (cmdQueueWorker)
564
- await cmdQueueWorker;
565
- }
566
- catch {
567
- // ignore
568
- }
569
- try {
570
- if (sandboxCmdWorker)
571
- await sandboxCmdWorker;
572
- }
573
- catch {
574
- // ignore
575
- }
576
- // Kill any spawned server processes
577
- for (const [, proc] of this.sandboxServerProcesses) {
578
- try {
579
- proc.kill("SIGTERM");
580
- }
581
- catch {
582
- // ignore
583
- }
584
- }
585
- this.sandboxServerProcesses.clear();
586
- for (const unsub of inputUnsubscribers) {
587
- try {
588
- unsub();
589
- }
590
- catch {
591
- // ignore
592
- }
593
- }
594
- // Clean up sync subscriptions
595
- if (configSyncUnsub) {
596
- try {
597
- configSyncUnsub();
598
- }
599
- catch { /* ignore */ }
600
- }
601
- if (contentSyncUnsub) {
602
- try {
603
- contentSyncUnsub();
604
- }
605
- catch { /* ignore */ }
606
- }
607
- closeClients();
608
- }
609
- const stoppedAt = new Date().toISOString();
610
- const result = { status: "stopped", deviceId, startedAt, stoppedAt };
611
- if (this.ndjsonEnabled()) {
612
- this.outputNdjson([result]);
613
- }
614
- return result;
615
- }
616
- async syncWindows(deviceId, options) {
617
- if (options?.clear) {
618
- const result = await getHttpClient().mutation(api.windows.sync, { deviceId, windows: [] });
619
- return { synced: true, count: result.synced, hash: "[]" };
620
- }
621
- const states = options?.inventory ??
622
- (await windows.inventory({
623
- includeNvim: true,
624
- includeAgent: true,
625
- includeBuffers: true,
626
- }));
627
- const sorted = states.sort((a, b) => {
628
- if (a.window.session !== b.window.session)
629
- return a.window.session.localeCompare(b.window.session);
630
- if (a.window.window !== b.window.window)
631
- return a.window.window - b.window.window;
632
- return a.window.pane - b.window.pane;
633
- });
634
- // Build PID → sessionId map for agent↔session linkage
635
- const pidToSessionId = new Map();
636
- try {
637
- const activeSessions = getActiveSessionManager().list();
638
- for (const s of activeSessions) {
639
- if (s.pid && s.status === "running") {
640
- pidToSessionId.set(s.pid, s.session_id);
641
- }
642
- }
643
- }
644
- catch {
645
- // Best-effort — don't block sync if session manager fails
646
- }
647
- const windowData = sorted.map((state) => ({
648
- paneId: state.window.paneId ||
649
- `${state.window.session}:${state.window.window}.${state.window.pane}`,
650
- tmuxSession: state.window.session,
651
- windowIndex: state.window.window,
652
- windowName: state.window.windowName,
653
- paneIndex: state.window.pane,
654
- cwd: state.window.cwd || "",
655
- title: state.window.title || undefined,
656
- isActive: state.window.active,
657
- cols: state.window.size.cols,
658
- rows: state.window.size.rows,
659
- hasNvim: state.nvim?.available ?? false,
660
- nvimPid: state.nvim?.pid ?? undefined,
661
- nvimServerAddr: state.nvim?.serverAddr ?? undefined,
662
- agentType: state.agent?.type ?? undefined,
663
- agentPid: state.agent?.pid ?? undefined,
664
- sessionId: state.agent?.pid ? pidToSessionId.get(state.agent.pid) : undefined,
665
- nvimCurrentBuffer: state.nvim?.currentBuffer
666
- ? {
667
- name: state.nvim.currentBuffer.name,
668
- path: state.nvim.currentBuffer.path,
669
- modified: state.nvim.currentBuffer.modified,
670
- filetype: state.nvim.currentBuffer.filetype,
671
- lineCount: state.nvim.currentBuffer.lineCount,
672
- }
673
- : undefined,
674
- nvimBuffers: state.buffers.length > 0
675
- ? state.buffers.map((b) => ({
676
- name: b.name,
677
- path: b.path,
678
- modified: b.modified,
679
- filetype: b.filetype,
680
- lineCount: b.lineCount,
681
- }))
682
- : undefined,
683
- }));
684
- const hash = JSON.stringify(windowData);
685
- if (options?.lastHash === hash) {
686
- return { synced: false, count: windowData.length, hash };
687
- }
688
- const syncResult = await getHttpClient().mutation(api.windows.sync, {
689
- deviceId,
690
- windows: windowData,
691
- });
692
- return { synced: true, count: syncResult.synced, hash };
693
- }
694
- async syncPanes(deviceId, inventory, lastHashByPaneId, redactMode) {
695
- const updates = [];
696
- let redacted = 0;
697
- for (const state of inventory) {
698
- const paneId = state.window.paneId ||
699
- `${state.window.session}:${state.window.window}.${state.window.pane}`;
700
- const raw = await capturePane(state.window.window, state.window.pane, {
701
- session: state.window.session,
702
- lines: 100,
703
- });
704
- let content = raw;
705
- if (redactMode !== "none") {
706
- const title = state.window.title ?? "";
707
- if (title && isSensitiveCommand(title)) {
708
- content = "***REDACTED***";
709
- redacted++;
710
- }
711
- else {
712
- const result = redactContent(raw, redactMode);
713
- content = result.content;
714
- if (result.wasRedacted)
715
- redacted++;
716
- }
717
- }
718
- const hash = createHash("sha1").update(content).digest("hex");
719
- const prev = lastHashByPaneId.get(paneId);
720
- if (prev === hash)
721
- continue;
722
- lastHashByPaneId.set(paneId, hash);
723
- updates.push({ paneId, paneIndex: state.window.pane, content });
724
- }
725
- if (updates.length === 0) {
726
- return { synced: false, count: 0, redacted: 0 };
727
- }
728
- const result = await getHttpClient().mutation(api.panes.sync, {
729
- deviceId,
730
- panes: updates,
731
- });
732
- return { synced: true, count: result.total, redacted };
733
- }
734
- async syncNvimBufferContent(deviceId, inventory, hashByKey) {
735
- let totalSynced = 0;
736
- for (const state of inventory) {
737
- const serverAddr = state.nvim?.serverAddr;
738
- if (!serverAddr)
739
- continue;
740
- const paneId = state.window.paneId ||
741
- `${state.window.session}:${state.window.window}.${state.window.pane}`;
742
- const buffers = await nvim.listBuffers(serverAddr);
743
- if (!buffers || buffers.length === 0)
744
- continue;
745
- const bufferData = [];
746
- let anyChanged = false;
747
- for (const buf of buffers) {
748
- const isTerminal = buf.name.startsWith("term://");
749
- // Read last 200 lines — enough for context, bounded storage
750
- const lines = await nvim.getBufferLines(serverAddr, buf.bufnr, 1, "$");
751
- if (!lines)
752
- continue;
753
- const tail = lines.length > 200 ? lines.slice(-200) : lines;
754
- const content = tail.join("\n");
755
- // Track if content changed via hash
756
- const key = `${paneId}:${buf.bufnr}`;
757
- const hash = createHash("sha1").update(content).digest("hex");
758
- if (hashByKey.get(key) !== hash) {
759
- hashByKey.set(key, hash);
760
- anyChanged = true;
761
- }
762
- bufferData.push({
763
- bufnr: buf.bufnr,
764
- name: buf.name,
765
- content,
766
- lineCount: lines.length,
767
- isTerminal,
768
- });
769
- }
770
- // Only sync if something changed (but always send ALL buffers so stale removal works)
771
- if (!anyChanged || bufferData.length === 0)
772
- continue;
773
- await getHttpClient().mutation(api.nvimBufferContent.sync, {
774
- deviceId,
775
- paneId,
776
- buffers: bufferData,
777
- });
778
- totalSynced += bufferData.length;
779
- }
780
- return { synced: totalSynced > 0, count: totalSynced };
781
- }
782
- async syncDeviceHeartbeat(deviceId) {
783
- await getHttpClient().mutation(api.devices.upsert, {
784
- deviceId,
785
- hostname: hostname(),
786
- platform: process.platform,
787
- metadata: {
788
- user: process.env.USER || process.env.LOGNAME || undefined,
789
- version: this.config.version || undefined,
790
- },
791
- });
792
- }
793
- async syncDesktopHeartbeat(deviceId) {
794
- if (!this.desktopProviderRegistered)
795
- return;
796
- if (!this.clerkId) {
797
- throw new Error("Desktop provider registered, but clerkId is missing");
798
- }
799
- if (!this.desktopProviderId) {
800
- throw new Error("Desktop provider registered, but providerId is missing");
801
- }
802
- const result = await getHttpClient().mutation(api["functions/sandbox/desktop"].heartbeat, {
803
- clerkId: this.clerkId,
804
- providerId: this.desktopProviderId,
805
- machineId: deviceId,
806
- machineName: hostname(),
807
- platform: process.platform,
808
- });
809
- if (result?.connectionId) {
810
- this.desktopConnectionId = result.connectionId;
811
- }
812
- }
813
- async syncSessions(deviceId, stateBySessionId) {
814
- const manager = getActiveSessionManager();
815
- // Avoid scanning huge histories every tick.
816
- // Note: manager.list() already reaps orphaned terminal-mode sessions
817
- // (marks them "failed" if their tmux pane is gone — see isTmuxPaneAlive check).
818
- const sessions = manager.list().slice(0, 200);
819
- let created = 0;
820
- let updated = 0;
821
- for (const session of sessions) {
822
- const prev = stateBySessionId.get(session.session_id);
823
- if (prev?.status === session.status) {
824
- if (session.status === "running")
825
- continue;
826
- if (prev.endedAt !== undefined)
827
- continue;
828
- }
829
- const next = {
830
- status: session.status,
831
- cwd: prev?.cwd ?? session.cwd,
832
- traceId: prev?.traceId ?? session.trace_id,
833
- sessionId: session.session_id,
834
- project: session.project,
835
- startCommitSha: prev?.startCommitSha ?? session.start_commit_sha,
836
- };
837
- if (session.status !== "running") {
838
- next.endedAt = prev?.endedAt ?? Date.now();
839
- }
840
- const updateArgs = {
841
- sessionId: session.session_id,
842
- status: session.status,
843
- pid: session.pid || undefined,
844
- endedAt: next.endedAt,
845
- metadata: session.exit_code !== undefined || session.read_only !== undefined
846
- ? {
847
- exitCode: session.exit_code,
848
- readOnly: session.read_only,
849
- }
850
- : undefined,
851
- };
852
- let updatedRemote = false;
853
- try {
854
- const res = await getHttpClient().mutation(api.sessions.update, updateArgs);
855
- // Newer backend returns `{ updated: false }` instead of throwing when missing.
856
- if (res &&
857
- typeof res === "object" &&
858
- "updated" in res &&
859
- res.updated === false) {
860
- updatedRemote = false;
861
- }
862
- else {
863
- updatedRemote = true;
864
- }
865
- }
866
- catch (err) {
867
- if (!isSessionNotFoundError(err, session.session_id)) {
868
- throw err;
869
- }
870
- updatedRemote = false;
871
- }
872
- if (updatedRemote) {
873
- // Emit file_activity on session completion (Agent Trace producer)
874
- if (prev?.status === "running" &&
875
- session.status !== "running" &&
876
- prev.startCommitSha &&
877
- prev.cwd &&
878
- prev.traceId) {
879
- try {
880
- const changes = getGitDiff(prev.cwd, prev.startCommitSha);
881
- if (changes.length > 0) {
882
- await getHttpClient().mutation(api.contextBus.publish, {
883
- traceId: prev.traceId,
884
- sessionId: session.session_id,
885
- project: session.project,
886
- type: "file_activity",
887
- title: `${changes.length} file(s) changed`,
888
- content: JSON.stringify(changes),
889
- metadata: {
890
- startCommitSha: prev.startCommitSha,
891
- endCommitSha: getHeadSha(prev.cwd),
892
- agent: session.agent,
893
- sessionStatus: session.status,
894
- },
895
- });
896
- }
897
- }
898
- catch {
899
- // Best-effort — never block session sync
900
- }
901
- }
902
- updated++;
903
- stateBySessionId.set(session.session_id, next);
904
- continue;
905
- }
906
- // Create (only if not found)
907
- const goal = session.goal?.trim();
908
- // Prefer sha captured at spawn time (from active-sessions.ts start()),
909
- // fall back to current HEAD if not available (e.g. old session format)
910
- const headSha = session.start_commit_sha || getHeadSha(session.cwd);
911
- next.startCommitSha = headSha;
912
- await getHttpClient().mutation(api.sessions.create, {
913
- sessionId: session.session_id,
914
- agent: session.agent,
915
- project: session.project,
916
- goal: goal ? truncate(goal, 1000) : undefined,
917
- issue: session.issue,
918
- status: session.status,
919
- pid: session.pid || undefined,
920
- cwd: session.cwd,
921
- deviceId,
922
- parentSessionId: session.parent_session_id,
923
- traceId: session.trace_id,
924
- promptPath: session.prompt_path,
925
- outputPath: session.output_path,
926
- startCommitSha: headSha,
927
- metadata: session.exit_code !== undefined || session.read_only !== undefined
928
- ? {
929
- exitCode: session.exit_code,
930
- readOnly: session.read_only,
931
- }
932
- : undefined,
933
- });
934
- created++;
935
- stateBySessionId.set(session.session_id, next);
936
- }
937
- return { created, updated };
938
- }
939
- lastProjectsHash = null;
940
- desktopProviderRegistered = false;
941
- clerkId = null;
942
- desktopProviderId = null;
943
- desktopConnectionId = null;
944
- sandboxServerProcesses = new Map();
945
- async syncProjects(deviceId) {
946
- // Query Convex for projects associated with this device's user
947
- let result = await getHttpClient().query(api.projects.listByDeviceId, { deviceId });
948
- if (!result.clerkId) {
949
- // User not logged in — can't sync
950
- return { synced: false, count: 0 };
951
- }
952
- // Store clerkId for other operations
953
- this.clerkId = result.clerkId;
954
- // Register this machine as a Desktop sandbox provider (once per daemon lifecycle)
955
- if (!this.desktopProviderRegistered) {
956
- try {
957
- const reg = await getHttpClient().mutation(api["functions/sandbox/providers"].registerDesktopProvider, {
958
- clerkId: result.clerkId,
959
- deviceId,
960
- machineName: hostname(),
961
- platform: process.platform,
962
- });
963
- this.desktopProviderId = reg.providerId;
964
- this.desktopProviderRegistered = true;
965
- this.logInfo(`Desktop sandbox provider registered${reg.isNew ? "" : " (existing)"}.`);
966
- }
967
- catch (err) {
968
- this.logWarn(`Desktop provider registration failed: ${err instanceof Error ? err.message : String(err)}`);
969
- }
970
- }
971
- // Push local project configs UP to Convex (idempotent, hash-deduped)
972
- try {
973
- const pushResult = await this.pushLocalProjects(result.clerkId);
974
- if (pushResult.pushed > 0) {
975
- this.logInfo(`Pushed ${pushResult.pushed} projects to Convex`);
976
- // Re-fetch to get the updated list
977
- result = await getHttpClient().query(api.projects.listByDeviceId, { deviceId });
978
- }
979
- }
980
- catch (err) {
981
- this.logWarn(`Project push failed: ${err instanceof Error ? err.message : String(err)}`);
982
- }
983
- if (result.projects.length === 0) {
984
- this.logWarn(`syncProjects: 0 projects after push (clerkId=${result.clerkId})`);
985
- return { synced: false, count: 0 };
986
- }
987
- // Build the projects.json structure matching existing format
988
- const projectsMap = {};
989
- for (const project of result.projects) {
990
- projectsMap[project.name] = {
991
- name: project.name,
992
- emoji: project.emoji || "📁",
993
- linearTeam: project.linearTeam || null,
994
- color: project.color || "#bec2c8",
995
- };
996
- }
997
- const projectsJson = {
998
- projects: projectsMap,
999
- defaultEmoji: "📁",
1000
- lastUpdated: new Date().toISOString(),
1001
- };
1002
- // Check if content changed
1003
- const hash = createHash("sha1").update(JSON.stringify(projectsMap)).digest("hex");
1004
- if (hash === this.lastProjectsHash) {
1005
- return { synced: false, count: result.projects.length };
1006
- }
1007
- // Write to ~/.abbie/projects.json
1008
- const abbieDir = join(homedir(), ".abbie");
1009
- if (!existsSync(abbieDir)) {
1010
- mkdirSync(abbieDir, { recursive: true });
1011
- }
1012
- const projectsPath = join(abbieDir, "projects.json");
1013
- writeFileSync(projectsPath, JSON.stringify(projectsJson, null, 2), "utf-8");
1014
- this.lastProjectsHash = hash;
1015
- return { synced: true, count: result.projects.length };
1016
- }
1017
- lastProjectsPushHash = null;
1018
- /**
1019
- * Push local project configs (.abbie/config.json) UP to Convex projects table.
1020
- * Idempotent — uses hash dedup to avoid redundant mutations.
1021
- */
1022
- async pushLocalProjects(clerkId) {
1023
- const discovered = discoverProjects().filter((p) => p.hasConfig && p.configPath);
1024
- // Build normalized config data for hashing
1025
- const configData = [];
1026
- for (const p of discovered) {
1027
- const loaded = loadConfig(p.configPath);
1028
- if (!loaded?.config?.name)
1029
- continue;
1030
- configData.push({
1031
- name: loaded.config.name,
1032
- emoji: loaded.config.emoji?.trim() || "📁",
1033
- color: "#bec2c8",
1034
- team: loaded.config.team,
1035
- path: p.path,
1036
- });
1037
- }
1038
- const hash = createHash("sha1").update(JSON.stringify(configData)).digest("hex");
1039
- if (hash === this.lastProjectsPushHash) {
1040
- return { pushed: 0 };
1041
- }
1042
- let pushed = 0;
1043
- for (const cfg of configData) {
1044
- try {
1045
- await getHttpClient().mutation(api.projects.upsert, {
1046
- clerkId,
1047
- name: cfg.name,
1048
- emoji: cfg.emoji,
1049
- color: cfg.color,
1050
- linearTeam: cfg.team,
1051
- path: cfg.path,
1052
- });
1053
- pushed++;
1054
- }
1055
- catch (err) {
1056
- this.logWarn(`Failed to push project ${cfg.name}: ${err instanceof Error ? err.message : String(err)}`);
1057
- }
1058
- }
1059
- this.lastProjectsPushHash = hash;
1060
- return { pushed };
1061
- }
1062
- async runCommandQueueWorker(deviceId, state, signal) {
1063
- while (!signal.aborted) {
1064
- if (state.inFlight) {
1065
- await sleep(250, signal);
1066
- continue;
1067
- }
1068
- let claimed = null;
1069
- try {
1070
- const next = await getHttpClient().mutation(api.commandQueue.claim, { deviceId });
1071
- if (!next) {
1072
- await sleep(1000, signal);
1073
- continue;
1074
- }
1075
- claimed = next;
1076
- state.inFlight = true;
1077
- const result = await this.executeQueuedCommand(claimed.command, claimed.args);
1078
- if (result.success) {
1079
- await getHttpClient().mutation(api.commandQueue.complete, { id: claimed.id, result });
1080
- state.processed++;
1081
- state.error = null;
1082
- this.logInfo(`Command executed: ${claimed.command}`);
1083
- }
1084
- else {
1085
- const errorMsg = result.error || "Unknown error";
1086
- await getHttpClient().mutation(api.commandQueue.fail, {
1087
- id: claimed.id,
1088
- error: errorMsg,
1089
- });
1090
- state.failed++;
1091
- state.error = errorMsg;
1092
- this.logWarn(`Command failed: ${claimed.command} - ${errorMsg}`);
1093
- }
1094
- }
1095
- catch (err) {
1096
- const errorMsg = err instanceof Error ? err.message : String(err);
1097
- state.failed++;
1098
- state.error = errorMsg;
1099
- if (claimed) {
1100
- try {
1101
- await getHttpClient().mutation(api.commandQueue.fail, {
1102
- id: claimed.id,
1103
- error: errorMsg,
1104
- });
1105
- }
1106
- catch {
1107
- // ignore
1108
- }
1109
- }
1110
- await sleep(1000, signal);
1111
- }
1112
- finally {
1113
- state.inFlight = false;
1114
- }
1115
- }
1116
- }
1117
- /**
1118
- * Background worker that polls the sandbox command queue for pending
1119
- * commands (startServer, stopServer, exec) and executes them locally.
1120
- */
1121
- async runSandboxCommandWorker(connectionId, state, signal) {
1122
- while (!signal.aborted) {
1123
- try {
1124
- const pending = await getHttpClient().query(api["functions/sandbox/desktop"].listPendingCommands, { connectionId });
1125
- if (!pending || pending.length === 0) {
1126
- await sleep(2000, signal);
1127
- continue;
1128
- }
1129
- for (const cmd of pending) {
1130
- if (signal.aborted)
1131
- break;
1132
- const { _id: commandId, commandType } = cmd;
1133
- // Claim the command
1134
- try {
1135
- await getHttpClient().mutation(api["functions/sandbox/desktop"].claimCommand, {
1136
- commandId,
1137
- });
1138
- }
1139
- catch (_err) {
1140
- // Another daemon may have claimed it — skip
1141
- continue;
1142
- }
1143
- try {
1144
- await this.executeSandboxCommand(cmd);
1145
- state.processed++;
1146
- state.error = null;
1147
- this.logInfo(`Sandbox command executed: ${commandType}`);
1148
- }
1149
- catch (err) {
1150
- const errorMsg = err instanceof Error ? err.message : String(err);
1151
- state.failed++;
1152
- state.error = errorMsg;
1153
- this.logWarn(`Sandbox command failed: ${commandType} - ${errorMsg}`);
1154
- // Report failure
1155
- try {
1156
- await getHttpClient().mutation(api["functions/sandbox/desktop"].completeCommand, {
1157
- commandId,
1158
- status: "failed",
1159
- stderr: errorMsg.slice(0, 2000),
1160
- });
1161
- }
1162
- catch {
1163
- // ignore
1164
- }
1165
- }
1166
- }
1167
- }
1168
- catch (err) {
1169
- state.error = err instanceof Error ? err.message : String(err);
1170
- // Don't spam on persistent errors
1171
- await sleep(5000, signal);
1172
- }
1173
- await sleep(1000, signal);
1174
- }
1175
- }
1176
- /**
1177
- * Execute a single sandbox command locally.
1178
- * Handles startServer (spawn detached process), stopServer (kill by PID),
1179
- * and exec (run command and capture output).
1180
- */
1181
- async executeSandboxCommand(cmd) {
1182
- const c = cmd;
1183
- const commandId = c._id;
1184
- switch (c.commandType) {
1185
- case "startServer": {
1186
- if (!c.serverId) {
1187
- throw new Error("startServer command missing serverId");
1188
- }
1189
- const [command, ...args] = c.cmd;
1190
- if (!command) {
1191
- throw new Error("startServer command has empty cmd array");
1192
- }
1193
- const proc = spawnProcess(command, args, {
1194
- cwd: c.workdir ?? process.cwd(),
1195
- env: { ...process.env, ...(c.env ?? {}) },
1196
- detached: true,
1197
- stdio: ["ignore", "pipe", "pipe"],
1198
- });
1199
- // Track the process by serverId
1200
- this.sandboxServerProcesses.set(c.serverId, proc);
1201
- const pid = proc.pid;
1202
- let stdoutBuf = "";
1203
- let stderrBuf = "";
1204
- proc.stdout?.on("data", (chunk) => {
1205
- stdoutBuf += chunk.toString();
1206
- // Keep only last 4KB for reporting
1207
- if (stdoutBuf.length > 4096) {
1208
- stdoutBuf = stdoutBuf.slice(-4096);
1209
- }
1210
- });
1211
- proc.stderr?.on("data", (chunk) => {
1212
- stderrBuf += chunk.toString();
1213
- if (stderrBuf.length > 4096) {
1214
- stderrBuf = stderrBuf.slice(-4096);
1215
- }
1216
- });
1217
- // Report running status to Convex
1218
- const publicUrl = c.serverOpts ? `http://localhost:${c.serverOpts.port}` : undefined;
1219
- await getHttpClient().mutation(api["functions/sandbox/desktop"].updateServerStatus, {
1220
- serverId: c.serverId,
1221
- status: "running",
1222
- providerServerId: pid ? String(pid) : undefined,
1223
- publicUrl,
1224
- });
1225
- // Complete the command
1226
- await getHttpClient().mutation(api["functions/sandbox/desktop"].completeCommand, {
1227
- commandId,
1228
- status: "completed",
1229
- stdout: `Server started with PID ${pid}`,
1230
- });
1231
- // Monitor process exit
1232
- proc.on("exit", (code, sig) => {
1233
- this.sandboxServerProcesses.delete(c.serverId);
1234
- // Best-effort status update
1235
- getHttpClient()
1236
- .mutation(api["functions/sandbox/desktop"].updateServerStatus, {
1237
- serverId: c.serverId,
1238
- status: code === 0 || sig === "SIGTERM" ? "stopped" : "failed",
1239
- error: code !== 0 && code !== null
1240
- ? `Process exited with code ${code}${stderrBuf ? `: ${stderrBuf.slice(0, 500)}` : ""}`
1241
- : undefined,
1242
- })
1243
- .catch(() => {
1244
- // ignore
1245
- });
1246
- });
1247
- proc.unref();
1248
- break;
1249
- }
1250
- case "stopServer": {
1251
- if (!c.serverId) {
1252
- throw new Error("stopServer command missing serverId");
1253
- }
1254
- const existing = this.sandboxServerProcesses.get(c.serverId);
1255
- if (existing) {
1256
- existing.kill("SIGTERM");
1257
- this.sandboxServerProcesses.delete(c.serverId);
1258
- }
1259
- await getHttpClient().mutation(api["functions/sandbox/desktop"].updateServerStatus, {
1260
- serverId: c.serverId,
1261
- status: "stopped",
1262
- });
1263
- await getHttpClient().mutation(api["functions/sandbox/desktop"].completeCommand, {
1264
- commandId,
1265
- status: "completed",
1266
- stdout: existing
1267
- ? `Server stopped (PID ${existing.pid})`
1268
- : "Server not found locally (may have already exited)",
1269
- });
1270
- break;
1271
- }
1272
- case "exec": {
1273
- const startTime = Date.now();
1274
- const [execCmd, ...execArgs] = c.cmd;
1275
- if (!execCmd) {
1276
- throw new Error("exec command has empty cmd array");
1277
- }
1278
- const result = await new Promise((resolve) => {
1279
- let stdout = "";
1280
- let stderr = "";
1281
- const proc = spawnProcess(execCmd, execArgs, {
1282
- cwd: c.workdir ?? process.cwd(),
1283
- env: { ...process.env, ...(c.env ?? {}) },
1284
- stdio: ["ignore", "pipe", "pipe"],
1285
- timeout: c.timeoutMs ?? 30_000,
1286
- });
1287
- proc.stdout?.on("data", (chunk) => {
1288
- stdout += chunk.toString();
1289
- });
1290
- proc.stderr?.on("data", (chunk) => {
1291
- stderr += chunk.toString();
1292
- });
1293
- proc.on("close", (code) => {
1294
- resolve({
1295
- exitCode: code ?? 1,
1296
- stdout: stdout.slice(0, 8000),
1297
- stderr: stderr.slice(0, 8000),
1298
- });
1299
- });
1300
- proc.on("error", (err) => {
1301
- resolve({
1302
- exitCode: 1,
1303
- stdout: "",
1304
- stderr: err.message,
1305
- });
1306
- });
1307
- });
1308
- const durationMs = Date.now() - startTime;
1309
- await getHttpClient().mutation(api["functions/sandbox/desktop"].completeCommand, {
1310
- commandId,
1311
- status: result.exitCode === 0 ? "completed" : "failed",
1312
- exitCode: result.exitCode,
1313
- stdout: result.stdout,
1314
- stderr: result.stderr,
1315
- durationMs,
1316
- });
1317
- break;
1318
- }
1319
- default:
1320
- throw new Error(`Unknown sandbox command type: ${c.commandType}`);
1321
- }
1322
- }
1323
- /**
1324
- * Execute a queued command from the chat agent.
1325
- * Currently supports "abbie session start" for spawning local agent sessions.
1326
- */
1327
- async executeQueuedCommand(command, args) {
1328
- switch (command) {
1329
- case "abbie session start": {
1330
- if (!args || typeof args !== "object") {
1331
- return {
1332
- success: false,
1333
- error: 'Invalid args for "abbie session start": expected an object',
1334
- };
1335
- }
1336
- const sessionArgs = args;
1337
- const allowedAgents = new Set(AGENTS);
1338
- if (!sessionArgs.agent || !allowedAgents.has(sessionArgs.agent)) {
1339
- return {
1340
- success: false,
1341
- error: 'Invalid args for "abbie session start": missing or invalid `agent` (claude|codex|copilot|gemini)',
1342
- };
1343
- }
1344
- if (typeof sessionArgs.project !== "string" || sessionArgs.project.trim().length === 0) {
1345
- return {
1346
- success: false,
1347
- error: 'Invalid args for "abbie session start": missing `project`',
1348
- };
1349
- }
1350
- if (typeof sessionArgs.goal !== "string" || sessionArgs.goal.trim().length === 0) {
1351
- return {
1352
- success: false,
1353
- error: 'Invalid args for "abbie session start": missing `goal`',
1354
- };
1355
- }
1356
- const manager = getActiveSessionManager();
1357
- const result = await manager.start({
1358
- agent: sessionArgs.agent,
1359
- project: sessionArgs.project,
1360
- goal: sessionArgs.goal,
1361
- issue: sessionArgs.issue,
1362
- cwd: sessionArgs.cwd,
1363
- });
1364
- return {
1365
- success: true,
1366
- sessionId: result.session_id,
1367
- };
1368
- }
1369
- case "read-buffer": {
1370
- if (!args || typeof args !== "object") {
1371
- return { success: false, error: 'Invalid args for "read-buffer": expected an object' };
1372
- }
1373
- const bufArgs = args;
1374
- if (!bufArgs.paneId) {
1375
- return { success: false, error: 'Missing "paneId" for read-buffer' };
1376
- }
1377
- // Find nvim server for this pane from inventory
1378
- const inv = await windows.inventory({
1379
- includeNvim: true,
1380
- includeAgent: false,
1381
- includeBuffers: false,
1382
- });
1383
- const paneState = inv.find((s) => s.window.paneId === bufArgs.paneId);
1384
- if (!paneState?.nvim?.serverAddr) {
1385
- return { success: false, error: `No nvim server found for pane ${bufArgs.paneId}` };
1386
- }
1387
- const serverAddr = paneState.nvim.serverAddr;
1388
- // If no bufnr, list all buffers
1389
- if (bufArgs.bufnr === undefined) {
1390
- const bufs = await nvim.listBuffers(serverAddr);
1391
- if (!bufs)
1392
- return { success: false, error: "Failed to list nvim buffers" };
1393
- return {
1394
- success: true,
1395
- buffers: bufs.map((b) => ({
1396
- bufnr: b.bufnr,
1397
- name: b.name,
1398
- isTerminal: b.name.startsWith("term://"),
1399
- })),
1400
- };
1401
- }
1402
- // Read specific buffer
1403
- const maxLines = bufArgs.lines ?? 200;
1404
- const allLines = await nvim.getBufferLines(serverAddr, bufArgs.bufnr, 1, "$");
1405
- if (!allLines) {
1406
- return { success: false, error: `Failed to read buffer ${bufArgs.bufnr}` };
1407
- }
1408
- const tail = allLines.length > maxLines ? allLines.slice(-maxLines) : allLines;
1409
- return {
1410
- success: true,
1411
- bufnr: bufArgs.bufnr,
1412
- totalLines: allLines.length,
1413
- returnedLines: tail.length,
1414
- content: tail.join("\n"),
1415
- };
1416
- }
1417
- case "terminal-input": {
1418
- if (!args || typeof args !== "object") {
1419
- return { success: false, error: 'Invalid args for "terminal-input": expected an object' };
1420
- }
1421
- const inputArgs = args;
1422
- if (!inputArgs.paneId || !inputArgs.data) {
1423
- return { success: false, error: 'Missing "paneId" or "data" for terminal-input' };
1424
- }
1425
- try {
1426
- const { spawn: spawnProcess } = await import("node:child_process");
1427
- // Split on newlines and send Enter for each boundary (same as sendToTmux)
1428
- const parts = inputArgs.data.split(/\r\n|\r|\n/);
1429
- for (let i = 0; i < parts.length; i++) {
1430
- const part = parts[i];
1431
- if (part.length > 0) {
1432
- await new Promise((resolve, reject) => {
1433
- const proc = spawnProcess("tmux", [
1434
- "send-keys",
1435
- "-t",
1436
- inputArgs.paneId,
1437
- "-l",
1438
- part,
1439
- ]);
1440
- proc.on("close", (code) => code === 0 ? resolve() : reject(new Error(`tmux exit ${code}`)));
1441
- proc.on("error", reject);
1442
- });
1443
- }
1444
- if (i < parts.length - 1) {
1445
- await new Promise((resolve, reject) => {
1446
- const proc = spawnProcess("tmux", ["send-keys", "-t", inputArgs.paneId, "Enter"]);
1447
- proc.on("close", (code) => code === 0 ? resolve() : reject(new Error(`tmux exit ${code}`)));
1448
- proc.on("error", reject);
1449
- });
1450
- }
1451
- }
1452
- return { success: true };
1453
- }
1454
- catch (err) {
1455
- return { success: false, error: `terminal-input failed: ${err.message}` };
1456
- }
1457
- }
1458
- default:
1459
- return {
1460
- success: false,
1461
- error: `Unknown command: ${command}`,
1462
- };
1463
- }
1464
- }
1465
- }