@phnx-labs/agents-cli 1.20.34 → 1.20.35

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 (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
@@ -22,7 +22,6 @@ import * as fs from 'fs';
22
22
  import * as path from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { randomBytes } from 'crypto';
25
- import { Transform } from 'stream';
26
25
  import { sshExec, SSH_OPTS } from './ssh-exec.js';
27
26
  import { encodePowerShell } from './browser/drivers/ssh.js';
28
27
  import { getDevice } from './devices/registry.js';
@@ -30,19 +29,18 @@ import { sshTargetFor } from './devices/connect.js';
30
29
  import { hostNameFor } from './devices/ssh-config.js';
31
30
  import { getCacheDir } from './state.js';
32
31
  import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
33
- /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure. */
32
+ /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
33
+ *
34
+ * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
35
+ * so the tunnel inherits the same options — crucially the keepalive, which lets
36
+ * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
34
37
  export function buildTunnelArgs(user, host, localPort, remotePort) {
35
38
  return [
36
39
  '-L',
37
40
  `${localPort}:127.0.0.1:${remotePort}`,
38
41
  `${user}@${host}`,
39
42
  '-N',
40
- '-o',
41
- 'StrictHostKeyChecking=accept-new',
42
- '-o',
43
- 'BatchMode=yes',
44
- '-o',
45
- 'ConnectTimeout=10',
43
+ ...SSH_OPTS,
46
44
  ];
47
45
  }
48
46
  /**
@@ -150,10 +148,15 @@ export async function resolveRemoteDevice(name) {
150
148
  return { device, target, user, host };
151
149
  }
152
150
  /**
153
- * PowerShell that streams base64 from stdin, decodes it incrementally to
154
- * %LOCALAPPDATA%\agents\computer-helper-win.exe, and stops any running instance
155
- * first so the file isn't locked. The CryptoStream/FromBase64Transform decode
156
- * is streaming the ~156MB exe never lands in memory whole on the remote.
151
+ * Single-quote a string for embedding inside a PowerShell literal.
152
+ */
153
+ function psSingleQuote(s) {
154
+ return "'" + s.replace(/'/g, "''") + "'";
155
+ }
156
+ /**
157
+ * PowerShell that resolves the destination under %LOCALAPPDATA%\agents and
158
+ * stops any running instance first so the file is not locked. The caller copies
159
+ * the exe with scp and then verifies the byte count separately.
157
160
  */
158
161
  export function buildPushScript() {
159
162
  return [
@@ -161,15 +164,18 @@ export function buildPushScript() {
161
164
  `New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
162
165
  `$dst = Join-Path $dir '${WIN_HELPER_EXE}'`,
163
166
  `Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
164
- `$si = [Console]::OpenStandardInput()`,
165
- `$t = New-Object Security.Cryptography.FromBase64Transform`,
166
- `$cs = New-Object Security.Cryptography.CryptoStream($si, $t, [Security.Cryptography.CryptoStreamMode]::Read)`,
167
- `$fs = [IO.File]::Create($dst)`,
168
- `$cs.CopyTo($fs)`,
169
- `$fs.Close(); $cs.Close()`,
170
167
  `Write-Output $dst`,
171
168
  ].join('; ');
172
169
  }
170
+ /** PowerShell that verifies scp wrote the expected number of bytes. */
171
+ export function buildVerifyPushScript(remotePath, expectedBytes) {
172
+ return [
173
+ `$dst = ${psSingleQuote(remotePath)}`,
174
+ `$item = Get-Item -LiteralPath $dst -ErrorAction Stop`,
175
+ `if ($item.Length -ne ${expectedBytes}) { throw "helper copy length mismatch: expected ${expectedBytes}, got $($item.Length)" }`,
176
+ `Write-Output "$dst $($item.Length)"`,
177
+ ].join('; ');
178
+ }
173
179
  /**
174
180
  * PowerShell that registers the daemon as a LOGON scheduled task. Interactive
175
181
  * logon type + Highest run level so the daemon runs in the real desktop session
@@ -195,51 +201,32 @@ export function buildUnregisterTaskScript(taskName) {
195
201
  `Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
196
202
  ].join('; ');
197
203
  }
204
+ /** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */
205
+ export function scpRemotePath(remotePath) {
206
+ return remotePath.replace(/\\/g, '/');
207
+ }
198
208
  /**
199
- * `setup --host`: push the exe, then register + start the LOGON task. Both hops
200
- * go through `sshExec` (BatchMode key auth the same hardening the browser
201
- * driver and `agents ssh` use). Throws with the remote stderr on any failure.
202
- */
203
- /**
204
- * Base64-encode a byte stream in 3-byte-aligned chunks so the concatenated
205
- * output is valid (every chunk boundary lands on a base64 quantum).
209
+ * Build the scp argv used for the helper exe transfer. Exported so tests can
210
+ * assert the real binary copy path keeps BatchMode and does not route bytes
211
+ * through a PowerShell decoder.
206
212
  */
207
- class Base64Encode extends Transform {
208
- leftover = Buffer.alloc(0);
209
- _transform(chunk, _enc, cb) {
210
- const buf = this.leftover.length ? Buffer.concat([this.leftover, chunk]) : chunk;
211
- const usable = buf.length - (buf.length % 3);
212
- this.leftover = Buffer.from(buf.subarray(usable));
213
- if (usable > 0)
214
- this.push(buf.subarray(0, usable).toString('base64'));
215
- cb();
216
- }
217
- _flush(cb) {
218
- if (this.leftover.length)
219
- this.push(this.leftover.toString('base64'));
220
- cb();
221
- }
213
+ export function buildScpArgs(target, remotePath, filePath) {
214
+ return [...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
222
215
  }
223
216
  /**
224
- * Stream a local file to a remote command's stdin over ssh, base64-encoded on
225
- * the fly. Async spawn + piping honors backpressure; the previous
226
- * `spawnSync({ input })` blob deadlocked once the ssh socket buffer filled
227
- * (~4MB) on large files (the 157MB Windows helper reproduced this reliably),
228
- * and worse, reported a false success leaving a 0-byte remote file. Rejects on
229
- * any pipe error so a broken transfer fails loudly instead.
217
+ * Copy a local file to the remote destination with scp. This is a binary
218
+ * transfer; no base64 transform runs on either side.
230
219
  */
231
- function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
220
+ function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
232
221
  return new Promise((resolve, reject) => {
233
- const child = spawn('ssh', [...SSH_OPTS, target, remoteCmd], {
234
- stdio: ['pipe', 'pipe', 'pipe'],
222
+ const child = spawn('scp', buildScpArgs(target, remotePath, filePath), {
223
+ stdio: ['ignore', 'ignore', 'pipe'],
235
224
  });
236
225
  let stderr = '';
237
- let stdout = '';
238
226
  child.stderr.on('data', (d) => (stderr += d.toString()));
239
- child.stdout.on('data', (d) => (stdout += d.toString()));
240
227
  const timer = setTimeout(() => {
241
228
  child.kill('SIGKILL');
242
- reject(new Error(`ssh push to ${target} timed out after ${timeoutMs}ms`));
229
+ reject(new Error(`scp push to ${target} timed out after ${timeoutMs}ms`));
243
230
  }, timeoutMs);
244
231
  const fail = (e) => {
245
232
  clearTimeout(timer);
@@ -247,30 +234,41 @@ function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
247
234
  reject(e);
248
235
  };
249
236
  child.on('error', fail);
250
- child.stdin.on('error', fail); // EPIPE if the remote decoder dies mid-stream
251
237
  child.on('close', (code) => {
252
238
  clearTimeout(timer);
253
- resolve({ code, stderr: stderr || stdout });
239
+ resolve({ code, stderr });
254
240
  });
255
- const src = fs.createReadStream(filePath);
256
- src.on('error', fail);
257
- // disk -> aligned base64 -> ssh stdin; .pipe() applies backpressure
258
- src.pipe(new Base64Encode()).pipe(child.stdin);
259
241
  });
260
242
  }
243
+ /**
244
+ * `setup --host`: push the exe, then register + start the LOGON task. Remote
245
+ * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
246
+ * the browser driver and `agents ssh` use), and the large exe rides a binary
247
+ * scp transfer. Throws with the remote stderr on any failure.
248
+ */
261
249
  export async function setupRemoteHelper(name) {
262
250
  const { target } = await resolveRemoteDevice(name);
263
251
  const exe = resolveWinHelperExe();
264
252
  if (!exe) {
265
253
  throw new Error(`Windows helper exe not built. Run: bash scripts/build-win.sh`);
266
254
  }
267
- // Push: stream the exe from disk, base64-encoded on the fly, to the remote
268
- // decoder. Streaming (vs a single spawnSync `input` blob) honors ssh socket
269
- // backpressure the blob path deadlocks once the socket buffer fills (~4MB).
270
- const push = await streamFileOverSsh(target, encodePowerShell(buildPushScript()), exe);
255
+ const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000 });
256
+ if (prep.code !== 0) {
257
+ throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
258
+ }
259
+ const remotePath = prep.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
260
+ if (!remotePath) {
261
+ throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
262
+ }
263
+ const push = await copyFileOverScp(target, remotePath, exe);
271
264
  if (push.code !== 0) {
272
265
  throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
273
266
  }
267
+ const expectedBytes = fs.statSync(exe).size;
268
+ const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000 });
269
+ if (verify.code !== 0) {
270
+ throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
271
+ }
274
272
  // Register + start the LOGON task.
275
273
  const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
276
274
  timeoutMs: 60_000,
@@ -32,7 +32,7 @@ function buildHooksWriter(agent) {
32
32
  }
33
33
  // Native hook registration in settings.json/hooks.json. Grok auto-
34
34
  // discovers from ~/.grok/hooks/ so the file copy is sufficient.
35
- if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity' || agent === 'kimi') {
35
+ if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid') {
36
36
  registerHooksToSettings(agent, versionHome);
37
37
  }
38
38
  return { synced };
@@ -69,6 +69,8 @@ export declare const loadTmux: ModuleLoader;
69
69
  export declare const loadBrowser: ModuleLoader;
70
70
  export declare const loadComputer: ModuleLoader;
71
71
  export declare const loadHosts: ModuleLoader;
72
+ export declare const loadLogs: ModuleLoader;
73
+ export declare const loadEvents: ModuleLoader;
72
74
  export declare const loadSsh: ModuleLoader;
73
75
  export declare const loadPull: ModuleLoader;
74
76
  export declare const loadPush: ModuleLoader;
@@ -47,6 +47,8 @@ export const loadTmux = async () => (await import('../../commands/tmux.js')).reg
47
47
  export const loadBrowser = async () => (await import('../../commands/browser.js')).registerBrowserCommand;
48
48
  export const loadComputer = async () => (await import('../../commands/computer.js')).registerComputerCommand;
49
49
  export const loadHosts = async () => (await import('../../commands/hosts.js')).registerHostsCommand;
50
+ export const loadLogs = async () => (await import('../../commands/logs.js')).registerLogsCommand;
51
+ export const loadEvents = async () => (await import('../../commands/events.js')).registerEventsCommand;
50
52
  export const loadSsh = async () => (await import('../../commands/ssh.js')).registerSshCommands;
51
53
  export const loadPull = async () => (await import('../../commands/pull.js')).registerPullCommand;
52
54
  export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
@@ -131,6 +133,8 @@ export const COMMAND_LOADERS = {
131
133
  browser: [loadBrowser],
132
134
  computer: [loadComputer],
133
135
  hosts: [loadHosts],
136
+ logs: [loadLogs],
137
+ events: [loadEvents],
134
138
  ssh: [loadSsh],
135
139
  devices: [loadSsh],
136
140
  pull: [loadPull],
@@ -13,6 +13,7 @@ import * as path from 'path';
13
13
  import { randomBytes } from 'crypto';
14
14
  import lockfile from 'proper-lockfile';
15
15
  import { getTeamsRegistryPath } from '../state.js';
16
+ import { emit } from '../events.js';
16
17
  async function registryPath() {
17
18
  return getTeamsRegistryPath();
18
19
  }
@@ -98,39 +99,50 @@ export async function createTeam(name, options) {
98
99
  throw new Error('Cannot use both --enable-worktrees and --use-worktree. Pick one.');
99
100
  }
100
101
  const p = await registryPath();
101
- return withRegistryLock(p, async () => {
102
+ const meta = await withRegistryLock(p, async () => {
102
103
  const reg = await loadTeams();
103
104
  if (reg[name]) {
104
105
  throw new Error(`Team '${name}' already exists`);
105
106
  }
106
- const meta = {
107
+ const m = {
107
108
  created_at: new Date().toISOString(),
108
109
  ...(options?.description ? { description: options.description } : {}),
109
110
  ...(options?.enableWorktrees ? { enable_worktrees: true } : {}),
110
111
  ...(options?.useWorktree ? { use_worktree: options.useWorktree } : {}),
111
112
  };
112
- reg[name] = meta;
113
+ reg[name] = m;
113
114
  await saveTeams(reg);
114
- return meta;
115
+ return m;
115
116
  });
117
+ // Audit the lifecycle boundary, not the CLI shell — captures every creation
118
+ // path (create + ensure) with team metadata the generic command log lacks.
119
+ emit('teams.create', { module: 'teams', team: name, worktrees: Boolean(options?.enableWorktrees || options?.useWorktree) });
120
+ return meta;
116
121
  }
117
122
  /** Return existing team metadata or create a new team if it does not exist. */
118
123
  export async function ensureTeam(name) {
119
124
  const p = await registryPath();
120
- return withRegistryLock(p, async () => {
125
+ let created = false;
126
+ const meta = await withRegistryLock(p, async () => {
121
127
  const reg = await loadTeams();
122
128
  if (reg[name])
123
129
  return reg[name];
124
- const meta = { created_at: new Date().toISOString() };
125
- reg[name] = meta;
130
+ const m = { created_at: new Date().toISOString() };
131
+ reg[name] = m;
126
132
  await saveTeams(reg);
127
- return meta;
133
+ created = true;
134
+ return m;
128
135
  });
136
+ // `teams add` auto-creates the team on first teammate — audit that creation
137
+ // too, but only when it actually happened (not the get-existing path).
138
+ if (created)
139
+ emit('teams.create', { module: 'teams', team: name, worktrees: false });
140
+ return meta;
129
141
  }
130
142
  /** Remove a team from the registry. Returns false if the team did not exist. */
131
143
  export async function removeTeam(name) {
132
144
  const p = await registryPath();
133
- return withRegistryLock(p, async () => {
145
+ const existed = await withRegistryLock(p, async () => {
134
146
  const reg = await loadTeams();
135
147
  if (!reg[name])
136
148
  return false;
@@ -138,6 +150,10 @@ export async function removeTeam(name) {
138
150
  await saveTeams(reg);
139
151
  return true;
140
152
  });
153
+ // "Disband" — only fires when a real team was removed, not a no-op.
154
+ if (existed)
155
+ emit('teams.disband', { module: 'teams', team: name });
156
+ return existed;
141
157
  }
142
158
  /** Check whether a team with the given name exists in the registry. */
143
159
  export async function teamExists(name) {
@@ -0,0 +1,6 @@
1
+ import type { TerminalBackend, SplitDirection } from '../types.js';
2
+ /** AppleScript that opens a Ghostty tab (a window if none is open). */
3
+ export declare function ghosttyTabScript(cwd: string, command: string[]): string;
4
+ /** AppleScript that splits the current Ghostty surface (a window if none is open). */
5
+ export declare function ghosttySplitScript(cwd: string, command: string[], direction: SplitDirection): string;
6
+ export declare const ghosttyBackend: TerminalBackend;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Ghostty backend — drives Ghostty (>= 1.3) via AppleScript (`osascript`).
3
+ *
4
+ * Ghostty's `new surface configuration` record carries the working directory and
5
+ * command natively, so no `cd` wrapper is needed. Tab: `new tab` (or a window
6
+ * when none is open). Split: `split <surface> direction right|down`, where the
7
+ * current surface is `focused terminal of selected tab of front window`
8
+ * (verified against Ghostty 1.3.1 — a surface is a "terminal" in its AS model).
9
+ */
10
+ import * as fs from 'fs';
11
+ import { appleScriptStr } from '../quote.js';
12
+ import { execOnly, iLoginShell } from '../shell.js';
13
+ const GHOSTTY_APP = '/Applications/Ghostty.app';
14
+ function appExists(p) {
15
+ try {
16
+ return fs.existsSync(p);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ /** Shared prologue: activate + a surface configuration carrying cwd + command. */
23
+ function configPrologue(cwd, command) {
24
+ const cmd = appleScriptStr(iLoginShell(execOnly(command)));
25
+ return [
26
+ 'tell application "Ghostty"',
27
+ ' activate',
28
+ ' set cfg to new surface configuration',
29
+ ` set initial working directory of cfg to ${appleScriptStr(cwd)}`,
30
+ ` set command of cfg to ${cmd}`,
31
+ ];
32
+ }
33
+ /** AppleScript that opens a Ghostty tab (a window if none is open). */
34
+ export function ghosttyTabScript(cwd, command) {
35
+ return [
36
+ ...configPrologue(cwd, command),
37
+ ' if (count of windows) is 0 then',
38
+ ' new window with configuration cfg',
39
+ ' else',
40
+ ' new tab in front window with configuration cfg',
41
+ ' end if',
42
+ 'end tell',
43
+ ].join('\n');
44
+ }
45
+ /** AppleScript that splits the current Ghostty surface (a window if none is open). */
46
+ export function ghosttySplitScript(cwd, command, direction) {
47
+ return [
48
+ ...configPrologue(cwd, command),
49
+ ' if (count of windows) is 0 then',
50
+ ' new window with configuration cfg',
51
+ ' else',
52
+ ` split (focused terminal of selected tab of front window) direction ${direction} with configuration cfg`,
53
+ ' end if',
54
+ 'end tell',
55
+ ].join('\n');
56
+ }
57
+ export const ghosttyBackend = {
58
+ id: 'ghostty',
59
+ label: 'Ghostty',
60
+ isAvailable(ctx) {
61
+ return ctx.platform === 'darwin' && appExists(GHOSTTY_APP);
62
+ },
63
+ buildTab(cwd, command) {
64
+ return { argv: ['osascript', '-e', ghosttyTabScript(cwd, command)] };
65
+ },
66
+ buildSplit(cwd, command, direction) {
67
+ return { argv: ['osascript', '-e', ghosttySplitScript(cwd, command, direction)] };
68
+ },
69
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Backend registry + current-terminal detection.
3
+ */
4
+ import type { Backend, EngineContext, TerminalBackend } from '../types.js';
5
+ import { itermBackend } from './iterm.js';
6
+ import { ghosttyBackend } from './ghostty.js';
7
+ import { tmuxBackend } from './tmux.js';
8
+ /** All known interactive backends, keyed by id. */
9
+ export declare const BACKENDS: Record<Backend, TerminalBackend>;
10
+ /**
11
+ * The backend for the terminal the CLI is currently running in, or null if we
12
+ * can't open a surface into it. tmux wins (via `$TMUX`) because a tmux pane can
13
+ * live inside iTerm/Ghostty; otherwise fall back to `TERM_PROGRAM`.
14
+ */
15
+ export declare function detectCurrentBackend(ctx: EngineContext): Backend | null;
16
+ /** Backends that can actually be driven in this context. */
17
+ export declare function availableBackends(ctx: EngineContext): TerminalBackend[];
18
+ export { itermBackend, ghosttyBackend, tmuxBackend };
@@ -0,0 +1,29 @@
1
+ import { itermBackend } from './iterm.js';
2
+ import { ghosttyBackend } from './ghostty.js';
3
+ import { tmuxBackend } from './tmux.js';
4
+ /** All known interactive backends, keyed by id. */
5
+ export const BACKENDS = {
6
+ iterm: itermBackend,
7
+ ghostty: ghosttyBackend,
8
+ tmux: tmuxBackend,
9
+ };
10
+ /**
11
+ * The backend for the terminal the CLI is currently running in, or null if we
12
+ * can't open a surface into it. tmux wins (via `$TMUX`) because a tmux pane can
13
+ * live inside iTerm/Ghostty; otherwise fall back to `TERM_PROGRAM`.
14
+ */
15
+ export function detectCurrentBackend(ctx) {
16
+ if (ctx.env.TMUX)
17
+ return 'tmux';
18
+ const term = (ctx.env.TERM_PROGRAM || '').toLowerCase();
19
+ if (term.includes('iterm'))
20
+ return 'iterm';
21
+ if (term.includes('ghostty'))
22
+ return 'ghostty';
23
+ return null;
24
+ }
25
+ /** Backends that can actually be driven in this context. */
26
+ export function availableBackends(ctx) {
27
+ return Object.values(BACKENDS).filter((b) => b.isAvailable(ctx));
28
+ }
29
+ export { itermBackend, ghosttyBackend, tmuxBackend };
@@ -0,0 +1,6 @@
1
+ import type { TerminalBackend, SplitDirection } from '../types.js';
2
+ /** AppleScript that opens an iTerm tab (a window if none is open) running the command. */
3
+ export declare function itermTabScript(cwd: string, command: string[]): string;
4
+ /** AppleScript that splits the current iTerm session (a window if none is open). */
5
+ export declare function itermSplitScript(cwd: string, command: string[], direction: SplitDirection): string;
6
+ export declare const itermBackend: TerminalBackend;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * iTerm backend — drives iTerm2 via AppleScript (`osascript`).
3
+ *
4
+ * Tab: creates a tab in the current window (or a window when none is open).
5
+ * Split: splits the current session — `split vertically` (side-by-side, a
6
+ * vertical divider) for `right`, `split horizontally` (stacked) for `down`.
7
+ */
8
+ import * as fs from 'fs';
9
+ import { appleScriptStr } from '../quote.js';
10
+ import { loginExec, iLoginShell } from '../shell.js';
11
+ const ITERM_APP = '/Applications/iTerm.app';
12
+ function appExists(p) {
13
+ try {
14
+ return fs.existsSync(p);
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /** AppleScript that opens an iTerm tab (a window if none is open) running the command. */
21
+ export function itermTabScript(cwd, command) {
22
+ const cmd = appleScriptStr(iLoginShell(loginExec(cwd, command)));
23
+ return [
24
+ 'tell application "iTerm2"',
25
+ ' activate',
26
+ ' if (count of windows) is 0 then',
27
+ ` create window with default profile command ${cmd}`,
28
+ ' else',
29
+ ` tell current window to create tab with default profile command ${cmd}`,
30
+ ' end if',
31
+ 'end tell',
32
+ ].join('\n');
33
+ }
34
+ /** AppleScript that splits the current iTerm session (a window if none is open). */
35
+ export function itermSplitScript(cwd, command, direction) {
36
+ const cmd = appleScriptStr(iLoginShell(loginExec(cwd, command)));
37
+ // iTerm: "split vertically" = a vertical divider = panes side by side (right).
38
+ const verb = direction === 'right' ? 'split vertically' : 'split horizontally';
39
+ return [
40
+ 'tell application "iTerm2"',
41
+ ' activate',
42
+ ' if (count of windows) is 0 then',
43
+ ` create window with default profile command ${cmd}`,
44
+ ' else',
45
+ ` tell current session of current window to ${verb} with default profile command ${cmd}`,
46
+ ' end if',
47
+ 'end tell',
48
+ ].join('\n');
49
+ }
50
+ export const itermBackend = {
51
+ id: 'iterm',
52
+ label: 'iTerm',
53
+ isAvailable(ctx) {
54
+ return ctx.platform === 'darwin' && appExists(ITERM_APP);
55
+ },
56
+ buildTab(cwd, command) {
57
+ return { argv: ['osascript', '-e', itermTabScript(cwd, command)] };
58
+ },
59
+ buildSplit(cwd, command, direction) {
60
+ return { argv: ['osascript', '-e', itermSplitScript(cwd, command, direction)] };
61
+ },
62
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * tmux backend — opens windows and splits in the running tmux server.
3
+ *
4
+ * cwd is passed natively via `-c`, so the command is wrapped in `zsh -ilc` with
5
+ * no `cd`. `split-window -h` = side-by-side (right); `-v` = stacked (down).
6
+ * Available only when the CLI is running inside tmux (`$TMUX` set) — otherwise
7
+ * there is no current window/pane to attach to.
8
+ */
9
+ import type { TerminalBackend, SplitDirection } from '../types.js';
10
+ /** argv that opens a new tmux window running the command in cwd. */
11
+ export declare function tmuxTabArgv(cwd: string, command: string[]): string[];
12
+ /** argv that splits the current tmux pane, running the command in cwd. */
13
+ export declare function tmuxSplitArgv(cwd: string, command: string[], direction: SplitDirection): string[];
14
+ export declare const tmuxBackend: TerminalBackend;
@@ -0,0 +1,23 @@
1
+ import { execOnly, iLoginShell } from '../shell.js';
2
+ /** argv that opens a new tmux window running the command in cwd. */
3
+ export function tmuxTabArgv(cwd, command) {
4
+ return ['tmux', 'new-window', '-c', cwd, iLoginShell(execOnly(command))];
5
+ }
6
+ /** argv that splits the current tmux pane, running the command in cwd. */
7
+ export function tmuxSplitArgv(cwd, command, direction) {
8
+ const flag = direction === 'right' ? '-h' : '-v';
9
+ return ['tmux', 'split-window', flag, '-c', cwd, iLoginShell(execOnly(command))];
10
+ }
11
+ export const tmuxBackend = {
12
+ id: 'tmux',
13
+ label: 'tmux',
14
+ isAvailable(ctx) {
15
+ return Boolean(ctx.env.TMUX);
16
+ },
17
+ buildTab(cwd, command) {
18
+ return { argv: tmuxTabArgv(cwd, command) };
19
+ },
20
+ buildSplit(cwd, command, direction) {
21
+ return { argv: tmuxSplitArgv(cwd, command, direction) };
22
+ },
23
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The engine — turns requests into open surfaces.
3
+ *
4
+ * `specForRequest` and `buildRequests` are pure (planning); `openSurface` and
5
+ * `openSurfaces` add the side-effecting transport. A batch runs sequentially and
6
+ * staggered so a `split-right` lands in the tab that was just opened (the split
7
+ * targets the front pane).
8
+ */
9
+ import type { Backend, EngineContext, LaunchRequest, LaunchResult, LaunchSpec } from './types.js';
10
+ import { type Packing } from './policy.js';
11
+ import { type HostResolver } from './transport.js';
12
+ /** The concrete launch command for a request (pure — no side effects). */
13
+ export declare function specForRequest(req: LaunchRequest): LaunchSpec;
14
+ export interface OpenOptions {
15
+ resolveHost?: HostResolver;
16
+ ctx?: EngineContext;
17
+ }
18
+ /** Open a single surface for one request. Never throws — failures come back in the result. */
19
+ export declare function openSurface(req: LaunchRequest, opts?: OpenOptions): Promise<LaunchResult>;
20
+ /** One command to run as a surface. */
21
+ export interface SurfaceItem {
22
+ cwd: string;
23
+ command: string[];
24
+ }
25
+ export interface BuildRequestsOptions {
26
+ backend: Backend;
27
+ host?: string;
28
+ packing?: Packing;
29
+ }
30
+ /** Turn a list of commands into layout-assigned requests (pure — the planning step). */
31
+ export declare function buildRequests(items: SurfaceItem[], opts: BuildRequestsOptions): LaunchRequest[];
32
+ export interface OpenManyOptions extends OpenOptions, BuildRequestsOptions {
33
+ staggerMs?: number;
34
+ }
35
+ /**
36
+ * Open many surfaces, applying the layout policy (default: two-per-tab).
37
+ * Sequential + staggered so each split follows the tab it splits.
38
+ */
39
+ export declare function openSurfaces(items: SurfaceItem[], opts: OpenManyOptions): Promise<LaunchResult[]>;
@@ -0,0 +1,54 @@
1
+ import { BACKENDS } from './backends/index.js';
2
+ import { planLayouts } from './policy.js';
3
+ import { runSpec } from './transport.js';
4
+ const DEFAULT_STAGGER_MS = 400;
5
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
6
+ /** The concrete launch command for a request (pure — no side effects). */
7
+ export function specForRequest(req) {
8
+ const backend = BACKENDS[req.backend];
9
+ if (!backend)
10
+ throw new Error(`unknown backend: ${req.backend}`);
11
+ if (req.layout === 'tab')
12
+ return backend.buildTab(req.cwd, req.command);
13
+ return backend.buildSplit(req.cwd, req.command, req.layout === 'split-down' ? 'down' : 'right');
14
+ }
15
+ /** Open a single surface for one request. Never throws — failures come back in the result. */
16
+ export async function openSurface(req, opts = {}) {
17
+ try {
18
+ // Both can throw: specForRequest on an unknown backend, runSpec when the
19
+ // SSH transport rejects an invalid --host target. Keep them inside the
20
+ // catch so a bad request degrades to a per-surface failure, never a throw.
21
+ const spec = specForRequest(req);
22
+ const res = await runSpec(spec, req.host, opts.resolveHost);
23
+ return { ok: res.ok, request: req, error: res.error };
24
+ }
25
+ catch (err) {
26
+ return { ok: false, request: req, error: err?.message ?? String(err) };
27
+ }
28
+ }
29
+ /** Turn a list of commands into layout-assigned requests (pure — the planning step). */
30
+ export function buildRequests(items, opts) {
31
+ const layouts = planLayouts(items.length, opts.packing ?? 'two-per-tab');
32
+ return items.map((item, i) => ({
33
+ backend: opts.backend,
34
+ layout: layouts[i],
35
+ cwd: item.cwd,
36
+ command: item.command,
37
+ host: opts.host,
38
+ }));
39
+ }
40
+ /**
41
+ * Open many surfaces, applying the layout policy (default: two-per-tab).
42
+ * Sequential + staggered so each split follows the tab it splits.
43
+ */
44
+ export async function openSurfaces(items, opts) {
45
+ const requests = buildRequests(items, opts);
46
+ const stagger = opts.staggerMs ?? DEFAULT_STAGGER_MS;
47
+ const results = [];
48
+ for (let i = 0; i < requests.length; i++) {
49
+ results.push(await openSurface(requests[i], opts));
50
+ if (i < requests.length - 1)
51
+ await sleep(stagger);
52
+ }
53
+ return results;
54
+ }