@phnx-labs/agents-cli 1.20.41 → 1.20.43
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 +14 -0
- package/dist/commands/computer-actions.js +1 -1
- package/dist/commands/computer.d.ts +2 -2
- package/dist/commands/computer.js +4 -4
- package/dist/commands/exec.js +18 -6
- package/dist/commands/go.js +6 -1
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/secrets.js +26 -0
- package/dist/commands/sessions.js +30 -12
- package/dist/lib/browser/chrome.d.ts +22 -0
- package/dist/lib/browser/chrome.js +53 -13
- package/dist/lib/browser/service.js +13 -0
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +59 -0
- package/dist/lib/exec.js +163 -0
- package/dist/lib/hosts/dispatch.d.ts +5 -0
- package/dist/lib/hosts/dispatch.js +4 -0
- package/dist/lib/hosts/session-index.js +1 -0
- package/dist/lib/hosts/tasks.d.ts +15 -0
- package/dist/lib/hosts/tasks.js +16 -0
- package/dist/lib/menubar/install-menubar.js +2 -2
- package/dist/lib/rotate.d.ts +11 -6
- package/dist/lib/rotate.js +25 -11
- package/dist/lib/secrets/bundles.js +5 -3
- package/dist/lib/secrets/remote.js +14 -0
- package/dist/lib/secrets/sync.js +13 -0
- package/dist/lib/session/active.d.ts +49 -3
- package/dist/lib/session/active.js +139 -10
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +129 -50
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +14 -3
- package/dist/lib/session/remote.d.ts +4 -6
- package/dist/lib/session/remote.js +5 -12
- package/dist/lib/session/run-names.d.ts +32 -0
- package/dist/lib/session/run-names.js +63 -0
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/session/viewing-in.d.ts +54 -0
- package/dist/lib/session/viewing-in.js +155 -0
- package/dist/lib/ssh-tunnel.d.ts +1 -1
- package/dist/lib/ssh-tunnel.js +3 -3
- package/dist/lib/tmux/session.d.ts +46 -0
- package/dist/lib/tmux/session.js +84 -2
- package/dist/lib/usage.d.ts +5 -3
- package/dist/lib/usage.js +5 -3
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-name index: the join between a `agents run --name <slug>` handle and the
|
|
3
|
+
* session id of the run it named.
|
|
4
|
+
*
|
|
5
|
+
* `agents run` records `<sessionId>.json` here at launch whenever both a name
|
|
6
|
+
* and a session id are known up front (Claude pre-mints its id — see
|
|
7
|
+
* spawnAgent). The session-discovery pass reads these sidecars and applies the
|
|
8
|
+
* names onto the SQLite index by id (via syncNames), the same idempotent,
|
|
9
|
+
* re-applied-every-scan pattern as Claude `/rename` labels. Names therefore
|
|
10
|
+
* survive transcript rescans without being parsed out of the transcript itself.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the host-task sidecar convention (`~/.agents/.cache/hosts/<id>.json`),
|
|
13
|
+
* one small JSON per run under `~/.agents/.cache/run-names/`.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
17
|
+
import { getCacheDir } from '../state.js';
|
|
18
|
+
export function runNamesDir() {
|
|
19
|
+
return path.join(getCacheDir(), 'run-names');
|
|
20
|
+
}
|
|
21
|
+
function recordFile(sessionId) {
|
|
22
|
+
return path.join(runNamesDir(), `${sessionId}.json`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Record a run's `--name` handle keyed by its session id. Best-effort: a failed
|
|
26
|
+
* write must never break the run itself. No-op without both a name and id.
|
|
27
|
+
*/
|
|
28
|
+
export function recordRunName(rec) {
|
|
29
|
+
if (!rec.sessionId || !rec.name)
|
|
30
|
+
return;
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(runNamesDir(), { recursive: true });
|
|
33
|
+
fs.writeFileSync(recordFile(rec.sessionId), JSON.stringify({ ...rec, ts: Date.now() }, null, 2));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* the run is already launching; the name is a convenience, not load-bearing */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build the sessionId → name map from every run-name sidecar, for syncNames to
|
|
41
|
+
* apply onto the index. Returns an empty map when the dir doesn't exist yet.
|
|
42
|
+
*/
|
|
43
|
+
export function buildRunNameMap() {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
let files;
|
|
46
|
+
try {
|
|
47
|
+
files = fs.readdirSync(runNamesDir()).filter((f) => f.endsWith('.json'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
for (const f of files) {
|
|
53
|
+
try {
|
|
54
|
+
const rec = JSON.parse(fs.readFileSync(path.join(runNamesDir(), f), 'utf-8'));
|
|
55
|
+
if (rec.sessionId && rec.name)
|
|
56
|
+
map.set(rec.sessionId, rec.name);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* skip a corrupt sidecar */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return map;
|
|
63
|
+
}
|
|
@@ -67,6 +67,14 @@ export interface SessionMeta {
|
|
|
67
67
|
topic?: string;
|
|
68
68
|
/** Custom name the user gave the session (e.g. Claude Code /rename). */
|
|
69
69
|
label?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Durable launch handle from `agents run --name <slug>` — an alias chosen at
|
|
72
|
+
* launch (not derived from the session id), used to resolve the run in
|
|
73
|
+
* `agents sessions <name>`. Distinct from `label` (post-hoc /rename): a run's
|
|
74
|
+
* name is immutable; both are searchable. Absent for runs launched without
|
|
75
|
+
* `--name`.
|
|
76
|
+
*/
|
|
77
|
+
name?: string;
|
|
70
78
|
/** Set when this session was spawned by `agents teams`. */
|
|
71
79
|
teamOrigin?: TeamOrigin;
|
|
72
80
|
/** Durable state signals extracted at scan time by the session-state engine. */
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Viewing in <app> tab N" for tmux-hosted agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* A tmux-wrapped agent (see src/lib/exec.ts `runInTmux`) runs detached on the
|
|
5
|
+
* shared socket; a terminal only *displays* it while a client is attached. This
|
|
6
|
+
* resolver answers "which app + tab is looking at this session right now" by
|
|
7
|
+
* matching the session to its attached tmux client(s) and reusing the app/tab
|
|
8
|
+
* resolvers we already have:
|
|
9
|
+
*
|
|
10
|
+
* - app — the client's terminal PID walked up the process ancestry via the
|
|
11
|
+
* shared HOST_MATCHERS logic (`hostFromPid`).
|
|
12
|
+
* - tab — per app: Ghostty via `assignGhosttyTabs` (cwd + title match), iTerm
|
|
13
|
+
* via the `t<n>` field of the client's `$ITERM_SESSION_ID`, and
|
|
14
|
+
* VS Code / Cursor / Codium via the extension-published `tabIndex` in
|
|
15
|
+
* live-terminals.json (keyed by session id).
|
|
16
|
+
*
|
|
17
|
+
* No client attached => `undefined` (the session is running detached). Every
|
|
18
|
+
* lookup is best-effort; a miss degrades to `{ app }` with no tab, never throws.
|
|
19
|
+
*/
|
|
20
|
+
import type { TmuxClient } from '../tmux/session.js';
|
|
21
|
+
import type { ActiveSession } from './active.js';
|
|
22
|
+
import { type GhosttySurface } from './ghostty-tabs.js';
|
|
23
|
+
/** Where a tmux-hosted session is currently displayed. */
|
|
24
|
+
export interface ViewingIn {
|
|
25
|
+
/** Host app of the attached client — 'ghostty', 'iterm', 'code', 'codium', … */
|
|
26
|
+
app: string;
|
|
27
|
+
/** 1-based tab number within that app, when it can be resolved. */
|
|
28
|
+
tab?: number;
|
|
29
|
+
}
|
|
30
|
+
/** Injection seams so `resolveViewingIn` is unit-testable without a live tmux/ps/osascript. */
|
|
31
|
+
export interface ViewingInDeps {
|
|
32
|
+
/** Ghostty surfaces (window/tab/cwd/title). Enumerated once by the caller and shared. */
|
|
33
|
+
ghosttySurfaces?: GhosttySurface[];
|
|
34
|
+
/** pane id -> `session:window.pane`, from `mapPanesToTargets`. Used to find the session name. */
|
|
35
|
+
paneToTarget?: Map<string, string>;
|
|
36
|
+
/** client pid -> host app. Defaults to the real `hostFromPid`. */
|
|
37
|
+
resolveApp?: (pid: number) => Promise<string | undefined>;
|
|
38
|
+
/** client pid -> raw env. Defaults to reading /proc (Linux) or `ps eww` (macOS). */
|
|
39
|
+
readClientEnv?: (pid: number) => Promise<Record<string, string> | undefined>;
|
|
40
|
+
/** session id -> VS Code editor-tab index, from live-terminals.json. Defaults to the real read. */
|
|
41
|
+
tabIndexForSession?: (sessionId: string | undefined) => number | undefined;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve where a single tmux-hosted session is being viewed. Returns undefined
|
|
45
|
+
* when the session isn't tmux-hosted, can't be located, or has no client
|
|
46
|
+
* attached (detached). Pure aside from the injected (defaulted) probes.
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveViewingIn(session: ActiveSession, clients: TmuxClient[], deps?: ViewingInDeps): Promise<ViewingIn | undefined>;
|
|
49
|
+
/**
|
|
50
|
+
* iTerm tab from the attaching client's `$ITERM_SESSION_ID` (`w<n>t<n>p<n>:UUID`).
|
|
51
|
+
* The `t<n>` field is iTerm2's 0-based tab index; we present it 1-based to match
|
|
52
|
+
* Ghostty's `index of tab`. Exported for the parser test.
|
|
53
|
+
*/
|
|
54
|
+
export declare function itermTabFromSessionId(value: string | undefined): number | undefined;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Viewing in <app> tab N" for tmux-hosted agent sessions.
|
|
3
|
+
*
|
|
4
|
+
* A tmux-wrapped agent (see src/lib/exec.ts `runInTmux`) runs detached on the
|
|
5
|
+
* shared socket; a terminal only *displays* it while a client is attached. This
|
|
6
|
+
* resolver answers "which app + tab is looking at this session right now" by
|
|
7
|
+
* matching the session to its attached tmux client(s) and reusing the app/tab
|
|
8
|
+
* resolvers we already have:
|
|
9
|
+
*
|
|
10
|
+
* - app — the client's terminal PID walked up the process ancestry via the
|
|
11
|
+
* shared HOST_MATCHERS logic (`hostFromPid`).
|
|
12
|
+
* - tab — per app: Ghostty via `assignGhosttyTabs` (cwd + title match), iTerm
|
|
13
|
+
* via the `t<n>` field of the client's `$ITERM_SESSION_ID`, and
|
|
14
|
+
* VS Code / Cursor / Codium via the extension-published `tabIndex` in
|
|
15
|
+
* live-terminals.json (keyed by session id).
|
|
16
|
+
*
|
|
17
|
+
* No client attached => `undefined` (the session is running detached). Every
|
|
18
|
+
* lookup is best-effort; a miss degrades to `{ app }` with no tab, never throws.
|
|
19
|
+
*/
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import { execFile } from 'child_process';
|
|
23
|
+
import { promisify } from 'util';
|
|
24
|
+
import { readFile } from 'fs/promises';
|
|
25
|
+
import { hostFromPid } from './active.js';
|
|
26
|
+
import { enumerateGhosttyTabs, assignGhosttyTabs } from './ghostty-tabs.js';
|
|
27
|
+
import { getTerminalsDir } from '../state.js';
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
/** Apps whose tab index is published by the extension via live-terminals.json. */
|
|
30
|
+
const EDITOR_APPS = new Set(['code', 'cursor', 'codium', 'windsurf']);
|
|
31
|
+
/** The tmux session name a session's pane belongs to, from `session:window.pane`. */
|
|
32
|
+
function sessionNameFor(session, paneToTarget) {
|
|
33
|
+
const pane = session.provenance?.mux?.pane;
|
|
34
|
+
const target = (pane && paneToTarget?.get(pane)) ?? session.tmuxTarget;
|
|
35
|
+
if (!target)
|
|
36
|
+
return undefined;
|
|
37
|
+
const name = target.split(':')[0];
|
|
38
|
+
return name || undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve where a single tmux-hosted session is being viewed. Returns undefined
|
|
42
|
+
* when the session isn't tmux-hosted, can't be located, or has no client
|
|
43
|
+
* attached (detached). Pure aside from the injected (defaulted) probes.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveViewingIn(session, clients, deps = {}) {
|
|
46
|
+
if (session.provenance?.mux?.kind !== 'tmux' || !session.provenance.mux.pane)
|
|
47
|
+
return undefined;
|
|
48
|
+
const sessName = sessionNameFor(session, deps.paneToTarget);
|
|
49
|
+
if (!sessName)
|
|
50
|
+
return undefined;
|
|
51
|
+
const attached = clients.filter((c) => c.target.split(':')[0] === sessName);
|
|
52
|
+
if (attached.length === 0)
|
|
53
|
+
return undefined; // running detached — no viewer
|
|
54
|
+
const client = attached[0];
|
|
55
|
+
const resolveApp = deps.resolveApp ?? hostFromPid;
|
|
56
|
+
const app = (await resolveApp(client.pid)) ?? 'terminal';
|
|
57
|
+
let tab;
|
|
58
|
+
if (app === 'ghostty') {
|
|
59
|
+
tab = await ghosttyTab(session, deps.ghosttySurfaces);
|
|
60
|
+
}
|
|
61
|
+
else if (app === 'iterm') {
|
|
62
|
+
tab = await itermTab(client.pid, deps.readClientEnv ?? readClientEnv);
|
|
63
|
+
}
|
|
64
|
+
else if (EDITOR_APPS.has(app)) {
|
|
65
|
+
const lookup = deps.tabIndexForSession ?? tabIndexFromLiveTerminals;
|
|
66
|
+
tab = lookup(session.sessionId);
|
|
67
|
+
}
|
|
68
|
+
return { app, tab };
|
|
69
|
+
}
|
|
70
|
+
/** Ghostty tab via the existing cwd+title matcher, reusing shared surfaces when provided. */
|
|
71
|
+
async function ghosttyTab(session, surfaces) {
|
|
72
|
+
const s = surfaces ?? (await enumerateGhosttyTabs());
|
|
73
|
+
if (s.length === 0)
|
|
74
|
+
return undefined;
|
|
75
|
+
// assignGhosttyTabs only considers host === 'ghostty' sessions; use a probe
|
|
76
|
+
// clone so we don't mutate the real row's host.
|
|
77
|
+
const probe = { ...session, host: 'ghostty' };
|
|
78
|
+
return assignGhosttyTabs([probe], s).get(probe);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* iTerm tab from the attaching client's `$ITERM_SESSION_ID` (`w<n>t<n>p<n>:UUID`).
|
|
82
|
+
* The `t<n>` field is iTerm2's 0-based tab index; we present it 1-based to match
|
|
83
|
+
* Ghostty's `index of tab`. Exported for the parser test.
|
|
84
|
+
*/
|
|
85
|
+
export function itermTabFromSessionId(value) {
|
|
86
|
+
if (!value)
|
|
87
|
+
return undefined;
|
|
88
|
+
const m = value.match(/t(\d+)/);
|
|
89
|
+
if (!m)
|
|
90
|
+
return undefined;
|
|
91
|
+
const n = parseInt(m[1], 10);
|
|
92
|
+
return Number.isFinite(n) ? n + 1 : undefined;
|
|
93
|
+
}
|
|
94
|
+
async function itermTab(pid, readEnv) {
|
|
95
|
+
const env = await readEnv(pid);
|
|
96
|
+
return itermTabFromSessionId(env?.ITERM_SESSION_ID);
|
|
97
|
+
}
|
|
98
|
+
/** Read a process's env (best-effort): /proc on Linux, `ps eww` on macOS. */
|
|
99
|
+
async function readClientEnv(pid) {
|
|
100
|
+
if (process.platform === 'linux') {
|
|
101
|
+
try {
|
|
102
|
+
const buf = await readFile(`/proc/${pid}/environ`, 'utf8');
|
|
103
|
+
const env = {};
|
|
104
|
+
for (const pair of buf.split('\0')) {
|
|
105
|
+
const eq = pair.indexOf('=');
|
|
106
|
+
if (eq > 0)
|
|
107
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
108
|
+
}
|
|
109
|
+
return env;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (process.platform === 'darwin') {
|
|
116
|
+
try {
|
|
117
|
+
const { stdout } = await execFileAsync('ps', ['eww', '-p', String(pid), '-o', 'command='], {
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
maxBuffer: 1024 * 1024,
|
|
120
|
+
});
|
|
121
|
+
// ITERM_SESSION_ID is a single token (no spaces): grab it out of the flat line.
|
|
122
|
+
const m = stdout.match(/(?:^|\s)ITERM_SESSION_ID=(\S+)/);
|
|
123
|
+
return m ? { ITERM_SESSION_ID: m[1] } : {};
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* VS Code editor-tab index for a session, from the extension's live-terminals.json
|
|
133
|
+
* (`tabIndex` per entry — the DATA CONTRACT with the extension teammate). Read
|
|
134
|
+
* directly (not via active.ts's readLiveTerminals, which strips tabIndex).
|
|
135
|
+
*/
|
|
136
|
+
function tabIndexFromLiveTerminals(sessionId) {
|
|
137
|
+
if (!sessionId)
|
|
138
|
+
return undefined;
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(fs.readFileSync(path.join(getTerminalsDir(), 'live-terminals.json'), 'utf8'));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (!parsed || typeof parsed !== 'object')
|
|
147
|
+
return undefined;
|
|
148
|
+
for (const slice of Object.values(parsed)) {
|
|
149
|
+
for (const e of (slice?.entries ?? [])) {
|
|
150
|
+
if (e?.sessionId === sessionId && typeof e.tabIndex === 'number')
|
|
151
|
+
return e.tabIndex;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
package/dist/lib/ssh-tunnel.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare function startSSHTunnel(user: string, host: string, localPort: nu
|
|
|
47
47
|
export declare const REMOTE_HELPER_PORT = 8765;
|
|
48
48
|
/** Task Scheduler task name for the daemon. Stable so setup/stop pair up. */
|
|
49
49
|
export declare const REMOTE_TASK_NAME = "AgentsComputerHelper";
|
|
50
|
-
/** Basename of the cross-published exe under
|
|
50
|
+
/** Basename of the cross-published exe under native/computer-win/dist. */
|
|
51
51
|
export declare const WIN_HELPER_EXE = "computer-helper-win.exe";
|
|
52
52
|
/**
|
|
53
53
|
* Locate the cross-published Windows daemon exe. Only the local build output is
|
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -86,7 +86,7 @@ export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
|
|
|
86
86
|
export const REMOTE_HELPER_PORT = 8765;
|
|
87
87
|
/** Task Scheduler task name for the daemon. Stable so setup/stop pair up. */
|
|
88
88
|
export const REMOTE_TASK_NAME = 'AgentsComputerHelper';
|
|
89
|
-
/** Basename of the cross-published exe under
|
|
89
|
+
/** Basename of the cross-published exe under native/computer-win/dist. */
|
|
90
90
|
export const WIN_HELPER_EXE = 'computer-helper-win.exe';
|
|
91
91
|
/**
|
|
92
92
|
* Locate the cross-published Windows daemon exe. Only the local build output is
|
|
@@ -95,8 +95,8 @@ export const WIN_HELPER_EXE = 'computer-helper-win.exe';
|
|
|
95
95
|
export function resolveWinHelperExe() {
|
|
96
96
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
97
97
|
const candidates = [
|
|
98
|
-
// Running from the agents-cli checkout
|
|
99
|
-
path.resolve(here, '..', '..', '
|
|
98
|
+
// Running from the agents-cli checkout. apps/cli/dist/lib -> repo root (4 up) -> native/computer-win.
|
|
99
|
+
path.resolve(here, '..', '..', '..', '..', 'native', 'computer-win', 'dist', WIN_HELPER_EXE),
|
|
100
100
|
// Bundled with the npm package (dist/lib -> package root).
|
|
101
101
|
path.resolve(here, '..', 'computer-helper-win', WIN_HELPER_EXE),
|
|
102
102
|
];
|
|
@@ -22,6 +22,13 @@ export interface SessionMeta {
|
|
|
22
22
|
source: 'cli' | 'extension' | 'teams' | 'external';
|
|
23
23
|
/** Free-form labels callers can stamp (e.g. `{ agent: 'claude', vscodePid: 1234 }`). */
|
|
24
24
|
labels?: Record<string, string>;
|
|
25
|
+
/**
|
|
26
|
+
* The first pane's id (`%N`) captured at creation. The exact send-keys /
|
|
27
|
+
* attach handle for the agent that runs in this session — recorded so
|
|
28
|
+
* `agents sessions --active` and the spawn-wrap path (src/lib/exec.ts) don't
|
|
29
|
+
* have to re-query it. Absent for pre-existing/`attach-existing` sessions.
|
|
30
|
+
*/
|
|
31
|
+
pane?: string;
|
|
25
32
|
}
|
|
26
33
|
export interface CreateSessionOptions {
|
|
27
34
|
name: string;
|
|
@@ -80,6 +87,45 @@ export declare function killAll(socket?: string): Promise<number>;
|
|
|
80
87
|
* failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
|
|
81
88
|
*/
|
|
82
89
|
export declare function mapPanesToTargets(socket?: string): Promise<Map<string, string>>;
|
|
90
|
+
/** One tmux client attached to the shared server. */
|
|
91
|
+
export interface TmuxClient {
|
|
92
|
+
/** Controlling TTY of the terminal running `tmux attach` (e.g. '/dev/ttys004'). */
|
|
93
|
+
tty: string;
|
|
94
|
+
/** PID of the `tmux attach` client process — the leaf whose ancestry names the host app. */
|
|
95
|
+
pid: number;
|
|
96
|
+
/** The `session:window.pane` the client is currently displaying. */
|
|
97
|
+
target: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* List every client attached to the shared server, with the terminal PID and
|
|
101
|
+
* the session/window/pane it's viewing. This is how "viewing in <app> tab N"
|
|
102
|
+
* resolves: a client's `pid` walks the process ancestry to name the host app,
|
|
103
|
+
* and its `target` says which session it's attached to. Best-effort — returns
|
|
104
|
+
* an empty list on any failure (no server, foreign socket) so the renderer
|
|
105
|
+
* degrades to "detached".
|
|
106
|
+
*/
|
|
107
|
+
export declare function listClients(socket?: string): Promise<TmuxClient[]>;
|
|
108
|
+
/** A dead pane's exit status, read from tmux while the pane lingers under remain-on-exit. */
|
|
109
|
+
export interface PaneExit {
|
|
110
|
+
/** True once the process that ran in the pane has exited (pane is dead). */
|
|
111
|
+
dead: boolean;
|
|
112
|
+
/** Exit status of the dead pane's process, when tmux reports it. */
|
|
113
|
+
status?: number;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Read whether a pane's process has exited and, if so, its exit status. Used by
|
|
117
|
+
* the spawn-wrap path to recover the wrapped agent's exit code after the attach
|
|
118
|
+
* client returns. Returns `{ dead: false }` when tmux can't answer (session gone,
|
|
119
|
+
* pane missing) so the caller treats an unreadable pane as "still alive / detach".
|
|
120
|
+
*/
|
|
121
|
+
export declare function paneExitStatus(pane: string, socket?: string): Promise<PaneExit>;
|
|
122
|
+
/**
|
|
123
|
+
* Bind a per-session hook. Used by the spawn-wrap path to install a `pane-died`
|
|
124
|
+
* hook that detaches the attach client the instant the wrapped agent exits (the
|
|
125
|
+
* global `remain-on-exit on` otherwise leaves the client staring at a dead pane).
|
|
126
|
+
* Best-effort — a failed hook just means the user Ctrl-b d's out manually.
|
|
127
|
+
*/
|
|
128
|
+
export declare function setSessionHook(name: string, hook: string, command: string, socket?: string): Promise<void>;
|
|
83
129
|
/**
|
|
84
130
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
85
131
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/dist/lib/tmux/session.js
CHANGED
|
@@ -76,7 +76,9 @@ export async function createSession(opts) {
|
|
|
76
76
|
// exit the server, and the follow-up `set-option` would race with "no
|
|
77
77
|
// server running". Server-wide (`-g`) is applied in the same tmux
|
|
78
78
|
// invocation as new-session so they share one server lifetime.
|
|
79
|
-
|
|
79
|
+
// `-P -F '#{pane_id}'` prints the new session's first pane id on stdout so we
|
|
80
|
+
// can record the exact `%N` handle without a follow-up `list-panes`.
|
|
81
|
+
const args = ['set-option', '-g', 'remain-on-exit', 'on', ';', 'new-session', '-d', '-s', opts.name, '-P', '-F', '#{pane_id}'];
|
|
80
82
|
if (opts.width)
|
|
81
83
|
args.push('-x', String(opts.width));
|
|
82
84
|
if (opts.height)
|
|
@@ -88,7 +90,9 @@ export async function createSession(opts) {
|
|
|
88
90
|
if (opts.cmd) {
|
|
89
91
|
args.push('--', 'sh', '-c', opts.cmd);
|
|
90
92
|
}
|
|
91
|
-
await runTmux({ socket, args, env: opts.env });
|
|
93
|
+
const res = await runTmux({ socket, args, env: opts.env });
|
|
94
|
+
// Only the new-session command in the `;`-chained invocation emits output.
|
|
95
|
+
const pane = /^%\d+$/.test(res.stdout.trim()) ? res.stdout.trim() : undefined;
|
|
92
96
|
const meta = {
|
|
93
97
|
name: opts.name,
|
|
94
98
|
socket,
|
|
@@ -97,6 +101,7 @@ export async function createSession(opts) {
|
|
|
97
101
|
cwd: opts.cwd,
|
|
98
102
|
source: opts.source ?? 'cli',
|
|
99
103
|
labels: opts.labels,
|
|
104
|
+
pane,
|
|
100
105
|
};
|
|
101
106
|
writeSessionMeta(meta);
|
|
102
107
|
return meta;
|
|
@@ -187,6 +192,83 @@ export async function mapPanesToTargets(socket) {
|
|
|
187
192
|
}
|
|
188
193
|
return out;
|
|
189
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* List every client attached to the shared server, with the terminal PID and
|
|
197
|
+
* the session/window/pane it's viewing. This is how "viewing in <app> tab N"
|
|
198
|
+
* resolves: a client's `pid` walks the process ancestry to name the host app,
|
|
199
|
+
* and its `target` says which session it's attached to. Best-effort — returns
|
|
200
|
+
* an empty list on any failure (no server, foreign socket) so the renderer
|
|
201
|
+
* degrades to "detached".
|
|
202
|
+
*/
|
|
203
|
+
export async function listClients(socket) {
|
|
204
|
+
let res;
|
|
205
|
+
try {
|
|
206
|
+
res = await runTmux({
|
|
207
|
+
socket,
|
|
208
|
+
args: ['list-clients', '-F', '#{client_tty} #{client_pid} #{session_name}:#{window_index}.#{pane_index}'],
|
|
209
|
+
throwOnError: false,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
if (res.code !== 0)
|
|
216
|
+
return [];
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const line of res.stdout.split('\n')) {
|
|
219
|
+
const t = line.trim();
|
|
220
|
+
if (!t)
|
|
221
|
+
continue;
|
|
222
|
+
const sp1 = t.indexOf(' ');
|
|
223
|
+
if (sp1 < 0)
|
|
224
|
+
continue;
|
|
225
|
+
const sp2 = t.indexOf(' ', sp1 + 1);
|
|
226
|
+
if (sp2 < 0)
|
|
227
|
+
continue;
|
|
228
|
+
const tty = t.slice(0, sp1);
|
|
229
|
+
const pid = parseInt(t.slice(sp1 + 1, sp2), 10);
|
|
230
|
+
const target = t.slice(sp2 + 1).trim();
|
|
231
|
+
if (!Number.isFinite(pid) || !target)
|
|
232
|
+
continue;
|
|
233
|
+
out.push({ tty, pid, target });
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Read whether a pane's process has exited and, if so, its exit status. Used by
|
|
239
|
+
* the spawn-wrap path to recover the wrapped agent's exit code after the attach
|
|
240
|
+
* client returns. Returns `{ dead: false }` when tmux can't answer (session gone,
|
|
241
|
+
* pane missing) so the caller treats an unreadable pane as "still alive / detach".
|
|
242
|
+
*/
|
|
243
|
+
export async function paneExitStatus(pane, socket) {
|
|
244
|
+
let res;
|
|
245
|
+
try {
|
|
246
|
+
res = await runTmux({
|
|
247
|
+
socket,
|
|
248
|
+
args: ['display-message', '-pt', pane, '-p', '#{pane_dead} #{pane_dead_status}'],
|
|
249
|
+
throwOnError: false,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return { dead: false };
|
|
254
|
+
}
|
|
255
|
+
if (res.code !== 0)
|
|
256
|
+
return { dead: false };
|
|
257
|
+
const [deadRaw, statusRaw] = res.stdout.trim().split(/\s+/);
|
|
258
|
+
const status = statusRaw !== undefined && statusRaw !== '' ? parseInt(statusRaw, 10) : undefined;
|
|
259
|
+
return { dead: deadRaw === '1', status: Number.isFinite(status) ? status : undefined };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Bind a per-session hook. Used by the spawn-wrap path to install a `pane-died`
|
|
263
|
+
* hook that detaches the attach client the instant the wrapped agent exits (the
|
|
264
|
+
* global `remain-on-exit on` otherwise leaves the client staring at a dead pane).
|
|
265
|
+
* Best-effort — a failed hook just means the user Ctrl-b d's out manually.
|
|
266
|
+
*/
|
|
267
|
+
export async function setSessionHook(name, hook, command, socket) {
|
|
268
|
+
assertValidSessionName(name);
|
|
269
|
+
const sock = socket ?? getDefaultSocketPath();
|
|
270
|
+
await runTmux({ socket: sock, args: ['set-hook', '-t', name, hook, command], throwOnError: false }).catch(() => { });
|
|
271
|
+
}
|
|
190
272
|
/**
|
|
191
273
|
* List live sessions on the socket. Reconciles meta JSONs against tmux's view:
|
|
192
274
|
* - tmux session with no meta → returned without `meta` (external session)
|
package/dist/lib/usage.d.ts
CHANGED
|
@@ -88,9 +88,11 @@ export declare function getUsageInfoForIdentity(input: UsageIdentityInput): Prom
|
|
|
88
88
|
export declare function formatUsageSummary(plan: string | null, snapshot: UsageSnapshot | null, planWidth?: number): string;
|
|
89
89
|
/**
|
|
90
90
|
* Derive an account's real throttle state from its live usage windows — the
|
|
91
|
-
*
|
|
92
|
-
* (`
|
|
93
|
-
* the account is throttled until
|
|
91
|
+
* single signal both the `agents view` badge and run-rotation eligibility share
|
|
92
|
+
* (`hasUsageAvailable` in rotate.ts treats a `rate_limited` verdict here as
|
|
93
|
+
* ineligible). A window at 100% utilization means the account is throttled until
|
|
94
|
+
* that window resets. Rotation *weighting* still ranks eligible accounts by
|
|
95
|
+
* weekly headroom (`getRoutingUsedPercent`); this function is the yes/no gate.
|
|
94
96
|
*
|
|
95
97
|
* Returns `null` when there is no snapshot, so callers render no badge rather
|
|
96
98
|
* than a misleading one. This deliberately never consults
|
package/dist/lib/usage.js
CHANGED
|
@@ -212,9 +212,11 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
|
|
|
212
212
|
}
|
|
213
213
|
/**
|
|
214
214
|
* Derive an account's real throttle state from its live usage windows — the
|
|
215
|
-
*
|
|
216
|
-
* (`
|
|
217
|
-
* the account is throttled until
|
|
215
|
+
* single signal both the `agents view` badge and run-rotation eligibility share
|
|
216
|
+
* (`hasUsageAvailable` in rotate.ts treats a `rate_limited` verdict here as
|
|
217
|
+
* ineligible). A window at 100% utilization means the account is throttled until
|
|
218
|
+
* that window resets. Rotation *weighting* still ranks eligible accounts by
|
|
219
|
+
* weekly headroom (`getRoutingUsedPercent`); this function is the yes/no gate.
|
|
218
220
|
*
|
|
219
221
|
* Returns `null` when there is no snapshot, so callers render no badge rather
|
|
220
222
|
* than a misleading one. This deliberately never consults
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.43",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|