@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/daemon.js
CHANGED
|
@@ -538,14 +538,42 @@ export function buildDetachedDaemonEnv(baseEnv = process.env) {
|
|
|
538
538
|
}
|
|
539
539
|
return env;
|
|
540
540
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
|
|
543
|
+
* exact form that works under a direct `daemon _run`.
|
|
544
|
+
*
|
|
545
|
+
* We spawn the Node runtime (`process.execPath`) with the CLI entry as an
|
|
546
|
+
* argument rather than executing the entry path directly. Executing the `.js`
|
|
547
|
+
* path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
|
|
548
|
+
* `.js`/shim directly at all — it gets launched through a transient
|
|
549
|
+
* console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
|
|
550
|
+
* closes its console, and the detached daemon sharing that console receives a
|
|
551
|
+
* console-close event that trips its shutdown handler — the daemon comes up,
|
|
552
|
+
* binds the browser IPC socket, then tears itself down ~36ms later (#556).
|
|
553
|
+
* Going through `process.execPath` means a real PE/binary is spawned with
|
|
554
|
+
* `detached: true` and no console, so nothing signals the daemon after launch.
|
|
555
|
+
*
|
|
556
|
+
* When the entry isn't a JS file (e.g. a native launcher resolved via
|
|
557
|
+
* `which agents`), run it directly — it owns its own runtime resolution.
|
|
558
|
+
*/
|
|
559
|
+
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
560
|
+
if (/\.(c|m)?js$/.test(agentsBin)) {
|
|
561
|
+
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
562
|
+
}
|
|
563
|
+
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
564
|
+
}
|
|
565
|
+
export function startDetached(opts = {}) {
|
|
566
|
+
const agentsBin = opts.agentsBin ?? getAgentsBinPath();
|
|
567
|
+
const logPath = opts.logPath ?? getLogPath();
|
|
544
568
|
const logFd = fs.openSync(logPath, 'a');
|
|
545
|
-
const
|
|
569
|
+
const { command, args } = getDaemonLaunch(agentsBin);
|
|
570
|
+
const child = spawn(command, args, {
|
|
546
571
|
stdio: ['ignore', logFd, logFd],
|
|
547
572
|
detached: true,
|
|
548
|
-
|
|
573
|
+
// No console window on Windows — a hidden, consoleless process can't be
|
|
574
|
+
// torn down by a console-close event when its launcher exits (#556).
|
|
575
|
+
windowsHide: true,
|
|
576
|
+
env: opts.env ?? buildDetachedDaemonEnv(),
|
|
549
577
|
});
|
|
550
578
|
child.unref();
|
|
551
579
|
fs.closeSync(logFd);
|
|
@@ -11,8 +11,9 @@ export declare function sshTargetFor(device: DeviceProfile): string;
|
|
|
11
11
|
/**
|
|
12
12
|
* Wrap a remote command for the device's shell. Windows devices speak
|
|
13
13
|
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
14
|
-
* -
|
|
15
|
-
* parses it). Returns undefined when no command was given (interactive
|
|
14
|
+
* -EncodedCommand`; POSIX devices get the command verbatim (the remote login
|
|
15
|
+
* shell parses it). Returns undefined when no command was given (interactive
|
|
16
|
+
* login).
|
|
16
17
|
*/
|
|
17
18
|
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
|
|
18
19
|
/**
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { encodePwshBase64 } from '../pwsh.js';
|
|
19
20
|
import { getCacheDir } from '../state.js';
|
|
20
21
|
import { hostNameFor } from './ssh-config.js';
|
|
21
22
|
/** Env var the askpass shim reads to know which bundle holds the password. */
|
|
@@ -38,15 +39,16 @@ export function sshTargetFor(device) {
|
|
|
38
39
|
/**
|
|
39
40
|
* Wrap a remote command for the device's shell. Windows devices speak
|
|
40
41
|
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
41
|
-
* -
|
|
42
|
-
* parses it). Returns undefined when no command was given (interactive
|
|
42
|
+
* -EncodedCommand`; POSIX devices get the command verbatim (the remote login
|
|
43
|
+
* shell parses it). Returns undefined when no command was given (interactive
|
|
44
|
+
* login).
|
|
43
45
|
*/
|
|
44
46
|
export function wrapRemoteCommand(device, cmd) {
|
|
45
47
|
if (cmd.length === 0)
|
|
46
48
|
return undefined;
|
|
47
49
|
const joined = cmd.join(' ');
|
|
48
50
|
if (device.shell === 'powershell') {
|
|
49
|
-
return `powershell -NoProfile -
|
|
51
|
+
return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(joined)}`;
|
|
50
52
|
}
|
|
51
53
|
return joined;
|
|
52
54
|
}
|
package/dist/lib/events.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized event logging for agents-cli.
|
|
3
3
|
*
|
|
4
|
-
* Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
|
|
4
|
+
* Structured JSONL logs at ~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl
|
|
5
5
|
* with automatic daily rotation and rich metadata for debugging/auditing.
|
|
6
6
|
*
|
|
7
7
|
* Features:
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - Permissions: logs dir is 0700, files are 0600 (owner-only)
|
|
12
12
|
* - Performance tracking: withTiming() wrapper for any async function
|
|
13
13
|
*/
|
|
14
|
-
export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'resource.sync' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'error' | 'warn' | 'info' | 'debug';
|
|
14
|
+
export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'resource.sync' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'error' | 'warn' | 'info' | 'debug';
|
|
15
15
|
export interface EventMeta {
|
|
16
16
|
ts: string;
|
|
17
17
|
tz: string;
|
|
@@ -22,12 +22,18 @@ export interface EventMeta {
|
|
|
22
22
|
pid: number;
|
|
23
23
|
ppid: number;
|
|
24
24
|
event: EventType;
|
|
25
|
+
osUser: string;
|
|
26
|
+
transport: 'local' | 'ssh';
|
|
27
|
+
sshClientIp?: string;
|
|
25
28
|
}
|
|
26
29
|
export interface EventPayload {
|
|
27
30
|
agent?: string;
|
|
28
31
|
version?: string;
|
|
29
32
|
sessionId?: string;
|
|
30
33
|
cwd?: string;
|
|
34
|
+
/** Top-level command group, e.g. 'teams', 'secrets' — the audit filter key. */
|
|
35
|
+
module?: string;
|
|
36
|
+
/** Full command path, e.g. 'teams create', 'secrets get'. */
|
|
31
37
|
command?: string;
|
|
32
38
|
args?: string[];
|
|
33
39
|
input?: string;
|
|
@@ -155,6 +161,7 @@ export declare function query(options: {
|
|
|
155
161
|
eventTypes?: EventType[];
|
|
156
162
|
agent?: string;
|
|
157
163
|
command?: string;
|
|
164
|
+
module?: string;
|
|
158
165
|
limit?: number;
|
|
159
166
|
}): EventRecord[];
|
|
160
167
|
/**
|
package/dist/lib/events.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized event logging for agents-cli.
|
|
3
3
|
*
|
|
4
|
-
* Structured JSONL logs at ~/.agents/logs/events-YYYY-MM-DD.jsonl
|
|
4
|
+
* Structured JSONL logs at ~/.agents/.cache/logs/events-YYYY-MM-DD.jsonl
|
|
5
5
|
* with automatic daily rotation and rich metadata for debugging/auditing.
|
|
6
6
|
*
|
|
7
7
|
* Features:
|
|
@@ -15,6 +15,7 @@ import * as fs from 'fs';
|
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import * as os from 'os';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
|
+
import { parseSshConnection } from './session/provenance.js';
|
|
18
19
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
19
20
|
// Logs live under the cache bucket — they're regenerable telemetry.
|
|
20
21
|
const LOGS_DIR = path.join(os.homedir(), '.agents', '.cache', 'logs');
|
|
@@ -130,6 +131,30 @@ function truncatePayload(payload, maxLength = DEFAULT_TRUNCATE_LENGTH) {
|
|
|
130
131
|
}
|
|
131
132
|
return result;
|
|
132
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Who is running this process and from where. Derived once per process from the
|
|
136
|
+
* OS user and $SSH_CONNECTION (via the same parser the sessions layer uses), then
|
|
137
|
+
* cached — provenance can't change mid-process, so every emit() pays for it once.
|
|
138
|
+
*/
|
|
139
|
+
let _origin;
|
|
140
|
+
function auditOrigin() {
|
|
141
|
+
if (_origin)
|
|
142
|
+
return _origin;
|
|
143
|
+
let osUser = 'unknown';
|
|
144
|
+
try {
|
|
145
|
+
osUser = os.userInfo().username;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Container/edge cases where the uid has no passwd entry.
|
|
149
|
+
}
|
|
150
|
+
const ssh = process.env.SSH_CONNECTION ? parseSshConnection(process.env.SSH_CONNECTION) : undefined;
|
|
151
|
+
_origin = {
|
|
152
|
+
osUser,
|
|
153
|
+
transport: ssh ? 'ssh' : 'local',
|
|
154
|
+
...(ssh ? { sshClientIp: ssh.clientIp } : {}),
|
|
155
|
+
};
|
|
156
|
+
return _origin;
|
|
157
|
+
}
|
|
133
158
|
// ─── Core API ─────────────────────────────────────────────────────────────────
|
|
134
159
|
/**
|
|
135
160
|
* Emit a structured event to the daily log file.
|
|
@@ -152,23 +177,33 @@ export function emit(event, payload = {}) {
|
|
|
152
177
|
pid: process.pid,
|
|
153
178
|
ppid: process.ppid,
|
|
154
179
|
event,
|
|
180
|
+
...auditOrigin(),
|
|
155
181
|
...truncatePayload(payload),
|
|
156
182
|
};
|
|
157
183
|
const line = JSON.stringify(record) + '\n';
|
|
158
184
|
const logPath = getLogFilePath();
|
|
185
|
+
const isNew = !fs.existsSync(logPath);
|
|
159
186
|
fs.appendFileSync(logPath, line, { mode: FILE_MODE });
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
187
|
+
// appendFileSync's mode only applies when it CREATES the file, so we chmod
|
|
188
|
+
// to guarantee 0600 — but only on first write to a given path this process.
|
|
189
|
+
// command.start/end fire on every invocation; chmod-per-append would double
|
|
190
|
+
// the syscalls on the hot path for no gain (perms don't drift mid-run).
|
|
191
|
+
if (isNew || logPath !== _chmoddedPath) {
|
|
192
|
+
_chmoddedPath = logPath;
|
|
193
|
+
try {
|
|
194
|
+
fs.chmodSync(logPath, FILE_MODE);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// May fail if not owner
|
|
198
|
+
}
|
|
166
199
|
}
|
|
167
200
|
}
|
|
168
201
|
catch {
|
|
169
202
|
// Silent failure - logging should never break the CLI
|
|
170
203
|
}
|
|
171
204
|
}
|
|
205
|
+
/** Last log path this process chmod'd — avoids a redundant chmod per append. */
|
|
206
|
+
let _chmoddedPath;
|
|
172
207
|
/**
|
|
173
208
|
* Convenience wrapper for timed operations.
|
|
174
209
|
* Returns a function to call when the operation completes.
|
|
@@ -402,7 +437,7 @@ export function maybeRotate() {
|
|
|
402
437
|
* @returns Array of event records
|
|
403
438
|
*/
|
|
404
439
|
export function query(options) {
|
|
405
|
-
const { startDate, endDate = new Date(), eventTypes, agent, command, limit } = options;
|
|
440
|
+
const { startDate, endDate = new Date(), eventTypes, agent, command, module, limit } = options;
|
|
406
441
|
const results = [];
|
|
407
442
|
if (!fs.existsSync(LOGS_DIR))
|
|
408
443
|
return results;
|
|
@@ -410,26 +445,50 @@ export function query(options) {
|
|
|
410
445
|
.filter(f => f.startsWith('events-') && f.endsWith('.jsonl'))
|
|
411
446
|
.sort()
|
|
412
447
|
.reverse();
|
|
448
|
+
// Coarse file skip works on whole days, so floor the bounds to midnight —
|
|
449
|
+
// otherwise a sub-day window (`--since 2h` at 15:00 → startDate 13:00) would
|
|
450
|
+
// drop *today's* file, whose date stamps to 00:00. Precise filtering below is
|
|
451
|
+
// per-record on `ts`.
|
|
452
|
+
const startDay = startDate
|
|
453
|
+
? new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
|
|
454
|
+
: undefined;
|
|
455
|
+
const endDay = endDate
|
|
456
|
+
? new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate())
|
|
457
|
+
: undefined;
|
|
458
|
+
const startMs = startDate?.getTime();
|
|
459
|
+
const endMs = endDate?.getTime();
|
|
413
460
|
for (const file of files) {
|
|
414
461
|
const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
|
|
415
462
|
if (!match)
|
|
416
463
|
continue;
|
|
417
464
|
const [, yyyy, mm, dd] = match;
|
|
418
465
|
const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
|
|
419
|
-
if (
|
|
466
|
+
if (startDay && fileDate < startDay)
|
|
420
467
|
continue;
|
|
421
|
-
if (
|
|
468
|
+
if (endDay && fileDate > endDay)
|
|
422
469
|
continue;
|
|
423
470
|
const content = fs.readFileSync(path.join(LOGS_DIR, file), 'utf-8');
|
|
424
471
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
425
472
|
for (const line of lines.reverse()) {
|
|
426
473
|
try {
|
|
427
474
|
const record = JSON.parse(line);
|
|
475
|
+
// Precise per-record window — the file skip above is day-granular only.
|
|
476
|
+
const recMs = Date.parse(record.ts);
|
|
477
|
+
if (startMs !== undefined && !isNaN(recMs) && recMs < startMs)
|
|
478
|
+
continue;
|
|
479
|
+
if (endMs !== undefined && !isNaN(recMs) && recMs > endMs)
|
|
480
|
+
continue;
|
|
428
481
|
if (eventTypes && !eventTypes.includes(record.event))
|
|
429
482
|
continue;
|
|
430
483
|
if (agent && record.agent !== agent)
|
|
431
484
|
continue;
|
|
432
|
-
|
|
485
|
+
// `--command` matches by path prefix on a word boundary, so a coarse
|
|
486
|
+
// "teams" catches "teams create"/"teams remove" while an exact
|
|
487
|
+
// "teams create" stays exact.
|
|
488
|
+
if (command && record.command !== command &&
|
|
489
|
+
!(typeof record.command === 'string' && record.command.startsWith(command + ' ')))
|
|
490
|
+
continue;
|
|
491
|
+
if (module && record.module !== module)
|
|
433
492
|
continue;
|
|
434
493
|
results.push(record);
|
|
435
494
|
if (limit && results.length >= limit) {
|
package/dist/lib/exec.js
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveModel, buildReasoningFlags } from './models.js';
|
|
|
16
16
|
import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
|
|
17
17
|
import { sanitizeProcessEnv } from './secrets/bundles.js';
|
|
18
18
|
import { getShimsDir } from './state.js';
|
|
19
|
+
import { writePidSessionEntry } from './session/pid-registry.js';
|
|
19
20
|
/**
|
|
20
21
|
* Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
|
|
21
22
|
*
|
|
@@ -722,6 +723,15 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
|
|
|
722
723
|
* unbuffered.
|
|
723
724
|
*/
|
|
724
725
|
async function spawnAgent(options) {
|
|
726
|
+
// Assign a known session id up front for agents that accept one, so the
|
|
727
|
+
// launcher can record an EXACT pid -> session mapping (see pid-registry) —
|
|
728
|
+
// otherwise the headless `ag sessions --active` path can only guess
|
|
729
|
+
// "newest .jsonl in the cwd" and collapses co-located agents onto one row.
|
|
730
|
+
// Claude: `--session-id <uuid>` CREATES the session with that id (wired in
|
|
731
|
+
// buildExecCommand). Skip on resume — the id is the one being resumed.
|
|
732
|
+
if (options.agent === 'claude' && !options.resume && !options.sessionId) {
|
|
733
|
+
options = { ...options, sessionId: randomUUID() };
|
|
734
|
+
}
|
|
725
735
|
const cmd = buildExecCommand(options);
|
|
726
736
|
const [executable, ...args] = cmd;
|
|
727
737
|
const timeoutMs = options.timeout ? parseTimeout(options.timeout) : undefined;
|
|
@@ -770,6 +780,18 @@ async function spawnAgent(options) {
|
|
|
770
780
|
env: buildExecEnv(options),
|
|
771
781
|
shell: useShell,
|
|
772
782
|
});
|
|
783
|
+
// Record this launch so `ag sessions --active` can map the pid to its exact
|
|
784
|
+
// session (sessionId is set for Claude above) instead of guessing the newest
|
|
785
|
+
// .jsonl in the cwd — the collapse that made co-located agents indistinguishable.
|
|
786
|
+
// Best-effort: pruned when the pid dies; a failed write just degrades to the heuristic.
|
|
787
|
+
writePidSessionEntry({
|
|
788
|
+
pid: child.pid ?? 0,
|
|
789
|
+
agent: options.agent,
|
|
790
|
+
sessionId: options.sessionId,
|
|
791
|
+
cwd: options.cwd || process.cwd(),
|
|
792
|
+
tmuxPane: process.env.TMUX_PANE,
|
|
793
|
+
startedAtMs: Date.now(),
|
|
794
|
+
});
|
|
773
795
|
// Mark startup time (time from function call to process spawn)
|
|
774
796
|
timer.mark('startup');
|
|
775
797
|
let budgetKilled = false;
|
package/dist/lib/hooks.js
CHANGED
|
@@ -910,6 +910,12 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
|
|
|
910
910
|
if (agentId === 'claude') {
|
|
911
911
|
return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes);
|
|
912
912
|
}
|
|
913
|
+
if (agentId === 'droid') {
|
|
914
|
+
// Droid's settings.json hooks schema is identical to Claude's (top-level
|
|
915
|
+
// `hooks` object → event → matcher-group array), so reuse the Claude
|
|
916
|
+
// registrar targeting `.factory/settings.json` (agentConfigDirName('droid')).
|
|
917
|
+
return registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, agentConfigDirName('droid'));
|
|
918
|
+
}
|
|
913
919
|
if (agentId === 'codex') {
|
|
914
920
|
return registerHooksForCodex(versionHome, manifest, resolveScript, managedPrefixes);
|
|
915
921
|
}
|
|
@@ -953,10 +959,10 @@ const ANTIGRAVITY_EVENT_MAP = {
|
|
|
953
959
|
const GEMINI_EVENT_MAP = {
|
|
954
960
|
UserPromptSubmit: 'BeforeAgent',
|
|
955
961
|
};
|
|
956
|
-
function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes) {
|
|
962
|
+
function registerHooksForClaude(versionHome, manifest, resolveScript, managedPrefixes, configDirName = '.claude') {
|
|
957
963
|
const registered = [];
|
|
958
964
|
const errors = [];
|
|
959
|
-
const configDir = path.join(versionHome,
|
|
965
|
+
const configDir = path.join(versionHome, configDirName);
|
|
960
966
|
const settingsPath = path.join(configDir, 'settings.json');
|
|
961
967
|
let config = {};
|
|
962
968
|
if (fs.existsSync(settingsPath)) {
|
|
@@ -12,7 +12,7 @@ import { randomUUID } from 'crypto';
|
|
|
12
12
|
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
13
13
|
import { sshTargetFor } from './types.js';
|
|
14
14
|
import { ensureHostReady } from './ready.js';
|
|
15
|
-
import { saveTask, updateTask } from './tasks.js';
|
|
15
|
+
import { saveTask, updateTask, terminalPatch } from './tasks.js';
|
|
16
16
|
import { followHostTask } from './progress.js';
|
|
17
17
|
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
18
18
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
@@ -61,12 +61,11 @@ async function launchDetached(host, target, opts) {
|
|
|
61
61
|
echo: true,
|
|
62
62
|
timeoutMs: opts.timeoutMs,
|
|
63
63
|
});
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
return { task: finished ?? task, exitCode };
|
|
64
|
+
// -1 = the follow window closed while the run continues on the host. Leave the
|
|
65
|
+
// record 'running' (do NOT freeze it terminal) so a later `hosts ps`/`logs`
|
|
66
|
+
// reconcile against the remote `.exit` resolves the true final status.
|
|
67
|
+
const finished = exitCode === -1 ? task : (updateTask(id, terminalPatch(exitCode)) ?? task);
|
|
68
|
+
return { task: finished, exitCode };
|
|
70
69
|
}
|
|
71
70
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
72
71
|
export async function dispatchToHost(host, opts) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
|
+
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
|
+
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`);
|
|
6
|
+
* otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
|
|
7
|
+
* place so the two commands can never drift.
|
|
8
|
+
*/
|
|
9
|
+
export interface HostLogResult {
|
|
10
|
+
/** False when no host task with this id exists (caller may fall through to sessions). */
|
|
11
|
+
found: boolean;
|
|
12
|
+
/** Process exit code to adopt when the task was shown/followed. */
|
|
13
|
+
exitCode?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Show (or follow, when running) a dispatched host task's combined-stdout log. */
|
|
16
|
+
export declare function showHostTaskLog(id: string, follow: boolean): Promise<HostLogResult>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared host-task log viewer — the show-or-follow core behind both
|
|
3
|
+
* `agents hosts logs <id>` and the top-level `agents logs <id>`.
|
|
4
|
+
*
|
|
5
|
+
* A running task with follow re-enters the offset-tail (`followHostTask`);
|
|
6
|
+
* otherwise the captured local mirror (`localLogPath`) is printed. Kept in one
|
|
7
|
+
* place so the two commands can never drift.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import { loadTask, localLogPath, updateTask, terminalPatch } from './tasks.js';
|
|
12
|
+
import { followHostTask } from './progress.js';
|
|
13
|
+
import { reconcileTask } from './reconcile.js';
|
|
14
|
+
/** Show (or follow, when running) a dispatched host task's combined-stdout log. */
|
|
15
|
+
export async function showHostTaskLog(id, follow) {
|
|
16
|
+
const task = loadTask(id);
|
|
17
|
+
if (!task)
|
|
18
|
+
return { found: false };
|
|
19
|
+
if (follow && task.status === 'running') {
|
|
20
|
+
const code = await followHostTask(task.target, {
|
|
21
|
+
remoteLog: task.remoteLog,
|
|
22
|
+
remoteExit: task.remoteExit,
|
|
23
|
+
taskId: id,
|
|
24
|
+
echo: true,
|
|
25
|
+
});
|
|
26
|
+
// -1 = follow window closed; the run continues on the host (not a failure,
|
|
27
|
+
// so exit 0). Any real code is the finished run — persist the terminal
|
|
28
|
+
// status the killed dispatch follower would have written.
|
|
29
|
+
if (code === -1)
|
|
30
|
+
return { found: true, exitCode: 0 };
|
|
31
|
+
updateTask(id, terminalPatch(code));
|
|
32
|
+
return { found: true, exitCode: code };
|
|
33
|
+
}
|
|
34
|
+
// Non-follow view: heal a still-'running' record from the remote `.exit` so a
|
|
35
|
+
// plain `logs <id>` also unsticks a task whose follower was killed. No-op (no
|
|
36
|
+
// ssh) once the record is already terminal.
|
|
37
|
+
reconcileTask(task);
|
|
38
|
+
try {
|
|
39
|
+
process.stdout.write(fs.readFileSync(localLogPath(id), 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
console.log(chalk.gray('(no local log captured for this task)'));
|
|
43
|
+
}
|
|
44
|
+
return { found: true, exitCode: 0 };
|
|
45
|
+
}
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* a sibling `.exit` file. We poll `tail -c +<offset>` (durable, offset-tracked —
|
|
6
6
|
* a dropped connection resumes from the saved offset) and finish when `.exit`
|
|
7
7
|
* appears. Rich transcript-parser rendering is a fast-follow.
|
|
8
|
+
*
|
|
9
|
+
* Efficiency: each cycle is a SINGLE ssh round-trip that returns the new log
|
|
10
|
+
* bytes, a per-task sentinel, then the exit-file contents — half the process +
|
|
11
|
+
* handshake cost of the old tail-then-cat pair. It rides the default control
|
|
12
|
+
* socket (multiplex on) that the launch opened, and eases the poll interval off
|
|
13
|
+
* toward `maxPollMs` while the job is idle, so a quiet long-running follow no
|
|
14
|
+
* longer spawns thousands of ssh processes per hour on the laptop.
|
|
8
15
|
*/
|
|
9
16
|
export interface FollowOptions {
|
|
10
17
|
remoteLog: string;
|
|
@@ -15,7 +22,66 @@ export interface FollowOptions {
|
|
|
15
22
|
echo?: boolean;
|
|
16
23
|
/** Overall wall-clock cap; returns -1 on timeout. */
|
|
17
24
|
timeoutMs?: number;
|
|
25
|
+
/** Fast poll interval while output is flowing (default 1500ms). */
|
|
18
26
|
pollMs?: number;
|
|
27
|
+
/** Idle-backoff ceiling (default 4× the fast interval, min 4000ms). */
|
|
28
|
+
maxPollMs?: number;
|
|
19
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the per-task sentinel that separates the log tail from the exit-file
|
|
32
|
+
* contents in one combined fetch. The task id (8 hex chars) makes collision with
|
|
33
|
+
* the agent's own output effectively impossible; callers still split on the LAST
|
|
34
|
+
* occurrence so a token echoed into the log can never be mistaken for the real
|
|
35
|
+
* trailing marker.
|
|
36
|
+
*/
|
|
37
|
+
export declare function exitMarker(taskId: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Split a combined fetch (`<log bytes><marker><exit>`) back into its parts at the
|
|
40
|
+
* BYTE level. Splits on the LAST marker occurrence, so even if the agent's own
|
|
41
|
+
* output happened to echo the token, the real trailing sentinel still wins.
|
|
42
|
+
* Returns null when the marker is absent (a transient fetch miss — the remote
|
|
43
|
+
* shell never ran our printf), telling the caller to retry without advancing.
|
|
44
|
+
*
|
|
45
|
+
* Byte-level (not string) is load-bearing: `logChunk.length` is the EXACT number
|
|
46
|
+
* of log bytes consumed on the wire, which the follow loop adds to its offset. A
|
|
47
|
+
* string split would first UTF-8-decode, turning any multibyte char split at the
|
|
48
|
+
* `tail -c` boundary into a U+FFFD whose re-encoded length ≠ the wire bytes,
|
|
49
|
+
* drifting the offset (see followHostTask). `consumed` is returned explicitly for
|
|
50
|
+
* clarity; it always equals `logChunk.length`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function splitProgressBytes(buf: Buffer, taskId: string): {
|
|
53
|
+
logChunk: Buffer;
|
|
54
|
+
exit: Buffer;
|
|
55
|
+
consumed: number;
|
|
56
|
+
} | null;
|
|
57
|
+
/**
|
|
58
|
+
* One round-trip: new log bytes since `offset`, the sentinel, then the exit
|
|
59
|
+
* file. Returns null on a transient fetch miss (ssh error / marker absent) so
|
|
60
|
+
* the caller simply retries next cycle without advancing the offset.
|
|
61
|
+
*
|
|
62
|
+
* `remoteLog`/`remoteExit` are $HOME-prefixed paths with safe (hex) basenames —
|
|
63
|
+
* intentionally unquoted so the remote shell expands $HOME.
|
|
64
|
+
*/
|
|
65
|
+
export declare function fetchProgress(target: string, opts: {
|
|
66
|
+
remoteLog: string;
|
|
67
|
+
remoteExit: string;
|
|
68
|
+
taskId: string;
|
|
69
|
+
offset: number;
|
|
70
|
+
}): {
|
|
71
|
+
logChunk: Buffer;
|
|
72
|
+
exit: string;
|
|
73
|
+
} | null;
|
|
74
|
+
/**
|
|
75
|
+
* File identity (`dev:ino`) of a path on the remote host, or null if it can't be
|
|
76
|
+
* stat'd. GNU (`-c`) then BSD (`-f`) format, so it works on Linux and macOS hosts.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readRemoteFileId(target: string, remotePath: string): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* True when the local mirror file IS the very file we're tailing — the
|
|
81
|
+
* localhost-as-host case, where `remoteLog` ($HOME-expanded) and `localLogPath`
|
|
82
|
+
* resolve to the same inode. Appending our read bytes back into it would feed the
|
|
83
|
+
* tail and multiply the log, so the caller must skip the mirror write.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mirrorAliasesSource(localId: string | null, remoteId: string | null): boolean;
|
|
20
86
|
/** Tail the remote log to stdout until the run finishes; return its exit code. */
|
|
21
87
|
export declare function followHostTask(target: string, opts: FollowOptions): Promise<number>;
|