@phnx-labs/agents-cli 1.20.73 → 1.20.76
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 +989 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +31 -1
- package/dist/commands/exec.js +31 -3
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +87 -9
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/setup.js +6 -1
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +51 -11
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +99 -31
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +56 -0
- package/dist/lib/agents.js +133 -7
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +60 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +25 -0
- package/dist/lib/import.js +107 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/project-resources.js +20 -5
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +27 -1
- package/dist/lib/shims.js +88 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +15 -36
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +48 -2
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +22 -2
- package/dist/lib/versions.js +98 -10
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
package/dist/lib/import.js
CHANGED
|
@@ -20,9 +20,9 @@ import * as fs from 'fs';
|
|
|
20
20
|
import * as os from 'os';
|
|
21
21
|
import * as path from 'path';
|
|
22
22
|
import { AGENTS } from './agents.js';
|
|
23
|
-
import { getVersionsDir } from './state.js';
|
|
23
|
+
import { getUserAgentsDir, getVersionsDir } from './state.js';
|
|
24
24
|
import { setGlobalDefault } from './versions.js';
|
|
25
|
-
import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks } from './shims.js';
|
|
25
|
+
import { createShim, createVersionedAlias, ensureShimCurrent, switchHomeFileSymlinks, assertIsolationBoundary } from './shims.js';
|
|
26
26
|
const IMPORT_VERSION_RE = /^(?:latest|[A-Za-z0-9._+-]{1,64})$/;
|
|
27
27
|
export function isValidImportVersion(version) {
|
|
28
28
|
return IMPORT_VERSION_RE.test(version);
|
|
@@ -39,6 +39,12 @@ export async function importAgentConfig(agentId, version) {
|
|
|
39
39
|
if (!isValidImportVersion(version)) {
|
|
40
40
|
return { success: false, error: `Invalid version: ${JSON.stringify(version)}` };
|
|
41
41
|
}
|
|
42
|
+
// Adoption, done inline rather than via switchConfigSymlink — so it needs the gate
|
|
43
|
+
// directly. commands/import.ts checks at its entry point too (it must: it registers
|
|
44
|
+
// a normal version first, which would un-protect the agent before this runs), but
|
|
45
|
+
// an exported function that moves the user's real config must not depend on every
|
|
46
|
+
// future caller remembering.
|
|
47
|
+
assertIsolationBoundary(agentId, 'adopt your existing install');
|
|
42
48
|
const agent = AGENTS[agentId];
|
|
43
49
|
const configDir = agent.configDir;
|
|
44
50
|
const versionsDir = getVersionsDir();
|
|
@@ -237,3 +243,102 @@ export function resolvePackageDirFromBinary(binaryPath) {
|
|
|
237
243
|
return null;
|
|
238
244
|
}
|
|
239
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Seed an ISOLATED version's home from the user's real `~/.<agent>` — the mirror of
|
|
248
|
+
* {@link importAgentConfig}, which adopts.
|
|
249
|
+
*
|
|
250
|
+
* The difference is the whole point: this COPIES and leaves the original in place,
|
|
251
|
+
* never symlinks it, never sets a default, never creates a shim. So an isolated copy
|
|
252
|
+
* can start from the setup the user already has instead of from nothing, which was
|
|
253
|
+
* the only way to get a working sandbox before.
|
|
254
|
+
*
|
|
255
|
+
* Credentials are skipped by default and reported, not silently included. An isolated
|
|
256
|
+
* copy is a separate principal — `agents add --isolated` already tells the user to
|
|
257
|
+
* sign in on first run — and copying tokens into it should be a choice, not a side
|
|
258
|
+
* effect of wanting your settings. `--with-auth` opts in.
|
|
259
|
+
*/
|
|
260
|
+
export function seedIsolatedConfigFromLocal(agentId, version, opts = {}) {
|
|
261
|
+
const agent = AGENTS[agentId];
|
|
262
|
+
const configDir = agent.configDir;
|
|
263
|
+
const versionHome = path.join(getVersionsDir(), agentId, version, 'home');
|
|
264
|
+
// Mirror importAgentConfig's derivation so nested config dirs (e.g. Antigravity's
|
|
265
|
+
// ~/.gemini/antigravity-cli) land where the shim expects them.
|
|
266
|
+
let dest = path.join(versionHome, path.relative(os.homedir(), configDir));
|
|
267
|
+
// Codex uses a SUN_LEN-safe CODEX_HOME: `home/.codex` is a SYMLINK to
|
|
268
|
+
// `~/.agents/.codex-homes/<version>/.codex`, because the real path is too long for a
|
|
269
|
+
// unix socket. cpSync cannot overwrite a symlink with a directory (it fails with
|
|
270
|
+
// "Cannot overwrite non-directory"), and writing beside it would put settings
|
|
271
|
+
// somewhere the agent never reads. Follow the link and seed the actual home.
|
|
272
|
+
try {
|
|
273
|
+
if (fs.lstatSync(dest).isSymbolicLink())
|
|
274
|
+
dest = fs.realpathSync(dest);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
/* not created yet — the plain path is correct */
|
|
278
|
+
}
|
|
279
|
+
const result = { seeded: false, from: configDir, to: dest, skippedAuth: [], skippedRuntime: [] };
|
|
280
|
+
if (!fs.existsSync(configDir))
|
|
281
|
+
return result;
|
|
282
|
+
// Known credential paths, relative to the config dir. `authFiles` covers the agents
|
|
283
|
+
// that declare them for cross-version carry; the rest are verified filenames for
|
|
284
|
+
// agents that do not declare any (codex `auth.json`, claude `.credentials.json`).
|
|
285
|
+
// Runtime state, not settings. Seeding a sandbox with the user's session history,
|
|
286
|
+
// logs and caches duplicated 757MB on a real machine — 349MB of `sessions` alone —
|
|
287
|
+
// for a copy that wants config. These are regenerated by the agent as it runs, and
|
|
288
|
+
// an isolated copy keeps its own; carrying them over also drags conversation history
|
|
289
|
+
// into a sandbox the user may have created precisely to keep separate.
|
|
290
|
+
const RUNTIME_PREFIXES = ['sessions', 'log', 'logs', 'cache', '.tmp', 'tmp', 'generated_images'];
|
|
291
|
+
const RUNTIME_FILES = ['history.jsonl', 'session_index.jsonl'];
|
|
292
|
+
const isRuntime = (rel) => !opts.all && (RUNTIME_FILES.includes(rel) ||
|
|
293
|
+
/\.sqlite(-shm|-wal)?$/.test(rel) ||
|
|
294
|
+
RUNTIME_PREFIXES.some((d) => rel === d || rel.startsWith(d + path.sep)));
|
|
295
|
+
const authRel = new Set([
|
|
296
|
+
...(agent.authFiles ?? []),
|
|
297
|
+
'auth.json',
|
|
298
|
+
'.credentials.json',
|
|
299
|
+
'credentials.json',
|
|
300
|
+
]);
|
|
301
|
+
try {
|
|
302
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
303
|
+
const agentsDir = getUserAgentsDir();
|
|
304
|
+
const inside = agentsDir + path.sep;
|
|
305
|
+
fs.cpSync(configDir, dest, {
|
|
306
|
+
recursive: true,
|
|
307
|
+
// `force: true` is Node's default, but Bun drops it when a `filter` is supplied —
|
|
308
|
+
// existing files are then silently left alone. `dist/bin/agents` is bun-compiled,
|
|
309
|
+
// so this is a production path, not just a test artifact. State it explicitly.
|
|
310
|
+
force: true,
|
|
311
|
+
filter: (src) => {
|
|
312
|
+
const rel = path.relative(configDir, src);
|
|
313
|
+
if (!opts.withAuth && rel && (authRel.has(rel) || rel.startsWith('credentials' + path.sep))) {
|
|
314
|
+
result.skippedAuth.push(rel);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
if (rel && isRuntime(rel)) {
|
|
318
|
+
// Record only the top-level name so the report stays one line per item.
|
|
319
|
+
const top = rel.split(path.sep)[0];
|
|
320
|
+
if (!result.skippedRuntime.includes(top))
|
|
321
|
+
result.skippedRuntime.push(top);
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
// Same rule as the export path: a link into ~/.agents would dangle for a copy
|
|
325
|
+
// that is supposed to stand on its own.
|
|
326
|
+
try {
|
|
327
|
+
const st = fs.lstatSync(src);
|
|
328
|
+
if (st.isSymbolicLink()) {
|
|
329
|
+
const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
|
|
330
|
+
if (tgt === agentsDir || tgt.startsWith(inside))
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch { /* let cpSync surface unreadable entries */ }
|
|
335
|
+
return true;
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
result.seeded = true;
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
return { ...result, error: err.message };
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { IsolationBoundaryError } from './shims.js';
|
|
2
|
+
/**
|
|
3
|
+
* Turn an {@link IsolationBoundaryError} into guidance. The boundary is enforced by a
|
|
4
|
+
* throw so it cannot be forgotten; this is what keeps that from surfacing as a stack
|
|
5
|
+
* trace. The remedy is always the same shape, because the protection is derived from
|
|
6
|
+
* the isolated copies themselves: drop them and the agent is ordinary again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function explainIsolationBoundary(err: IsolationBoundaryError): void;
|
|
9
|
+
/** Run `fn`, converting a boundary refusal into guidance + a non-zero exit. */
|
|
10
|
+
export declare function withIsolationBoundary<T>(fn: () => Promise<T> | T): Promise<T | undefined>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { IsolationBoundaryError } from './shims.js';
|
|
3
|
+
import { listInstalledVersions } from './versions.js';
|
|
4
|
+
/**
|
|
5
|
+
* Turn an {@link IsolationBoundaryError} into guidance. The boundary is enforced by a
|
|
6
|
+
* throw so it cannot be forgotten; this is what keeps that from surfacing as a stack
|
|
7
|
+
* trace. The remedy is always the same shape, because the protection is derived from
|
|
8
|
+
* the isolated copies themselves: drop them and the agent is ordinary again.
|
|
9
|
+
*/
|
|
10
|
+
export function explainIsolationBoundary(err) {
|
|
11
|
+
const versions = listInstalledVersions(err.agent);
|
|
12
|
+
console.error(chalk.red(`\n${err.agent} is installed only as isolated copies.`));
|
|
13
|
+
console.error(chalk.gray(` Refused: ${err.operation} — that is exactly what --isolated promises not to do.`));
|
|
14
|
+
console.error(chalk.gray('\n To keep the sandbox and act inside it:'));
|
|
15
|
+
console.error(chalk.gray(` agents add ${err.agent}@<version> --isolated # another isolated copy`));
|
|
16
|
+
console.error(chalk.gray(` agents use ${err.agent}@<version> # pick which one 'agents run ${err.agent}' uses`));
|
|
17
|
+
console.error(chalk.gray(` agents export ${err.agent} # copy its config out to your real config dir`));
|
|
18
|
+
console.error(chalk.gray('\n To manage this agent normally instead, remove the isolated copies first:'));
|
|
19
|
+
for (const v of versions) {
|
|
20
|
+
console.error(chalk.gray(` agents remove ${err.agent}@${v} --isolated`));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Run `fn`, converting a boundary refusal into guidance + a non-zero exit. */
|
|
24
|
+
export async function withIsolationBoundary(fn) {
|
|
25
|
+
try {
|
|
26
|
+
return await fn();
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
if (err instanceof IsolationBoundaryError) {
|
|
30
|
+
explainIsolationBoundary(err);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -18,6 +18,26 @@
|
|
|
18
18
|
export declare function menubarDisabledByUser(): boolean;
|
|
19
19
|
/** True if the launchd plist for the menu-bar service is installed. */
|
|
20
20
|
export declare function menubarServiceInstalled(): boolean;
|
|
21
|
+
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
22
|
+
export declare function codesignVerifies(appPath: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Guarantee the installed bundle has a valid signature on THIS machine.
|
|
25
|
+
*
|
|
26
|
+
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
27
|
+
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
28
|
+
* kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
|
|
29
|
+
* (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
|
|
30
|
+
* an ad-hoc release helper crash-loops forever and its unstable identity makes
|
|
31
|
+
* the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
|
|
32
|
+
* re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
|
|
33
|
+
* matching cdhash, which the kernel accepts.
|
|
34
|
+
*
|
|
35
|
+
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
36
|
+
* still verifies — so we leave it alone and only re-sign when verification
|
|
37
|
+
* fails. Returns whether the bundle ends up validly signed. No-op cost on the
|
|
38
|
+
* common (already-valid) path is a single `codesign --verify`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function ensureValidSignature(appPath: string): boolean;
|
|
21
41
|
/**
|
|
22
42
|
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
23
43
|
* Returns the installed executable path, or null if no source bundle ships
|
|
@@ -135,6 +135,37 @@ function copyAppBundle(src, dest) {
|
|
|
135
135
|
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
/** True when the bundle carries a signature the kernel will accept at launch. */
|
|
139
|
+
export function codesignVerifies(appPath) {
|
|
140
|
+
const r = spawnSync('codesign', ['--verify', '--strict', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
141
|
+
return r.status === 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Guarantee the installed bundle has a valid signature on THIS machine.
|
|
145
|
+
*
|
|
146
|
+
* npm's pack/extract strips the ad-hoc/linker signature the release baked into
|
|
147
|
+
* the helper, leaving `code object is not signed at all`. On macOS 26+ the
|
|
148
|
+
* kernel's code-signing monitor SIGKILLs an unsigned/invalid binary at launch
|
|
149
|
+
* (`SIGKILL (Code Signature Invalid)`), so under the launchd `KeepAlive` service
|
|
150
|
+
* an ad-hoc release helper crash-loops forever and its unstable identity makes
|
|
151
|
+
* the Accessibility grant (needed for the clip→paste keystroke in Clip.swift)
|
|
152
|
+
* re-prompt every time. A fresh ad-hoc re-sign gives the on-disk bytes a
|
|
153
|
+
* matching cdhash, which the kernel accepts.
|
|
154
|
+
*
|
|
155
|
+
* A Developer-ID-signed helper survives npm untouched — its embedded signature
|
|
156
|
+
* still verifies — so we leave it alone and only re-sign when verification
|
|
157
|
+
* fails. Returns whether the bundle ends up validly signed. No-op cost on the
|
|
158
|
+
* common (already-valid) path is a single `codesign --verify`.
|
|
159
|
+
*/
|
|
160
|
+
export function ensureValidSignature(appPath) {
|
|
161
|
+
if (codesignVerifies(appPath))
|
|
162
|
+
return true;
|
|
163
|
+
// Drop any quarantine/xattrs the tarball round-trip added (they can break
|
|
164
|
+
// codesign), then re-sign ad-hoc under the helper's stable bundle identifier.
|
|
165
|
+
spawnSync('xattr', ['-cr', appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
166
|
+
spawnSync('codesign', ['--force', '--sign', '-', '--identifier', SERVICE_LABEL, appPath], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
167
|
+
return codesignVerifies(appPath);
|
|
168
|
+
}
|
|
138
169
|
/**
|
|
139
170
|
* Copy the bundled `.app` to the stable user path (idempotent unless forced).
|
|
140
171
|
* Returns the installed executable path, or null if no source bundle ships
|
|
@@ -147,9 +178,14 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
147
178
|
if (!src)
|
|
148
179
|
return null;
|
|
149
180
|
const dest = installedAppPath();
|
|
150
|
-
if (!opts.forceReinstall && fs.existsSync(dest))
|
|
181
|
+
if (!opts.forceReinstall && fs.existsSync(dest)) {
|
|
182
|
+
// Self-heal an already-installed bundle whose signature npm stripped on a
|
|
183
|
+
// prior upgrade (macOS 26+ SIGKILLs it otherwise) without a forced recopy.
|
|
184
|
+
ensureValidSignature(dest);
|
|
151
185
|
return installedExecutablePath();
|
|
186
|
+
}
|
|
152
187
|
copyAppBundle(src, dest);
|
|
188
|
+
ensureValidSignature(dest);
|
|
153
189
|
return installedExecutablePath();
|
|
154
190
|
}
|
|
155
191
|
function xmlEscape(s) {
|
|
@@ -241,6 +277,14 @@ export function enableMenubarService(opts = { clearOptOut: true }) {
|
|
|
241
277
|
const exec = ensureMenubarAppInstalled({ forceReinstall: true });
|
|
242
278
|
if (!exec)
|
|
243
279
|
return false;
|
|
280
|
+
// Never bootstrap a helper the kernel will kill on launch: an invalid
|
|
281
|
+
// signature under launchd KeepAlive is an infinite crash loop. If the bundle
|
|
282
|
+
// can't be made valid (re-sign already attempted in ensureMenubarAppInstalled),
|
|
283
|
+
// skip the service rather than spin the loop.
|
|
284
|
+
if (!codesignVerifies(installedAppPath())) {
|
|
285
|
+
process.stderr.write('agents: menu-bar helper has no valid code signature; skipping launch to avoid a crash loop.\n');
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
244
288
|
if (opts.clearOptOut) {
|
|
245
289
|
try {
|
|
246
290
|
fs.rmSync(disabledSentinelPath(), { force: true });
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -117,5 +117,21 @@ export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string):
|
|
|
117
117
|
* Params default to the real routines dir; injectable for tests.
|
|
118
118
|
*/
|
|
119
119
|
export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
|
|
120
|
+
/**
|
|
121
|
+
* Fold the legacy watchdog enable sentinel into the watchdog routine.
|
|
122
|
+
*
|
|
123
|
+
* The always-on watchdog used to be gated by a presence sentinel at
|
|
124
|
+
* `<runtime-state>/watchdog/enabled`; it is now a daemon routine. A user who had
|
|
125
|
+
* run `agents watchdog enable` under the old build has that file on disk — without
|
|
126
|
+
* this, upgrading would silently drop them back to OFF (the always-on nudge just
|
|
127
|
+
* stops), the worst failure mode for a "survives reboots" feature. If the sentinel
|
|
128
|
+
* exists, ensure the `watchdog` routine exists AND is enabled, then delete the
|
|
129
|
+
* sentinel so this runs exactly once. If ensuring the routine fails, the sentinel
|
|
130
|
+
* is left in place so a later run retries rather than silently losing the opt-in.
|
|
131
|
+
*
|
|
132
|
+
* Both seams are injectable so the migration is unit-testable without touching the
|
|
133
|
+
* real routines dir (and without racing the routine module's own tests).
|
|
134
|
+
*/
|
|
135
|
+
export declare function migrateWatchdogSentinelToRoutine(sentinelPath?: string, ensure?: (enabled: boolean) => void): void;
|
|
120
136
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
121
137
|
export declare function runMigration(): Promise<void>;
|
package/dist/lib/migrate.js
CHANGED
|
@@ -13,6 +13,7 @@ import { atomicWriteFileSync } from './fs-atomic.js';
|
|
|
13
13
|
import { machineId } from './machine-id.js';
|
|
14
14
|
import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
|
|
15
15
|
import { createLink } from './platform/index.js';
|
|
16
|
+
import { ensureWatchdogRoutine } from './watchdog/routine.js';
|
|
16
17
|
const HOME = process.env.HOME ?? os.homedir();
|
|
17
18
|
const USER_DIR = path.join(HOME, '.agents');
|
|
18
19
|
/** Canonical system-repo location (post-fold). */
|
|
@@ -1981,6 +1982,37 @@ export function migrateRoutineDeviceToDevices(routinesDir) {
|
|
|
1981
1982
|
console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: device → devices`);
|
|
1982
1983
|
}
|
|
1983
1984
|
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Fold the legacy watchdog enable sentinel into the watchdog routine.
|
|
1987
|
+
*
|
|
1988
|
+
* The always-on watchdog used to be gated by a presence sentinel at
|
|
1989
|
+
* `<runtime-state>/watchdog/enabled`; it is now a daemon routine. A user who had
|
|
1990
|
+
* run `agents watchdog enable` under the old build has that file on disk — without
|
|
1991
|
+
* this, upgrading would silently drop them back to OFF (the always-on nudge just
|
|
1992
|
+
* stops), the worst failure mode for a "survives reboots" feature. If the sentinel
|
|
1993
|
+
* exists, ensure the `watchdog` routine exists AND is enabled, then delete the
|
|
1994
|
+
* sentinel so this runs exactly once. If ensuring the routine fails, the sentinel
|
|
1995
|
+
* is left in place so a later run retries rather than silently losing the opt-in.
|
|
1996
|
+
*
|
|
1997
|
+
* Both seams are injectable so the migration is unit-testable without touching the
|
|
1998
|
+
* real routines dir (and without racing the routine module's own tests).
|
|
1999
|
+
*/
|
|
2000
|
+
export function migrateWatchdogSentinelToRoutine(sentinelPath = path.join(CACHE_DIR, 'state', 'watchdog', 'enabled'), ensure = ensureWatchdogRoutine) {
|
|
2001
|
+
if (!fs.existsSync(sentinelPath))
|
|
2002
|
+
return;
|
|
2003
|
+
try {
|
|
2004
|
+
ensure(true);
|
|
2005
|
+
}
|
|
2006
|
+
catch (err) {
|
|
2007
|
+
console.error(`watchdog sentinel migration: could not create the routine (${err.message}); leaving the sentinel for a later retry`);
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
try {
|
|
2011
|
+
fs.rmSync(sentinelPath);
|
|
2012
|
+
}
|
|
2013
|
+
catch { /* already gone */ }
|
|
2014
|
+
console.error('Migrated watchdog: legacy enable sentinel → watchdog routine (kept enabled)');
|
|
2015
|
+
}
|
|
1984
2016
|
/** Run all idempotent migrations. Safe to call multiple times. */
|
|
1985
2017
|
export async function runMigration() {
|
|
1986
2018
|
// MUST run first: every other migrator reads SYSTEM_DIR (the new path).
|
|
@@ -2039,6 +2071,10 @@ export async function runMigration() {
|
|
|
2039
2071
|
migrateExtrasExtrasToAgentsExtras();
|
|
2040
2072
|
// Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
|
|
2041
2073
|
migrateRoutineDeviceToDevices();
|
|
2074
|
+
// Fold the legacy watchdog enable sentinel into the watchdog routine so a user
|
|
2075
|
+
// who opted in under the old build stays opted in after upgrading. After the
|
|
2076
|
+
// routine rewrites above so the routines dir is in its canonical shape.
|
|
2077
|
+
migrateWatchdogSentinelToRoutine();
|
|
2042
2078
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
2043
2079
|
repairAgentConfigSymlinks();
|
|
2044
2080
|
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -96,6 +96,33 @@ export declare function resolveModel(agent: AgentId, version: string, requested:
|
|
|
96
96
|
* like `${agent}-default`.
|
|
97
97
|
*/
|
|
98
98
|
export declare function resolveEffectiveModel(agent: AgentId, version: string, requested?: string): string | null;
|
|
99
|
+
/** Where the model a given agent+version will actually run with came from. */
|
|
100
|
+
export type ConfiguredModelSource = 'run-default' | 'config' | 'cli-default';
|
|
101
|
+
export interface ConfiguredModel {
|
|
102
|
+
/** The model id the agent will run with (e.g. `opus`, `gpt-5-codex`). */
|
|
103
|
+
model: string;
|
|
104
|
+
source: ConfiguredModelSource;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The model a given agent+version is actually configured to use right now, with
|
|
108
|
+
* where that selection comes from. First hit wins:
|
|
109
|
+
* 1. run-default — the user's agents-cli `run.defaults` in agents.yaml
|
|
110
|
+
* 2. config — the agent's OWN native settings.json `model` field
|
|
111
|
+
* 3. cli-default — the CLI's built-in default: the catalog's `isDefault` model
|
|
112
|
+
* if one is flagged (e.g. Kimi), otherwise the literal
|
|
113
|
+
* `default` for a model-capable agent whose runtime picks its
|
|
114
|
+
* own default (Claude/Codex don't flag one — Claude's own UI
|
|
115
|
+
* calls this "Default").
|
|
116
|
+
* Each layer is a real source the agent consults; `version` must be concrete.
|
|
117
|
+
* Returns null only when the agent exposes no model catalog at all.
|
|
118
|
+
*/
|
|
119
|
+
export declare function resolveConfiguredModel(agent: AgentId, version: string): ConfiguredModel | null;
|
|
120
|
+
/**
|
|
121
|
+
* Join the identity cluster — `agent@version · model · account` — with a dim
|
|
122
|
+
* separator, dropping empty pieces. Pieces are pre-colored by the caller so the
|
|
123
|
+
* same cluster reads identically across `view`, `use`, `add`, and `status`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function formatAgentIdentity(...parts: Array<string | null | undefined>): string;
|
|
99
126
|
/**
|
|
100
127
|
* Build the per-agent CLI flags for a unified reasoning effort knob.
|
|
101
128
|
*
|
package/dist/lib/models.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import * as path from 'path';
|
|
12
12
|
import { execFileSync } from 'child_process';
|
|
13
|
-
import
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import { getVersionDir, getVersionHomePath } from './versions.js';
|
|
14
15
|
import { getModelsCachePath } from './state.js';
|
|
16
|
+
import { agentConfigDirName } from './agents.js';
|
|
17
|
+
import { resolveRunDefaults } from './run-defaults.js';
|
|
15
18
|
const CACHE_PATH = getModelsCachePath();
|
|
16
19
|
/**
|
|
17
20
|
* Bump when the extractor logic changes shape in an incompatible way so cached
|
|
@@ -864,6 +867,56 @@ export function resolveEffectiveModel(agent, version, requested) {
|
|
|
864
867
|
const def = catalog.models.find((m) => m.isDefault);
|
|
865
868
|
return def?.id ?? null;
|
|
866
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* The model a given agent+version is actually configured to use right now, with
|
|
872
|
+
* where that selection comes from. First hit wins:
|
|
873
|
+
* 1. run-default — the user's agents-cli `run.defaults` in agents.yaml
|
|
874
|
+
* 2. config — the agent's OWN native settings.json `model` field
|
|
875
|
+
* 3. cli-default — the CLI's built-in default: the catalog's `isDefault` model
|
|
876
|
+
* if one is flagged (e.g. Kimi), otherwise the literal
|
|
877
|
+
* `default` for a model-capable agent whose runtime picks its
|
|
878
|
+
* own default (Claude/Codex don't flag one — Claude's own UI
|
|
879
|
+
* calls this "Default").
|
|
880
|
+
* Each layer is a real source the agent consults; `version` must be concrete.
|
|
881
|
+
* Returns null only when the agent exposes no model catalog at all.
|
|
882
|
+
*/
|
|
883
|
+
export function resolveConfiguredModel(agent, version) {
|
|
884
|
+
const runModel = resolveRunDefaults(agent, version).model;
|
|
885
|
+
if (runModel && runModel.trim() !== '')
|
|
886
|
+
return { model: runModel, source: 'run-default' };
|
|
887
|
+
const nativeModel = readNativeConfigModel(agent, version);
|
|
888
|
+
if (nativeModel)
|
|
889
|
+
return { model: nativeModel, source: 'config' };
|
|
890
|
+
const catalog = getModelCatalog(agent, version);
|
|
891
|
+
if (catalog) {
|
|
892
|
+
const flagged = catalog.models.find((m) => m.isDefault);
|
|
893
|
+
return { model: flagged?.id ?? 'default', source: 'cli-default' };
|
|
894
|
+
}
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Best-effort read of the agent's own `model` from its native settings.json
|
|
899
|
+
* (e.g. `~/.agents/.history/versions/claude/<ver>/home/.claude/settings.json`).
|
|
900
|
+
* A missing/malformed file is a fall-through, not an error.
|
|
901
|
+
*/
|
|
902
|
+
function readNativeConfigModel(agent, version) {
|
|
903
|
+
try {
|
|
904
|
+
const settingsPath = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent), 'settings.json');
|
|
905
|
+
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
906
|
+
return typeof parsed.model === 'string' && parsed.model.trim() !== '' ? parsed.model : null;
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Join the identity cluster — `agent@version · model · account` — with a dim
|
|
914
|
+
* separator, dropping empty pieces. Pieces are pre-colored by the caller so the
|
|
915
|
+
* same cluster reads identically across `view`, `use`, `add`, and `status`.
|
|
916
|
+
*/
|
|
917
|
+
export function formatAgentIdentity(...parts) {
|
|
918
|
+
return parts.filter((p) => !!p && p.length > 0).join(` ${chalk.gray('·')} `);
|
|
919
|
+
}
|
|
867
920
|
/** Find the closest matching model ids/aliases using edit distance. */
|
|
868
921
|
function pickSuggestions(requested, catalog) {
|
|
869
922
|
const all = [...catalog.models.map((m) => m.id), ...Object.keys(catalog.aliases)];
|
package/dist/lib/overdue.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export interface OverdueJob {
|
|
|
21
21
|
/** Return every enabled, recurring job whose most recent expected fire was
|
|
22
22
|
* missed. One-shot jobs are excluded — they fire at most once. */
|
|
23
23
|
export declare function detectOverdueJobs(now?: Date): OverdueJob[];
|
|
24
|
-
/**
|
|
25
|
-
*
|
|
24
|
+
/**
|
|
25
|
+
* Fire a native desktop notification. Best-effort — failures (missing
|
|
26
|
+
* `osascript`/`notify-send`, no display, headless box) are swallowed so a
|
|
27
|
+
* notification attempt can never take the daemon down.
|
|
28
|
+
*/
|
|
29
|
+
export declare function notifyDesktop(title: string, body: string): void;
|
|
30
|
+
/** Fire a native desktop notification listing the overdue jobs. Best-effort. */
|
|
26
31
|
export declare function notifyOverdue(jobs: OverdueJob[]): void;
|
package/dist/lib/overdue.js
CHANGED
|
@@ -76,17 +76,12 @@ export function detectOverdueJobs(now = new Date()) {
|
|
|
76
76
|
}
|
|
77
77
|
return overdue;
|
|
78
78
|
}
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
? `Routine overdue: ${jobs[0].name}`
|
|
86
|
-
: `${jobs.length} routines overdue`;
|
|
87
|
-
const body = jobs.length === 1
|
|
88
|
-
? `Missed ${jobs[0].expectedAt.toLocaleString()}. Run: agents routines catchup`
|
|
89
|
-
: `${jobs.map((j) => j.name).join(', ')} — agents routines catchup`;
|
|
79
|
+
/**
|
|
80
|
+
* Fire a native desktop notification. Best-effort — failures (missing
|
|
81
|
+
* `osascript`/`notify-send`, no display, headless box) are swallowed so a
|
|
82
|
+
* notification attempt can never take the daemon down.
|
|
83
|
+
*/
|
|
84
|
+
export function notifyDesktop(title, body) {
|
|
90
85
|
const platform = os.platform();
|
|
91
86
|
try {
|
|
92
87
|
if (platform === 'darwin') {
|
|
@@ -117,3 +112,15 @@ export function notifyOverdue(jobs) {
|
|
|
117
112
|
// Notification is best-effort; nothing to do.
|
|
118
113
|
}
|
|
119
114
|
}
|
|
115
|
+
/** Fire a native desktop notification listing the overdue jobs. Best-effort. */
|
|
116
|
+
export function notifyOverdue(jobs) {
|
|
117
|
+
if (jobs.length === 0)
|
|
118
|
+
return;
|
|
119
|
+
const title = jobs.length === 1
|
|
120
|
+
? `Routine overdue: ${jobs[0].name}`
|
|
121
|
+
: `${jobs.length} routines overdue`;
|
|
122
|
+
const body = jobs.length === 1
|
|
123
|
+
? `Missed ${jobs[0].expectedAt.toLocaleString()}. Run: agents routines catchup`
|
|
124
|
+
: `${jobs.map((j) => j.name).join(', ')} — agents routines catchup`;
|
|
125
|
+
notifyDesktop(title, body);
|
|
126
|
+
}
|
package/dist/lib/picker.js
CHANGED
|
@@ -343,7 +343,10 @@ export function dynamicPicker(config) {
|
|
|
343
343
|
const [loading, setLoading] = useState(true);
|
|
344
344
|
const [query, setQuery] = useState('');
|
|
345
345
|
const [mode, setMode] = useState('nav');
|
|
346
|
-
|
|
346
|
+
// Default the preview pane open when the caller supplies a preview builder —
|
|
347
|
+
// matches the static `itemPicker`, so the session browser shows a live preview
|
|
348
|
+
// as you arrow through rows instead of hiding it behind `tab`.
|
|
349
|
+
const [previewOpen, setPreviewOpen] = useState(Boolean(cfg.buildPreview));
|
|
347
350
|
const [active, setActive] = useState(0);
|
|
348
351
|
const [flash, setFlash] = useState('');
|
|
349
352
|
const prefix = usePrefix({ status, theme });
|
|
@@ -49,3 +49,20 @@ export declare function backgroundSpawnOptions(opts?: {
|
|
|
49
49
|
* throw from `process.kill(pid, 0)` as "not running".
|
|
50
50
|
*/
|
|
51
51
|
export declare function isAlive(pid: number): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A stable identifier for the process at `pid` as of when it started, or null if
|
|
54
|
+
* unknowable. Used to defeat PID reuse: acting on a pid is only safe when the
|
|
55
|
+
* process still occupies the slot we observed earlier. The value is only ever
|
|
56
|
+
* compared for equality against an earlier capture of the SAME pid, so the format
|
|
57
|
+
* need only be stable, not parseable.
|
|
58
|
+
*
|
|
59
|
+
* Linux: field 22 of /proc/<pid>/stat (starttime in clock ticks since boot).
|
|
60
|
+
* macOS: `ps -o lstart= -p <pid>`.
|
|
61
|
+
* Windows: CreationDate from Win32_Process, as a culture-independent FILETIME.
|
|
62
|
+
*
|
|
63
|
+
* This is the single source of truth. `pty-server.ts` and `teams/agents.ts` each
|
|
64
|
+
* carried their own copy; the Windows branch was missing from both, so every
|
|
65
|
+
* caller there silently ran with NO pid-reuse protection — including
|
|
66
|
+
* `agents teams stop`, which is how it could SIGKILL an unrelated process group.
|
|
67
|
+
*/
|
|
68
|
+
export declare function captureProcessStartTime(pid: number): string | null;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Process liveness / control, platform-aware.
|
|
3
3
|
*/
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
5
6
|
/**
|
|
6
7
|
* Forcefully terminate a process AND its descendant tree.
|
|
7
8
|
*
|
|
@@ -80,3 +81,72 @@ export function isAlive(pid) {
|
|
|
80
81
|
return false;
|
|
81
82
|
}
|
|
82
83
|
}
|
|
84
|
+
/** Memoized per pid: a process's start time cannot change while it lives, and a
|
|
85
|
+
* recycled pid is precisely what the caller is trying to detect — so a stale
|
|
86
|
+
* hit still compares unequal against the recorded value. Bounded by the pids a
|
|
87
|
+
* single CLI invocation asks about. */
|
|
88
|
+
const startTimeByPid = new Map();
|
|
89
|
+
/**
|
|
90
|
+
* A stable identifier for the process at `pid` as of when it started, or null if
|
|
91
|
+
* unknowable. Used to defeat PID reuse: acting on a pid is only safe when the
|
|
92
|
+
* process still occupies the slot we observed earlier. The value is only ever
|
|
93
|
+
* compared for equality against an earlier capture of the SAME pid, so the format
|
|
94
|
+
* need only be stable, not parseable.
|
|
95
|
+
*
|
|
96
|
+
* Linux: field 22 of /proc/<pid>/stat (starttime in clock ticks since boot).
|
|
97
|
+
* macOS: `ps -o lstart= -p <pid>`.
|
|
98
|
+
* Windows: CreationDate from Win32_Process, as a culture-independent FILETIME.
|
|
99
|
+
*
|
|
100
|
+
* This is the single source of truth. `pty-server.ts` and `teams/agents.ts` each
|
|
101
|
+
* carried their own copy; the Windows branch was missing from both, so every
|
|
102
|
+
* caller there silently ran with NO pid-reuse protection — including
|
|
103
|
+
* `agents teams stop`, which is how it could SIGKILL an unrelated process group.
|
|
104
|
+
*/
|
|
105
|
+
export function captureProcessStartTime(pid) {
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
107
|
+
return null;
|
|
108
|
+
const cached = startTimeByPid.get(pid);
|
|
109
|
+
if (cached !== undefined)
|
|
110
|
+
return cached;
|
|
111
|
+
const value = readProcessStartTime(pid);
|
|
112
|
+
startTimeByPid.set(pid, value);
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function readProcessStartTime(pid) {
|
|
116
|
+
try {
|
|
117
|
+
if (process.platform === 'win32') {
|
|
118
|
+
// ToFileTimeUtc() rather than the raw DateTime: the default string form is
|
|
119
|
+
// rendered in the current culture, so a persisted fingerprint would stop
|
|
120
|
+
// comparing equal across a locale change.
|
|
121
|
+
const out = execFileSync('powershell.exe', [
|
|
122
|
+
'-NoProfile',
|
|
123
|
+
'-NonInteractive',
|
|
124
|
+
'-Command',
|
|
125
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate.ToFileTimeUtc()`,
|
|
126
|
+
], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, timeout: 5000 });
|
|
127
|
+
const trimmed = out.trim();
|
|
128
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
129
|
+
}
|
|
130
|
+
if (process.platform === 'linux') {
|
|
131
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
132
|
+
// The comm field (#2) is parenthesized and may contain spaces, so split
|
|
133
|
+
// off everything after the last ')' to get a clean field list.
|
|
134
|
+
const lastParen = stat.lastIndexOf(')');
|
|
135
|
+
if (lastParen < 0)
|
|
136
|
+
return null;
|
|
137
|
+
const fields = stat.slice(lastParen + 2).split(' ');
|
|
138
|
+
// After comm we are at field 3; starttime is field 22, so index 19 here.
|
|
139
|
+
return fields[19] || null;
|
|
140
|
+
}
|
|
141
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], {
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
144
|
+
timeout: 5000,
|
|
145
|
+
});
|
|
146
|
+
const trimmed = out.trim();
|
|
147
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|