@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.
- package/CHANGELOG.md +22 -0
- package/README.md +5 -1
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/cost.js +17 -12
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +59 -14
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.js +6 -1
- package/dist/commands/hosts.js +28 -24
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +54 -7
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/secrets.js +40 -16
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +209 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +73 -1
- package/dist/commands/sessions.js +273 -53
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/index.js +58 -31
- package/dist/lib/agents.js +30 -9
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +76 -16
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +13 -9
- package/dist/lib/browser/service.js +71 -15
- package/dist/lib/daemon.d.ts +35 -0
- package/dist/lib/daemon.js +33 -5
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/events.d.ts +9 -2
- package/dist/lib/events.js +70 -11
- package/dist/lib/exec.js +22 -0
- package/dist/lib/hooks.js +8 -2
- package/dist/lib/hosts/dispatch.js +6 -7
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +45 -0
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +23 -2
- package/dist/lib/hosts/ready.js +35 -13
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/tasks.d.ts +8 -0
- package/dist/lib/hosts/tasks.js +14 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +24 -0
- package/dist/lib/picker.js +124 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +45 -4
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +11 -3
- package/dist/lib/session/discover.js +94 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +283 -2
- package/dist/lib/session/pid-registry.d.ts +21 -0
- package/dist/lib/session/pid-registry.js +80 -0
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +141 -0
- package/dist/lib/session/remote.js +2 -6
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +11 -2
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +11 -3
- package/dist/lib/shims.js +54 -30
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +45 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +60 -62
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +4 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +18 -0
- package/dist/lib/terminal/backends/index.js +29 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +14 -0
- package/dist/lib/terminal/index.js +5 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +7 -3
- package/dist/lib/versions.js +10 -5
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/package.json +1 -1
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
-
|
|
208
|
-
|
|
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
|
-
*
|
|
225
|
-
*
|
|
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
|
|
220
|
+
function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
|
|
232
221
|
return new Promise((resolve, reject) => {
|
|
233
|
-
const child = spawn('
|
|
234
|
-
stdio: ['
|
|
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(`
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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] =
|
|
113
|
+
reg[name] = m;
|
|
113
114
|
await saveTeams(reg);
|
|
114
|
-
return
|
|
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
|
-
|
|
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
|
|
125
|
-
reg[name] =
|
|
130
|
+
const m = { created_at: new Date().toISOString() };
|
|
131
|
+
reg[name] = m;
|
|
126
132
|
await saveTeams(reg);
|
|
127
|
-
|
|
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
|
-
|
|
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
|
+
}
|