@phnx-labs/agents-cli 1.20.50 → 1.20.51
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 +24 -0
- package/dist/commands/browser-picker.js +1 -18
- package/dist/commands/cloud.js +1 -25
- package/dist/commands/computer.d.ts +1 -0
- package/dist/commands/computer.js +129 -8
- package/dist/commands/exec.js +49 -6
- package/dist/commands/factory.js +1 -4
- package/dist/commands/inspect.js +1 -11
- package/dist/commands/mcp.js +2 -6
- package/dist/commands/message.js +1 -4
- package/dist/commands/profiles.js +1 -18
- package/dist/commands/repo.js +33 -14
- package/dist/commands/resource-view.d.ts +1 -0
- package/dist/commands/resource-view.js +5 -17
- package/dist/commands/secrets.d.ts +1 -0
- package/dist/commands/secrets.js +1 -28
- package/dist/commands/sessions-picker.js +1 -18
- package/dist/commands/sessions.js +6 -8
- package/dist/commands/teams-picker.js +1 -32
- package/dist/commands/teams.js +1 -27
- package/dist/commands/tmux.js +1 -3
- package/dist/commands/view.js +1 -9
- package/dist/commands/worktree.js +1 -4
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +20 -33
- package/dist/lib/auto-dispatch-linear.d.ts +18 -0
- package/dist/lib/auto-dispatch-linear.js +107 -0
- package/dist/lib/auto-dispatch-provider.d.ts +10 -0
- package/dist/lib/auto-dispatch-provider.js +25 -0
- package/dist/lib/auto-dispatch.d.ts +87 -0
- package/dist/lib/auto-dispatch.js +142 -0
- package/dist/lib/browser/cdp.js +11 -2
- package/dist/lib/browser/drivers/ssh.d.ts +28 -10
- package/dist/lib/browser/drivers/ssh.js +57 -18
- package/dist/lib/browser/refs.js +1 -5
- package/dist/lib/cli-resources.d.ts +0 -2
- package/dist/lib/cli-resources.js +30 -13
- package/dist/lib/cloud/rush.d.ts +0 -24
- package/dist/lib/cloud/rush.js +0 -31
- package/dist/lib/crabbox/cli.js +4 -1
- package/dist/lib/crabbox/lease.js +29 -1
- package/dist/lib/daemon.js +41 -0
- package/dist/lib/exec.js +31 -14
- package/dist/lib/format.d.ts +38 -0
- package/dist/lib/format.js +108 -0
- package/dist/lib/git.d.ts +21 -0
- package/dist/lib/git.js +92 -0
- package/dist/lib/hooks/cache.d.ts +9 -2
- package/dist/lib/hooks/cache.js +220 -8
- package/dist/lib/hooks.js +17 -8
- package/dist/lib/platform/exec.d.ts +4 -1
- package/dist/lib/platform/exec.js +8 -2
- package/dist/lib/resources.d.ts +0 -8
- package/dist/lib/resources.js +0 -10
- package/dist/lib/runner.js +10 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +11 -1
- package/dist/lib/session/active.js +3 -0
- package/dist/lib/session/db.d.ts +1 -4
- package/dist/lib/session/db.js +20 -25
- package/dist/lib/session/discover.d.ts +2 -2
- package/dist/lib/session/discover.js +61 -48
- package/dist/lib/session/parse.js +35 -34
- package/dist/lib/session/render.d.ts +7 -3
- package/dist/lib/session/render.js +15 -9
- package/dist/lib/session/state.d.ts +55 -0
- package/dist/lib/session/state.js +87 -10
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/shims.d.ts +9 -3
- package/dist/lib/shims.js +44 -8
- package/dist/lib/ssh-tunnel.d.ts +33 -2
- package/dist/lib/ssh-tunnel.js +94 -7
- package/dist/lib/staleness/types.d.ts +0 -1
- package/dist/lib/types.d.ts +14 -1
- package/dist/lib/versions.d.ts +0 -26
- package/dist/lib/versions.js +2 -145
- package/dist/lib/warn-unpushed.d.ts +40 -0
- package/dist/lib/warn-unpushed.js +128 -0
- package/package.json +3 -1
- package/dist/lib/resources/index.d.ts +0 -53
- package/dist/lib/resources/index.js +0 -76
|
@@ -21,6 +21,7 @@ import * as path from 'path';
|
|
|
21
21
|
import { spawnSync } from 'child_process';
|
|
22
22
|
import * as yaml from 'yaml';
|
|
23
23
|
import { listResources, resolveResource } from './resources.js';
|
|
24
|
+
import { composeWin32CommandLine } from './platform/index.js';
|
|
24
25
|
// ─── Validation primitives ───────────────────────────────────────────────────
|
|
25
26
|
/** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
|
|
26
27
|
const SAFE_CHECK_TOKEN = /^[a-zA-Z0-9_./-]+$/;
|
|
@@ -253,19 +254,27 @@ export function resolveCliManifest(name, cwd) {
|
|
|
253
254
|
// ─── Host detection ──────────────────────────────────────────────────────────
|
|
254
255
|
/**
|
|
255
256
|
* Return true if a command resolves on the current PATH. Uses POSIX `command -v`
|
|
256
|
-
* via spawn argv (no shell); results are cached for the
|
|
257
|
+
* (or `where` on Windows) via spawn argv (no shell); results are cached for the
|
|
258
|
+
* lifetime of the process.
|
|
257
259
|
*/
|
|
258
260
|
const cmdExistsCache = new Map();
|
|
259
261
|
export function hasCommand(cmd) {
|
|
260
262
|
if (cmdExistsCache.has(cmd))
|
|
261
263
|
return cmdExistsCache.get(cmd);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
stdio: 'ignore'
|
|
267
|
-
}
|
|
268
|
-
|
|
264
|
+
let ok;
|
|
265
|
+
if (process.platform === 'win32') {
|
|
266
|
+
// `sh` only exists when Git Bash is installed; `where` is the native PATH
|
|
267
|
+
// probe (resolves .exe/.cmd/.bat via PATHEXT). Argv keeps `cmd` uninterpolated.
|
|
268
|
+
ok = spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
// `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
|
|
272
|
+
// with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
|
|
273
|
+
// by callers that haven't validated it, so we route via argv to neutralize metas.
|
|
274
|
+
ok = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
|
|
275
|
+
stdio: 'ignore',
|
|
276
|
+
}).status === 0;
|
|
277
|
+
}
|
|
269
278
|
cmdExistsCache.set(cmd, ok);
|
|
270
279
|
return ok;
|
|
271
280
|
}
|
|
@@ -280,7 +289,19 @@ export function isCliInstalled(manifest) {
|
|
|
280
289
|
return hasCommand(c.cmd);
|
|
281
290
|
}
|
|
282
291
|
const result = spawnSync(c.cmd, c.args, { stdio: 'ignore', timeout: 10_000 });
|
|
283
|
-
|
|
292
|
+
if (result.status === 0)
|
|
293
|
+
return true;
|
|
294
|
+
// On Windows the PATH entry point is often a `.cmd`/`.bat` shim (npm installs,
|
|
295
|
+
// script installers), which Node refuses to spawn without a shell (ENOENT /
|
|
296
|
+
// EINVAL). A failed *spawn* — not a failed run — gets one retry through the
|
|
297
|
+
// shell, as a single pre-composed line (DEP0190-safe). Check tokens are
|
|
298
|
+
// SAFE_CHECK_TOKEN-validated at parse time, so the line cannot inject.
|
|
299
|
+
if (process.platform === 'win32' && result.error) {
|
|
300
|
+
const line = composeWin32CommandLine(c.cmd, c.args);
|
|
301
|
+
const retry = spawnSync(line, { stdio: 'ignore', timeout: 10_000, shell: true });
|
|
302
|
+
return retry.status === 0;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
284
305
|
}
|
|
285
306
|
// ─── Method selection ────────────────────────────────────────────────────────
|
|
286
307
|
/**
|
|
@@ -480,7 +501,3 @@ export function listCliStatus(cwd) {
|
|
|
480
501
|
}));
|
|
481
502
|
return { statuses, errors };
|
|
482
503
|
}
|
|
483
|
-
/** Names of CLIs that are declared but not currently installed on the host. */
|
|
484
|
-
export function getMissingClis(cwd) {
|
|
485
|
-
return listCliStatus(cwd).statuses.filter((s) => !s.installed).map((s) => s.manifest);
|
|
486
|
-
}
|
package/dist/lib/cloud/rush.d.ts
CHANGED
|
@@ -75,30 +75,6 @@ export declare function buildDispatchBody(input: {
|
|
|
75
75
|
*/
|
|
76
76
|
images?: ImageAttachment[] | null;
|
|
77
77
|
}): Record<string, unknown>;
|
|
78
|
-
/** A single account registered in Rush Cloud's multi-account rotation pool. */
|
|
79
|
-
export interface RemoteAccount {
|
|
80
|
-
id: string;
|
|
81
|
-
provider: string;
|
|
82
|
-
email: string | null;
|
|
83
|
-
subscription_type: string | null;
|
|
84
|
-
five_hour_pct: number | null;
|
|
85
|
-
seven_day_pct: number | null;
|
|
86
|
-
usage_fetched_at: string | null;
|
|
87
|
-
created_at: string;
|
|
88
|
-
}
|
|
89
|
-
/** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
|
|
90
|
-
export declare function listRemoteAccounts(): Promise<RemoteAccount[]>;
|
|
91
|
-
/**
|
|
92
|
-
* Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
|
|
93
|
-
* The server validates the token against the Anthropic usage API and stores it
|
|
94
|
-
* encrypted in Vault. Returns the account metadata (no token).
|
|
95
|
-
*/
|
|
96
|
-
export declare function addRemoteAccount(provider: string, pastedToken: string): Promise<RemoteAccount & {
|
|
97
|
-
five_hour_pct: number | null;
|
|
98
|
-
seven_day_pct: number | null;
|
|
99
|
-
}>;
|
|
100
|
-
/** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
|
|
101
|
-
export declare function removeRemoteAccount(id: string): Promise<void>;
|
|
102
78
|
export declare class RushCloudProvider implements CloudProvider {
|
|
103
79
|
id: "rush";
|
|
104
80
|
name: string;
|
package/dist/lib/cloud/rush.js
CHANGED
|
@@ -294,37 +294,6 @@ export function buildDispatchBody(input) {
|
|
|
294
294
|
}
|
|
295
295
|
return body;
|
|
296
296
|
}
|
|
297
|
-
/** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
|
|
298
|
-
export async function listRemoteAccounts() {
|
|
299
|
-
const token = readToken();
|
|
300
|
-
const res = await api('GET', '/api/v1/cloud-accounts', token);
|
|
301
|
-
if (!res.ok) {
|
|
302
|
-
throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
|
|
303
|
-
}
|
|
304
|
-
const data = await res.json();
|
|
305
|
-
return data.accounts ?? [];
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
|
|
309
|
-
* The server validates the token against the Anthropic usage API and stores it
|
|
310
|
-
* encrypted in Vault. Returns the account metadata (no token).
|
|
311
|
-
*/
|
|
312
|
-
export async function addRemoteAccount(provider, pastedToken) {
|
|
313
|
-
const token = readToken();
|
|
314
|
-
const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
|
|
315
|
-
if (!res.ok) {
|
|
316
|
-
throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
|
|
317
|
-
}
|
|
318
|
-
return await res.json();
|
|
319
|
-
}
|
|
320
|
-
/** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
|
|
321
|
-
export async function removeRemoteAccount(id) {
|
|
322
|
-
const token = readToken();
|
|
323
|
-
const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
|
|
324
|
-
if (!res.ok) {
|
|
325
|
-
throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
297
|
export class RushCloudProvider {
|
|
329
298
|
id = 'rush';
|
|
330
299
|
name = 'Rush Cloud';
|
package/dist/lib/crabbox/cli.js
CHANGED
|
@@ -209,7 +209,10 @@ export function crabboxRunScript(slug, script, opts = {}) {
|
|
|
209
209
|
/** Release the lease / delete the box. Best-effort; never throws. */
|
|
210
210
|
export function crabboxStop(slug, opts = {}) {
|
|
211
211
|
try {
|
|
212
|
-
|
|
212
|
+
// Positional target: crabbox's stop subcommand has no --id flag (unlike
|
|
213
|
+
// status/run/ssh) — `stop --id <slug>` dies with "flag provided but not
|
|
214
|
+
// defined: -id" and the box leaks past the run it was leased for.
|
|
215
|
+
const r = spawnSync('crabbox', ['stop', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
|
|
213
216
|
return r.status === 0;
|
|
214
217
|
}
|
|
215
218
|
catch {
|
|
@@ -12,6 +12,34 @@ import { buildCredentialScript } from './runtimes.js';
|
|
|
12
12
|
function q(s) {
|
|
13
13
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Bash snippet that guarantees `agents` is runnable on the box. Fresh crabbox
|
|
17
|
+
* images ship without node, and the box user may not own the global npm prefix,
|
|
18
|
+
* so everything installs user-level under ~/.local (node from the official
|
|
19
|
+
* latest-v22.x tarball to satisfy engines.node >=22.5.0). Exits 96 with a
|
|
20
|
+
* diagnostic when the CLI still isn't runnable — a silent `|| true` here used
|
|
21
|
+
* to surface only as `agents: command not found` deep in the script.
|
|
22
|
+
*/
|
|
23
|
+
const ENSURE_AGENTS_CLI = [
|
|
24
|
+
'export PATH="$HOME/.local/bin:$PATH"',
|
|
25
|
+
'if ! command -v node >/dev/null 2>&1; then',
|
|
26
|
+
' case "$(uname -m)" in aarch64|arm64) narch=arm64;; *) narch=x64;; esac',
|
|
27
|
+
' nver=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ | grep -oE "v22\\.[0-9]+\\.[0-9]+" | head -1)',
|
|
28
|
+
' mkdir -p "$HOME/.local"',
|
|
29
|
+
' curl -fsSL "https://nodejs.org/dist/latest-v22.x/node-$nver-linux-$narch.tar.xz" | tar -xJ -C "$HOME/.local" --strip-components=1',
|
|
30
|
+
'fi',
|
|
31
|
+
'if ! command -v agents >/dev/null 2>&1; then',
|
|
32
|
+
' npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true',
|
|
33
|
+
' npm install -g @phnx-labs/agents-cli >/dev/null 2>&1',
|
|
34
|
+
'fi',
|
|
35
|
+
'if ! command -v agents >/dev/null 2>&1; then',
|
|
36
|
+
' echo "lease bootstrap: agents-cli install failed (node: $(command -v node || echo missing))" >&2',
|
|
37
|
+
' exit 96',
|
|
38
|
+
'fi',
|
|
39
|
+
// Same first-run guard the hosts bootstrap uses (hosts/ready.ts) — a fresh
|
|
40
|
+
// install refuses `agents run` with "agents-cli is not set up" until setup ran.
|
|
41
|
+
'if [ ! -d "$HOME/.agents/.system" ]; then agents setup >/dev/null 2>&1 || true; fi',
|
|
42
|
+
].join('\n');
|
|
15
43
|
/**
|
|
16
44
|
* Build the single bootstrap script run on the box: ensure agents-cli, install
|
|
17
45
|
* the picked runtime CLIs, write their credentials, run the agent, then shred
|
|
@@ -35,7 +63,7 @@ export function buildBootstrapScript(opts) {
|
|
|
35
63
|
const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
|
|
36
64
|
return [
|
|
37
65
|
'set -uo pipefail',
|
|
38
|
-
|
|
66
|
+
ENSURE_AGENTS_CLI,
|
|
39
67
|
installRuntimes,
|
|
40
68
|
credScript,
|
|
41
69
|
`${runParts.join(' ')}`,
|
package/dist/lib/daemon.js
CHANGED
|
@@ -393,6 +393,45 @@ export async function runDaemon() {
|
|
|
393
393
|
};
|
|
394
394
|
const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
|
|
395
395
|
const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
|
|
396
|
+
// Auto-dispatch: for any managed project that has opted in (autoDispatch:true +
|
|
397
|
+
// maxAgents>0 in ~/.agents/factory/projects.json), pick up Linear tickets that
|
|
398
|
+
// are delegated to an agent and still in Todo, and DISPATCH each through
|
|
399
|
+
// agents-cli's own cloud-provider layer (resolveProvider().dispatch(), same as
|
|
400
|
+
// `agents cloud run`) — then mark it Doing so it isn't re-picked. Capped at
|
|
401
|
+
// maxAgents concurrent per project. No hidden Prix dependency: Rush is one
|
|
402
|
+
// provider among rush/codex/factory, pinned per-project via `provider`. OFF
|
|
403
|
+
// unless a project opts in; no opted-in project or no LINEAR_API_KEY is a clean
|
|
404
|
+
// no-op. Overlap-guarded like the probes above. ~every 3 min.
|
|
405
|
+
let autoDispatching = false;
|
|
406
|
+
const runAutoDispatch = async () => {
|
|
407
|
+
if (autoDispatching)
|
|
408
|
+
return;
|
|
409
|
+
autoDispatching = true;
|
|
410
|
+
try {
|
|
411
|
+
const { readAutoDispatchProjects, isEligible, autoDispatchTick } = await import('./auto-dispatch.js');
|
|
412
|
+
const projects = readAutoDispatchProjects();
|
|
413
|
+
if (!projects.some(isEligible))
|
|
414
|
+
return; // opt-in: nothing enabled → skip
|
|
415
|
+
const { createLinearGateway } = await import('./auto-dispatch-linear.js');
|
|
416
|
+
const linear = createLinearGateway();
|
|
417
|
+
if (!linear)
|
|
418
|
+
return; // no LINEAR_API_KEY configured → skip
|
|
419
|
+
const { createProviderDispatcher } = await import('./auto-dispatch-provider.js');
|
|
420
|
+
const dispatcher = createProviderDispatcher();
|
|
421
|
+
const dispatched = await autoDispatchTick({ projects, linear, dispatcher, log: (lvl, m) => log(lvl, m) });
|
|
422
|
+
if (dispatched.length) {
|
|
423
|
+
log('INFO', `auto-dispatch: started ${dispatched.length} delegated ticket(s): ${dispatched.map((d) => d.identifier).join(', ')}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
log('ERROR', `auto-dispatch failed: ${err.message}`);
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
autoDispatching = false;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const autoDispatchInterval = setInterval(() => { void runAutoDispatch(); }, 3 * 60_000);
|
|
434
|
+
const autoDispatchKickoff = setTimeout(() => { void runAutoDispatch(); }, 45_000);
|
|
396
435
|
// Device probe: refresh registered devices' reachability and detect newly
|
|
397
436
|
// appeared tailnet nodes, dropping a sentinel per pending device so the
|
|
398
437
|
// menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
|
|
@@ -500,6 +539,8 @@ export async function runDaemon() {
|
|
|
500
539
|
clearInterval(syncInterval);
|
|
501
540
|
clearInterval(healInterval);
|
|
502
541
|
clearTimeout(healKickoff);
|
|
542
|
+
clearInterval(autoDispatchInterval);
|
|
543
|
+
clearTimeout(autoDispatchKickoff);
|
|
503
544
|
clearInterval(deviceProbeInterval);
|
|
504
545
|
clearTimeout(deviceProbeKickoff);
|
|
505
546
|
clearInterval(tmuxReconcileInterval);
|
package/dist/lib/exec.js
CHANGED
|
@@ -307,12 +307,12 @@ export const AGENT_COMMANDS = {
|
|
|
307
307
|
promptFlag: 'positional',
|
|
308
308
|
resume: { subcommand: 'resume' },
|
|
309
309
|
modeFlags: {
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
edit: ['--sandbox', 'workspace-write', '
|
|
315
|
-
// skip drops the sandbox entirely
|
|
310
|
+
plan: ['--sandbox', 'read-only'],
|
|
311
|
+
// Sandboxed writes inside the workspace. Network stays on (workspace-write
|
|
312
|
+
// disables it by default) so edit-mode runs can still use git/gh/package
|
|
313
|
+
// installs. No approval bypass here — only `skip` drops the guardrails.
|
|
314
|
+
edit: ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true'],
|
|
315
|
+
// skip = codex --yolo: drops the sandbox entirely and approves anything.
|
|
316
316
|
skip: ['--dangerously-bypass-approvals-and-sandbox'],
|
|
317
317
|
},
|
|
318
318
|
jsonFlags: ['--json'],
|
|
@@ -563,14 +563,25 @@ export function buildExecCommand(options) {
|
|
|
563
563
|
throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
|
|
564
564
|
}
|
|
565
565
|
if (resumeSpec && 'subcommand' in resumeSpec) {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// the bypass or it stalls on approval prompts; plan and interactive resume
|
|
570
|
-
// inherit codex's default sandbox and pass no flag.
|
|
571
|
-
if (!interactive && resolvedMode !== 'plan') {
|
|
566
|
+
if (resolvedMode === 'skip') {
|
|
567
|
+
// skip = yolo on resume too; both `codex resume` (TUI) and
|
|
568
|
+
// `codex exec resume` accept the bypass flag.
|
|
572
569
|
cmd.push('--dangerously-bypass-approvals-and-sandbox');
|
|
573
570
|
}
|
|
571
|
+
else if (interactive) {
|
|
572
|
+
// `codex resume` (TUI) accepts the same -s/--sandbox flags as a fresh run.
|
|
573
|
+
cmd.push(...modeFlags);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// `codex exec resume` rejects `--sandbox <mode>` (verified against
|
|
577
|
+
// `codex exec resume --help` on 0.142.5), but takes -c config overrides —
|
|
578
|
+
// map the mode through sandbox_mode so a non-skip resume never gets the
|
|
579
|
+
// approval/sandbox bypass.
|
|
580
|
+
cmd.push('-c', `sandbox_mode=${resolvedMode === 'plan' ? 'read-only' : 'workspace-write'}`);
|
|
581
|
+
if (resolvedMode !== 'plan') {
|
|
582
|
+
cmd.push('-c', 'sandbox_workspace_write.network_access=true');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
574
585
|
}
|
|
575
586
|
else if (options.agent === 'kimi' && !interactive) {
|
|
576
587
|
// kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
|
|
@@ -659,8 +670,14 @@ export function buildExecCommand(options) {
|
|
|
659
670
|
cmd.push(template.promptFlag, options.prompt);
|
|
660
671
|
}
|
|
661
672
|
}
|
|
662
|
-
// Claude
|
|
663
|
-
|
|
673
|
+
// Extra writable dirs. Claude and Codex both take `--add-dir`; for Codex it
|
|
674
|
+
// widens the workspace-write sandbox (teams relies on this to let teammates
|
|
675
|
+
// write ~/.agents), so it must actually be forwarded — it used to be
|
|
676
|
+
// claude-only, silently dropped for codex and masked by edit mode carrying
|
|
677
|
+
// the approval/sandbox bypass. Codex's resume forms reject --add-dir, so
|
|
678
|
+
// skip it there (claude's flag-based resume accepts it).
|
|
679
|
+
if (options.addDirs &&
|
|
680
|
+
(options.agent === 'claude' || (options.agent === 'codex' && !options.resume))) {
|
|
664
681
|
for (const dir of options.addDirs) {
|
|
665
682
|
cmd.push('--add-dir', dir);
|
|
666
683
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Print `msg` in red to stderr and exit the process with `code`. */
|
|
2
|
+
export declare function die(msg: string, code?: number): never;
|
|
3
|
+
/**
|
|
4
|
+
* Truncate `s` to at most `max` characters, appending a single-char ellipsis
|
|
5
|
+
* (`…`) when shortened. Character-count based (not ANSI/width aware — use
|
|
6
|
+
* `truncateToWidth` from `session/width.ts` for colored strings).
|
|
7
|
+
*/
|
|
8
|
+
export declare function truncate(s: string, max: number): string;
|
|
9
|
+
/**
|
|
10
|
+
* Format an ISO timestamp as a compact relative age: "just now", "5m ago",
|
|
11
|
+
* "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
|
|
12
|
+
* variant that once lived in `cloud.ts` is deliberately dropped. (For the
|
|
13
|
+
* session-list long form with calendar fallback, see
|
|
14
|
+
* `formatRelativeTime` in `session/relative-time.ts`.)
|
|
15
|
+
*/
|
|
16
|
+
export declare function relTime(iso: string): string;
|
|
17
|
+
/** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
|
|
18
|
+
export declare function humanDuration(ms: number): string;
|
|
19
|
+
/**
|
|
20
|
+
* Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
|
|
21
|
+
* wrappers). Matches the full CSI sequence including the `\x1b` escape.
|
|
22
|
+
*/
|
|
23
|
+
export declare function visibleWidth(s: string): number;
|
|
24
|
+
/** Pad `s` with trailing spaces to a target character width. */
|
|
25
|
+
export declare function padRight(s: string, width: number): string;
|
|
26
|
+
/** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
|
|
27
|
+
export declare function padVisible(s: string, width: number): string;
|
|
28
|
+
/** True when `--json` was passed or stdout is not a TTY. */
|
|
29
|
+
export declare function isJsonMode(opts: {
|
|
30
|
+
json?: boolean;
|
|
31
|
+
}): boolean;
|
|
32
|
+
/** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
|
|
33
|
+
export declare function readStdinSync(): string;
|
|
34
|
+
/**
|
|
35
|
+
* Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
|
|
36
|
+
* stdout is a TTY; otherwise return `text` unchanged.
|
|
37
|
+
*/
|
|
38
|
+
export declare function termLink(text: string, filePath: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared terminal-formatting helpers.
|
|
3
|
+
*
|
|
4
|
+
* These small utilities were previously copy-pasted across ~20 command and lib
|
|
5
|
+
* files, and had drifted into behavior differences (truncation ellipsis `...`
|
|
6
|
+
* vs `…` vs `.`; `relTime` long "5 minutes ago" vs short "5m ago"; a
|
|
7
|
+
* `visibleWidth` regex missing its `\x1b` escape). This module is the single
|
|
8
|
+
* canonical home — every consumer imports from here.
|
|
9
|
+
*/
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { readSync } from 'node:fs';
|
|
12
|
+
/** Print `msg` in red to stderr and exit the process with `code`. */
|
|
13
|
+
export function die(msg, code = 1) {
|
|
14
|
+
console.error(chalk.red(msg));
|
|
15
|
+
process.exit(code);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Truncate `s` to at most `max` characters, appending a single-char ellipsis
|
|
19
|
+
* (`…`) when shortened. Character-count based (not ANSI/width aware — use
|
|
20
|
+
* `truncateToWidth` from `session/width.ts` for colored strings).
|
|
21
|
+
*/
|
|
22
|
+
export function truncate(s, max) {
|
|
23
|
+
return s.length <= max ? s : s.slice(0, max - 1) + '…';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Format an ISO timestamp as a compact relative age: "just now", "5m ago",
|
|
27
|
+
* "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
|
|
28
|
+
* variant that once lived in `cloud.ts` is deliberately dropped. (For the
|
|
29
|
+
* session-list long form with calendar fallback, see
|
|
30
|
+
* `formatRelativeTime` in `session/relative-time.ts`.)
|
|
31
|
+
*/
|
|
32
|
+
export function relTime(iso) {
|
|
33
|
+
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
|
34
|
+
if (secs < 10)
|
|
35
|
+
return 'just now';
|
|
36
|
+
if (secs < 60)
|
|
37
|
+
return `${secs}s ago`;
|
|
38
|
+
if (secs < 3600)
|
|
39
|
+
return `${Math.floor(secs / 60)}m ago`;
|
|
40
|
+
if (secs < 86400)
|
|
41
|
+
return `${Math.floor(secs / 3600)}h ago`;
|
|
42
|
+
return `${Math.floor(secs / 86400)}d ago`;
|
|
43
|
+
}
|
|
44
|
+
/** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
|
|
45
|
+
export function humanDuration(ms) {
|
|
46
|
+
const s = Math.floor(ms / 1000);
|
|
47
|
+
if (s < 60)
|
|
48
|
+
return `${s}s`;
|
|
49
|
+
const m = Math.floor(s / 60);
|
|
50
|
+
if (m < 60)
|
|
51
|
+
return `${m}m`;
|
|
52
|
+
const h = Math.floor(m / 60);
|
|
53
|
+
const mm = m % 60;
|
|
54
|
+
if (h < 24)
|
|
55
|
+
return mm ? `${h}h ${mm}m` : `${h}h`;
|
|
56
|
+
const d = Math.floor(h / 24);
|
|
57
|
+
const hh = h % 24;
|
|
58
|
+
return hh ? `${d}d ${hh}h` : `${d}d`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
|
|
62
|
+
* wrappers). Matches the full CSI sequence including the `\x1b` escape.
|
|
63
|
+
*/
|
|
64
|
+
export function visibleWidth(s) {
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '').length;
|
|
67
|
+
}
|
|
68
|
+
/** Pad `s` with trailing spaces to a target character width. */
|
|
69
|
+
export function padRight(s, width) {
|
|
70
|
+
return s.length >= width ? s : s + ' '.repeat(width - s.length);
|
|
71
|
+
}
|
|
72
|
+
/** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
|
|
73
|
+
export function padVisible(s, width) {
|
|
74
|
+
const w = visibleWidth(s);
|
|
75
|
+
return w >= width ? s : s + ' '.repeat(width - w);
|
|
76
|
+
}
|
|
77
|
+
/** True when `--json` was passed or stdout is not a TTY. */
|
|
78
|
+
export function isJsonMode(opts) {
|
|
79
|
+
return Boolean(opts.json) || !process.stdout.isTTY;
|
|
80
|
+
}
|
|
81
|
+
/** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
|
|
82
|
+
export function readStdinSync() {
|
|
83
|
+
const chunks = [];
|
|
84
|
+
const buf = Buffer.alloc(65536);
|
|
85
|
+
while (true) {
|
|
86
|
+
let bytesRead;
|
|
87
|
+
try {
|
|
88
|
+
bytesRead = readSync(0, buf, 0, buf.length, null);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (bytesRead === 0)
|
|
94
|
+
break;
|
|
95
|
+
chunks.push(Buffer.from(buf.subarray(0, bytesRead)));
|
|
96
|
+
}
|
|
97
|
+
return Buffer.concat(chunks).toString('utf-8').trim();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
|
|
101
|
+
* stdout is a TTY; otherwise return `text` unchanged.
|
|
102
|
+
*/
|
|
103
|
+
export function termLink(text, filePath) {
|
|
104
|
+
if (!filePath || !process.stdout.isTTY)
|
|
105
|
+
return text;
|
|
106
|
+
const url = `file://${filePath}`;
|
|
107
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
108
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -104,6 +104,27 @@ export declare function cloneIntoExisting(source: string, targetDir: string): Pr
|
|
|
104
104
|
commit: string;
|
|
105
105
|
error?: string;
|
|
106
106
|
}>;
|
|
107
|
+
/**
|
|
108
|
+
* Git-back an EXISTING, populated directory from a remote — clone it in place
|
|
109
|
+
* without deleting the local files. Turns a plain `~/.agents` folder (which setup
|
|
110
|
+
* creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
|
|
111
|
+
* into a real clone of the user's config remote, so `agents repo pull/push` and
|
|
112
|
+
* `agents sync` work on a fresh or Windows machine that never got the manual clone.
|
|
113
|
+
*
|
|
114
|
+
* Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
|
|
115
|
+
* BACKS UP every tracked file whose local copy differs from the remote — into a
|
|
116
|
+
* sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
|
|
117
|
+
* before overwriting it. So a box with local edits to agents.yaml/hooks/rules
|
|
118
|
+
* doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
|
|
119
|
+
* all gitignored) is never touched because `checkout .` only restores tracked paths.
|
|
120
|
+
*/
|
|
121
|
+
export declare function adoptRepo(source: string, targetDir: string): Promise<{
|
|
122
|
+
success: boolean;
|
|
123
|
+
commit: string;
|
|
124
|
+
backupDir?: string;
|
|
125
|
+
backedUp: string[];
|
|
126
|
+
error?: string;
|
|
127
|
+
}>;
|
|
107
128
|
/**
|
|
108
129
|
* Check if the repo's origin points to the system repo.
|
|
109
130
|
*/
|
package/dist/lib/git.js
CHANGED
|
@@ -462,6 +462,98 @@ export async function cloneIntoExisting(source, targetDir) {
|
|
|
462
462
|
return { success: false, commit: '', error: err.message };
|
|
463
463
|
}
|
|
464
464
|
}
|
|
465
|
+
/**
|
|
466
|
+
* Git-back an EXISTING, populated directory from a remote — clone it in place
|
|
467
|
+
* without deleting the local files. Turns a plain `~/.agents` folder (which setup
|
|
468
|
+
* creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
|
|
469
|
+
* into a real clone of the user's config remote, so `agents repo pull/push` and
|
|
470
|
+
* `agents sync` work on a fresh or Windows machine that never got the manual clone.
|
|
471
|
+
*
|
|
472
|
+
* Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
|
|
473
|
+
* BACKS UP every tracked file whose local copy differs from the remote — into a
|
|
474
|
+
* sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
|
|
475
|
+
* before overwriting it. So a box with local edits to agents.yaml/hooks/rules
|
|
476
|
+
* doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
|
|
477
|
+
* all gitignored) is never touched because `checkout .` only restores tracked paths.
|
|
478
|
+
*/
|
|
479
|
+
export async function adoptRepo(source, targetDir) {
|
|
480
|
+
const trimmed = source.trim();
|
|
481
|
+
if (fs.existsSync(path.join(targetDir, '.git'))) {
|
|
482
|
+
return { success: false, commit: '', backedUp: [], error: 'Already a git repo — nothing to adopt' };
|
|
483
|
+
}
|
|
484
|
+
// Preserve the user's transport. `parseSource` THROWS for `ssh://` and any
|
|
485
|
+
// non-github `git@host:` URL, and rewrites `git@github.com:x` → https (breaking
|
|
486
|
+
// SSH-key-only auth — the common config-repo setup — so a private clone hangs on
|
|
487
|
+
// a credential prompt). So for an SSH URL, clone it AS-IS and never call
|
|
488
|
+
// parseSource; for everything else, normalize + reject local via parseSource —
|
|
489
|
+
// inside the try, so a malformed URL returns a graceful error, not a stack trace.
|
|
490
|
+
const isSsh = trimmed.startsWith('git@') || trimmed.startsWith('ssh://');
|
|
491
|
+
const tempDir = path.join(targetDir, '.git-adopt-temp');
|
|
492
|
+
try {
|
|
493
|
+
let cloneUrl;
|
|
494
|
+
let ref;
|
|
495
|
+
if (isSsh) {
|
|
496
|
+
cloneUrl = trimmed; // SSH stays SSH; clone the remote's default HEAD.
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const parsed = parseSource(source);
|
|
500
|
+
if (parsed.type === 'local') {
|
|
501
|
+
return { success: false, commit: '', backedUp: [], error: 'Cannot adopt from a local source' };
|
|
502
|
+
}
|
|
503
|
+
cloneUrl = parsed.url;
|
|
504
|
+
ref = parsed.ref;
|
|
505
|
+
}
|
|
506
|
+
assertSafeGitTransport(cloneUrl);
|
|
507
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
508
|
+
// Idempotency: clear a stale temp left by an interrupted prior run.
|
|
509
|
+
if (fs.existsSync(tempDir))
|
|
510
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
511
|
+
// Clone to temp, then move its .git in so the index == remote HEAD.
|
|
512
|
+
// Fail fast on a missing credential instead of hanging on a prompt: set
|
|
513
|
+
// GIT_TERMINAL_PROMPT=0 on the inherited env directly rather than via
|
|
514
|
+
// simple-git's `.env()`, which validates and rejects command-like vars the
|
|
515
|
+
// harness may set (GIT_EDITOR, PAGER, …) — the child inherits process.env,
|
|
516
|
+
// and non-interactive git is what we always want in the CLI anyway.
|
|
517
|
+
process.env.GIT_TERMINAL_PROMPT = '0';
|
|
518
|
+
await simpleGit().clone(cloneUrl, tempDir);
|
|
519
|
+
const repoGit = simpleGit(tempDir);
|
|
520
|
+
if (ref)
|
|
521
|
+
await repoGit.checkout(ref);
|
|
522
|
+
fs.renameSync(path.join(tempDir, '.git'), path.join(targetDir, '.git'));
|
|
523
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
524
|
+
const targetGit = simpleGit(targetDir);
|
|
525
|
+
// Back up any TRACKED file whose local copy differs from the remote before the
|
|
526
|
+
// checkout clobbers it. `diff --name-only` (worktree vs the moved-in index) is
|
|
527
|
+
// exactly that set; a deleted-locally file has nothing to preserve.
|
|
528
|
+
const diff = await targetGit.diff(['--name-only']);
|
|
529
|
+
const clobbered = diff.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
530
|
+
let backupDir;
|
|
531
|
+
const backedUp = [];
|
|
532
|
+
if (clobbered.length > 0) {
|
|
533
|
+
backupDir = path.join(path.dirname(targetDir), path.basename(targetDir) + '.pre-adopt-backup');
|
|
534
|
+
for (const rel of clobbered) {
|
|
535
|
+
const src = path.join(targetDir, rel);
|
|
536
|
+
if (!fs.existsSync(src))
|
|
537
|
+
continue;
|
|
538
|
+
const dst = path.join(backupDir, rel);
|
|
539
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
540
|
+
fs.copyFileSync(src, dst);
|
|
541
|
+
backedUp.push(rel);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// Materialize the remote's tracked files (respects .gitignore, so
|
|
545
|
+
// .cache/.history/.system stay put), overwriting the now-backed-up locals.
|
|
546
|
+
await targetGit.checkout('.');
|
|
547
|
+
installGithooksSymlinks(targetDir);
|
|
548
|
+
const log = await targetGit.log({ maxCount: 1 });
|
|
549
|
+
return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', backupDir, backedUp };
|
|
550
|
+
}
|
|
551
|
+
catch (err) {
|
|
552
|
+
if (fs.existsSync(tempDir))
|
|
553
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
554
|
+
return { success: false, commit: '', backedUp: [], error: err.message };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
465
557
|
/**
|
|
466
558
|
* Check if the repo's origin points to the system repo.
|
|
467
559
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { HookCache, HookCacheConfig } from '../types.js';
|
|
1
|
+
import type { HookCache, HookCacheConfig, HookMatches } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Parse a `cache:` value from hooks.yaml into the canonical config form.
|
|
4
4
|
* Accepts the shorthand string ("5m", "30s-bg") or the full object form.
|
|
@@ -30,11 +30,18 @@ export interface HookShimPaths {
|
|
|
30
30
|
/**
|
|
31
31
|
* Generate (or refresh) the shim script for a hook. Idempotent — only writes
|
|
32
32
|
* when the content differs from what's on disk. Returns the absolute shim path.
|
|
33
|
+
*
|
|
34
|
+
* A shim is generated when the hook opts into caching (`cache`) and/or declares
|
|
35
|
+
* `matches:` predicates. When `matches` is present the shim gates execution on
|
|
36
|
+
* those predicates before running the underlying script (see `renderShim`);
|
|
37
|
+
* when `cache` is absent the shim is a thin pass-through wrapper that only
|
|
38
|
+
* applies the gate and forwards stdin/stdout unchanged.
|
|
33
39
|
*/
|
|
34
40
|
export declare function generateHookShim(args: {
|
|
35
41
|
name: string;
|
|
36
42
|
scriptPath: string;
|
|
37
|
-
cache
|
|
43
|
+
cache?: HookCacheConfig | null;
|
|
44
|
+
matches?: HookMatches;
|
|
38
45
|
paths?: HookShimPaths;
|
|
39
46
|
}): string;
|
|
40
47
|
/**
|