@phnx-labs/agents-cli 1.20.28 → 1.20.30
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/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/exec.js +22 -10
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/secrets.js +93 -6
- package/dist/commands/sessions.js +157 -44
- package/dist/commands/ssh.d.ts +14 -0
- package/dist/commands/ssh.js +263 -0
- package/dist/commands/sync.js +70 -14
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +54 -5
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/devices/connect.d.ts +34 -0
- package/dist/lib/devices/connect.js +101 -0
- package/dist/lib/devices/registry.d.ts +78 -0
- package/dist/lib/devices/registry.js +168 -0
- package/dist/lib/devices/ssh-config.d.ts +21 -0
- package/dist/lib/devices/ssh-config.js +33 -0
- package/dist/lib/devices/tailscale.d.ts +31 -0
- package/dist/lib/devices/tailscale.js +126 -0
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/secrets/remote.d.ts +67 -0
- package/dist/lib/secrets/remote.js +133 -0
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/active.js +79 -18
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +12 -0
- package/dist/lib/session/db.js +66 -9
- package/dist/lib/session/discover.d.ts +7 -0
- package/dist/lib/session/discover.js +309 -0
- package/dist/lib/session/parse.d.ts +22 -0
- package/dist/lib/session/parse.js +132 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +10 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote secrets — read and use `agents secrets` bundles that live on another
|
|
3
|
+
* host, over the same hardened SSH path that `agents secrets export --host`
|
|
4
|
+
* (the write inverse) already uses.
|
|
5
|
+
*
|
|
6
|
+
* This is the READ / USE direction:
|
|
7
|
+
* - browse: drive the remote `agents secrets list|view` and stream its
|
|
8
|
+
* stdout back verbatim (lossless, no parsing).
|
|
9
|
+
* - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
|
|
10
|
+
* inject it ephemerally — never written to this machine's keychain.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
|
+
* boundary as `export --host` / `run --host`). Bundle names are shell-quoted
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout; a forwarded
|
|
15
|
+
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
|
+
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
|
+
*/
|
|
18
|
+
import { type SshExecResult } from '../ssh-exec.js';
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
|
|
21
|
+
* registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
|
|
22
|
+
* treats the value as a raw ssh target and validates it against injection.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveSshTarget(nameOrAlias: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
|
|
27
|
+
* list. Both flags compose; either alone works. Empty when neither is set.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseHostsOption(opts: {
|
|
30
|
+
host?: string;
|
|
31
|
+
hosts?: string;
|
|
32
|
+
}): string[];
|
|
33
|
+
/**
|
|
34
|
+
* Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
|
|
35
|
+
* Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
|
|
36
|
+
* separates the bundle from the ssh target — and the target itself may be a
|
|
37
|
+
* `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
|
|
38
|
+
* `muqsit@box`).
|
|
39
|
+
*/
|
|
40
|
+
export declare function splitBundleRef(ref: string): {
|
|
41
|
+
bundle: string;
|
|
42
|
+
host?: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Run `agents secrets <args>` on a remote host over ssh and return the raw
|
|
46
|
+
* result. Used by the browse commands — the remote's human-readable stdout is
|
|
47
|
+
* streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
|
|
48
|
+
* remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
|
|
49
|
+
*/
|
|
50
|
+
export declare function remoteSecretsRaw(target: string, args: string[], opts?: {
|
|
51
|
+
tty?: boolean;
|
|
52
|
+
input?: string;
|
|
53
|
+
}): SshExecResult;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
56
|
+
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
57
|
+
* ssh stdout (encrypted in transit), parsed in memory, never persisted.
|
|
58
|
+
*
|
|
59
|
+
* The remote unlocks the bundle with ITS OWN credentials — the owner host's
|
|
60
|
+
* keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
|
|
61
|
+
* env) for a file-backed bundle. We deliberately do NOT forward this machine's
|
|
62
|
+
* passphrase: the remote bundle is encrypted with the remote's passphrase, so
|
|
63
|
+
* overriding it would break the read. (A macOS remote under non-interactive
|
|
64
|
+
* SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
|
|
65
|
+
* an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
|
|
66
|
+
*/
|
|
67
|
+
export declare function remoteResolveEnv(target: string, bundle: string): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote secrets — read and use `agents secrets` bundles that live on another
|
|
3
|
+
* host, over the same hardened SSH path that `agents secrets export --host`
|
|
4
|
+
* (the write inverse) already uses.
|
|
5
|
+
*
|
|
6
|
+
* This is the READ / USE direction:
|
|
7
|
+
* - browse: drive the remote `agents secrets list|view` and stream its
|
|
8
|
+
* stdout back verbatim (lossless, no parsing).
|
|
9
|
+
* - use: resolve a remote bundle to an env map (JSON over ssh stdout) and
|
|
10
|
+
* inject it ephemerally — never written to this machine's keychain.
|
|
11
|
+
*
|
|
12
|
+
* Trust model: relies on the operator's existing SSH access to the host (same
|
|
13
|
+
* boundary as `export --host` / `run --host`). Bundle names are shell-quoted
|
|
14
|
+
* into the remote command; resolved VALUES return over ssh stdout; a forwarded
|
|
15
|
+
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
|
+
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
|
+
*/
|
|
18
|
+
import { sshExec, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { resolveHost } from '../hosts/registry.js';
|
|
20
|
+
import { sshTargetFor } from '../hosts/types.js';
|
|
21
|
+
const REMOTE_TIMEOUT_MS = 30_000;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a `--host` value to an ssh target string. Tries the `agents hosts`
|
|
24
|
+
* registry first (enrolled name → ssh-config alias / `user@host`); on a miss,
|
|
25
|
+
* treats the value as a raw ssh target and validates it against injection.
|
|
26
|
+
*/
|
|
27
|
+
export async function resolveSshTarget(nameOrAlias) {
|
|
28
|
+
const host = await resolveHost(nameOrAlias);
|
|
29
|
+
if (host)
|
|
30
|
+
return sshTargetFor(host);
|
|
31
|
+
assertValidSshTarget(nameOrAlias);
|
|
32
|
+
return nameOrAlias;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merge `--host <single>` and `--hosts <a,b,c>` into an ordered, de-duplicated
|
|
36
|
+
* list. Both flags compose; either alone works. Empty when neither is set.
|
|
37
|
+
*/
|
|
38
|
+
export function parseHostsOption(opts) {
|
|
39
|
+
const out = [];
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const push = (h) => {
|
|
42
|
+
const t = h.trim();
|
|
43
|
+
if (t && !seen.has(t)) {
|
|
44
|
+
seen.add(t);
|
|
45
|
+
out.push(t);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
if (opts.host)
|
|
49
|
+
push(opts.host);
|
|
50
|
+
if (opts.hosts)
|
|
51
|
+
for (const h of opts.hosts.split(','))
|
|
52
|
+
push(h);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Split a `bundle@host` reference. No `@` → a local bundle (host undefined).
|
|
57
|
+
* Bundle names can't contain `@` (BUNDLE_NAME_PATTERN), so the FIRST `@`
|
|
58
|
+
* separates the bundle from the ssh target — and the target itself may be a
|
|
59
|
+
* `user@host` (e.g. `r2.backups@muqsit@box` → bundle `r2.backups`, host
|
|
60
|
+
* `muqsit@box`).
|
|
61
|
+
*/
|
|
62
|
+
export function splitBundleRef(ref) {
|
|
63
|
+
const at = ref.indexOf('@');
|
|
64
|
+
if (at === -1)
|
|
65
|
+
return { bundle: ref };
|
|
66
|
+
const bundle = ref.slice(0, at);
|
|
67
|
+
const host = ref.slice(at + 1);
|
|
68
|
+
if (!bundle || !host) {
|
|
69
|
+
throw new Error(`Invalid remote bundle reference ${JSON.stringify(ref)}. Expected 'bundle@host'.`);
|
|
70
|
+
}
|
|
71
|
+
return { bundle, host };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Run `agents secrets <args>` on a remote host over ssh and return the raw
|
|
75
|
+
* result. Used by the browse commands — the remote's human-readable stdout is
|
|
76
|
+
* streamed back unchanged. `tty` forces an interactive ssh session (`-tt`) so a
|
|
77
|
+
* remote Touch-ID / passphrase prompt can surface (e.g. `view --reveal`).
|
|
78
|
+
*/
|
|
79
|
+
export function remoteSecretsRaw(target, args, opts = {}) {
|
|
80
|
+
const inner = ['agents', 'secrets', ...args].map(shellQuote).join(' ');
|
|
81
|
+
const remoteCmd = `bash -lc ${shellQuote(inner)}`;
|
|
82
|
+
return sshExec(target, remoteCmd, {
|
|
83
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
84
|
+
input: opts.input,
|
|
85
|
+
extraSshArgs: opts.tty ? ['-tt'] : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
90
|
+
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
91
|
+
* ssh stdout (encrypted in transit), parsed in memory, never persisted.
|
|
92
|
+
*
|
|
93
|
+
* The remote unlocks the bundle with ITS OWN credentials — the owner host's
|
|
94
|
+
* keychain/secrets-agent, or its own `AGENTS_SECRETS_PASSPHRASE` (in the login
|
|
95
|
+
* env) for a file-backed bundle. We deliberately do NOT forward this machine's
|
|
96
|
+
* passphrase: the remote bundle is encrypted with the remote's passphrase, so
|
|
97
|
+
* overriding it would break the read. (A macOS remote under non-interactive
|
|
98
|
+
* SSH will block on Touch-ID — use `view`/`exec` with a remote `file` bundle,
|
|
99
|
+
* an already-unlocked remote secrets-agent, or an interactive `-tt` session.)
|
|
100
|
+
*/
|
|
101
|
+
export async function remoteResolveEnv(target, bundle) {
|
|
102
|
+
assertValidSshTarget(target);
|
|
103
|
+
const exportCmd = `agents secrets export ${shellQuote(bundle)} --plaintext --format json`;
|
|
104
|
+
const res = sshExec(target, `bash -lc ${shellQuote(exportCmd)}`, {
|
|
105
|
+
timeoutMs: REMOTE_TIMEOUT_MS,
|
|
106
|
+
});
|
|
107
|
+
if (res.code !== 0) {
|
|
108
|
+
const msg = (res.stderr || res.stdout || '').trim();
|
|
109
|
+
const why = res.timedOut ? 'timed out' : res.code === null ? 'ssh failed' : `exit ${res.code}`;
|
|
110
|
+
throw new Error(`Failed to resolve '${bundle}' on ${target} (${why})${msg ? `: ${msg}` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
// Tolerate login-shell banner noise on stdout: take the outer { … } object.
|
|
113
|
+
const raw = res.stdout;
|
|
114
|
+
const start = raw.indexOf('{');
|
|
115
|
+
const end = raw.lastIndexOf('}');
|
|
116
|
+
const jsonText = start >= 0 && end >= start ? raw.slice(start, end + 1) : raw.trim();
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(jsonText);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
throw new Error(`Could not parse secrets JSON from '${bundle}' on ${target}. ` +
|
|
123
|
+
`Is the remote agents-cli new enough for 'secrets export --format json'?`);
|
|
124
|
+
}
|
|
125
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
126
|
+
throw new Error(`Unexpected payload resolving '${bundle}' on ${target}.`);
|
|
127
|
+
}
|
|
128
|
+
const env = {};
|
|
129
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
130
|
+
env[k] = typeof v === 'string' ? v : String(v);
|
|
131
|
+
}
|
|
132
|
+
return env;
|
|
133
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type SessionActivity, type AwaitingReason, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
|
|
1
2
|
export type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
|
|
2
3
|
export type ActiveStatus = 'running' | 'idle' | 'queued' | 'input_required';
|
|
3
4
|
export interface ActiveSession {
|
|
@@ -12,6 +13,18 @@ export interface ActiveSession {
|
|
|
12
13
|
label?: string;
|
|
13
14
|
/** First meaningful line of the initial prompt (extracted topic). */
|
|
14
15
|
topic?: string;
|
|
16
|
+
/** Live preview: the latest turn (agent message or tool action), from the state engine. */
|
|
17
|
+
preview?: string;
|
|
18
|
+
/** Inferred activity: working / waiting_input / idle (from the transcript tail). */
|
|
19
|
+
activity?: SessionActivity;
|
|
20
|
+
/** Why the agent is waiting, when activity is waiting_input. */
|
|
21
|
+
awaitingReason?: AwaitingReason;
|
|
22
|
+
/** PR opened during the session. */
|
|
23
|
+
pr?: DetectedPr;
|
|
24
|
+
/** Worktree the session runs in. */
|
|
25
|
+
worktree?: DetectedWorktree;
|
|
26
|
+
/** Tracker ticket the session is tied to. */
|
|
27
|
+
ticket?: DetectedTicket;
|
|
15
28
|
sessionFile?: string;
|
|
16
29
|
startedAtMs?: number;
|
|
17
30
|
status: ActiveStatus;
|
|
@@ -25,7 +25,10 @@ import { listActiveTasks } from '../cloud/store.js';
|
|
|
25
25
|
import { AgentManager } from '../teams/agents.js';
|
|
26
26
|
import { getTerminalsDir } from '../state.js';
|
|
27
27
|
import { buildClaudeLabelMap } from './discover.js';
|
|
28
|
+
import { latestSessionFileForCwd } from './db.js';
|
|
28
29
|
import { extractSessionTopic } from './prompt.js';
|
|
30
|
+
import { readSessionTail } from './tail.js';
|
|
31
|
+
import { inferSessionState } from './state.js';
|
|
29
32
|
const execFileAsync = promisify(execFile);
|
|
30
33
|
const HOME = os.homedir();
|
|
31
34
|
const LIVE_TERMINALS_FILE = path.join(getTerminalsDir(), 'live-terminals.json');
|
|
@@ -42,6 +45,7 @@ const AGENT_CLI_NAMES = {
|
|
|
42
45
|
gemini: 'gemini',
|
|
43
46
|
'cursor-agent': 'cursor',
|
|
44
47
|
opencode: 'opencode',
|
|
48
|
+
droid: 'droid',
|
|
45
49
|
};
|
|
46
50
|
function isPidAlive(pid) {
|
|
47
51
|
if (!pid || pid < 1)
|
|
@@ -128,6 +132,66 @@ function classifyActivity(sessionFile) {
|
|
|
128
132
|
return 'running';
|
|
129
133
|
}
|
|
130
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Locate the live transcript for an agent process. Claude files are keyed by
|
|
137
|
+
* cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
|
|
138
|
+
* the newest indexed Codex session for the cwd instead.
|
|
139
|
+
*/
|
|
140
|
+
function findSessionFileForKind(kind, cwd, sessionId) {
|
|
141
|
+
if (!cwd)
|
|
142
|
+
return undefined;
|
|
143
|
+
if (kind === 'claude')
|
|
144
|
+
return findClaudeSessionFile(cwd, sessionId);
|
|
145
|
+
if (kind === 'codex')
|
|
146
|
+
return latestSessionFileForCwd('codex', cwd);
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
/** Recover the session UUID from a transcript filename (Claude `<uuid>.jsonl`, Codex `rollout-…-<uuid>.jsonl`). */
|
|
150
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
151
|
+
function sessionIdFromFile(file) {
|
|
152
|
+
if (!file)
|
|
153
|
+
return undefined;
|
|
154
|
+
return path.basename(file).match(UUID_RE)?.[0];
|
|
155
|
+
}
|
|
156
|
+
/** Infer live state from a session file's tail (Claude/Codex). Undefined when unreadable. */
|
|
157
|
+
function computeLiveState(kind, sessionFile, cwd, pidAlive) {
|
|
158
|
+
if (!sessionFile)
|
|
159
|
+
return undefined;
|
|
160
|
+
const agent = kind === 'codex' ? 'codex' : 'claude';
|
|
161
|
+
const events = readSessionTail(sessionFile, agent);
|
|
162
|
+
if (events.length === 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
let mtimeMs;
|
|
165
|
+
try {
|
|
166
|
+
mtimeMs = fs.statSync(sessionFile).mtimeMs;
|
|
167
|
+
}
|
|
168
|
+
catch { /* vanished between calls */ }
|
|
169
|
+
return inferSessionState(events, { cwd, pidAlive, mtimeMs, activeWindowMs: ACTIVE_MTIME_WINDOW_MS });
|
|
170
|
+
}
|
|
171
|
+
/** Map inferred activity onto the coarse ActiveStatus used by the renderer and counts. */
|
|
172
|
+
function statusFromActivity(activity) {
|
|
173
|
+
return activity === 'working' ? 'running' : activity === 'waiting_input' ? 'input_required' : 'idle';
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Fold a computed SessionState onto an active-session row: rich status +
|
|
177
|
+
* preview + PR/worktree/ticket badges. With no state (unreadable/non-Claude/
|
|
178
|
+
* Codex file) it degrades to the mtime-only classification.
|
|
179
|
+
*/
|
|
180
|
+
function applyState(base, state, fallbackFile) {
|
|
181
|
+
if (!state)
|
|
182
|
+
return { ...base, status: classifyActivity(fallbackFile) };
|
|
183
|
+
return {
|
|
184
|
+
...base,
|
|
185
|
+
status: statusFromActivity(state.activity),
|
|
186
|
+
activity: state.activity,
|
|
187
|
+
awaitingReason: state.awaitingReason,
|
|
188
|
+
// Prefer the live preview (latest turn); keep the first-prompt topic as a fallback.
|
|
189
|
+
preview: state.preview ?? base.preview,
|
|
190
|
+
pr: state.pr,
|
|
191
|
+
worktree: state.worktree,
|
|
192
|
+
ticket: state.ticket,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
131
195
|
/**
|
|
132
196
|
* Extract the first user message's content from a Claude JSONL file.
|
|
133
197
|
* Reads only the first ~50 lines for speed, since the user message is
|
|
@@ -207,24 +271,22 @@ export async function listTeamsActive() {
|
|
|
207
271
|
const running = await mgr.listRunning();
|
|
208
272
|
return running.map((a) => {
|
|
209
273
|
const sessionId = a.parentSessionId ?? a.remoteSessionId ?? undefined;
|
|
210
|
-
const sessionFile = a.agentType
|
|
211
|
-
? findClaudeSessionFile(a.cwd, sessionId ?? undefined)
|
|
212
|
-
: undefined;
|
|
274
|
+
const sessionFile = findSessionFileForKind(a.agentType, a.cwd ?? undefined, sessionId ?? undefined);
|
|
213
275
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
214
|
-
|
|
276
|
+
const state = computeLiveState(a.agentType, sessionFile, a.cwd ?? undefined, a.pid ? isPidAlive(a.pid) : true);
|
|
277
|
+
return applyState({
|
|
215
278
|
context: 'teams',
|
|
216
279
|
kind: a.agentType,
|
|
217
280
|
pid: a.pid ?? undefined,
|
|
218
|
-
sessionId,
|
|
281
|
+
sessionId: sessionId ?? sessionIdFromFile(sessionFile),
|
|
219
282
|
cwd: a.cwd ?? undefined,
|
|
220
283
|
label: a.name ?? undefined,
|
|
221
284
|
topic,
|
|
222
285
|
sessionFile,
|
|
223
286
|
startedAtMs: a.startedAt.getTime(),
|
|
224
|
-
status: classifyActivity(sessionFile),
|
|
225
287
|
teamName: a.taskName,
|
|
226
288
|
agentId: a.agentId,
|
|
227
|
-
};
|
|
289
|
+
}, state, sessionFile);
|
|
228
290
|
});
|
|
229
291
|
}
|
|
230
292
|
/** Live editor-terminal agents across every IDE window. */
|
|
@@ -240,27 +302,25 @@ export async function listTerminalsActive() {
|
|
|
240
302
|
// Build label map from Claude's sessions/*.json for /rename support
|
|
241
303
|
const labelMap = buildClaudeLabelMap();
|
|
242
304
|
return entries.map((t) => {
|
|
243
|
-
const sessionFile = t.kind
|
|
244
|
-
? findClaudeSessionFile(t.cwd, t.sessionId)
|
|
245
|
-
: undefined;
|
|
305
|
+
const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, t.sessionId);
|
|
246
306
|
// Prefer label from live terminal, fall back to Claude's session label
|
|
247
307
|
const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
|
|
248
308
|
// Extract topic from session file (first meaningful user message)
|
|
249
309
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
250
|
-
|
|
310
|
+
const state = computeLiveState(t.kind, sessionFile, t.cwd ?? undefined, isPidAlive(t.pid));
|
|
311
|
+
return applyState({
|
|
251
312
|
context: 'terminal',
|
|
252
313
|
kind: t.kind,
|
|
253
314
|
host: detectHost(t.pid, procByPid),
|
|
254
315
|
pid: t.pid,
|
|
255
|
-
sessionId: t.sessionId,
|
|
316
|
+
sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
|
|
256
317
|
cwd: t.cwd ?? undefined,
|
|
257
318
|
label,
|
|
258
319
|
topic,
|
|
259
320
|
sessionFile,
|
|
260
321
|
startedAtMs: t.startedAtMs,
|
|
261
|
-
status: classifyActivity(sessionFile),
|
|
262
322
|
windowId: t.windowId,
|
|
263
|
-
};
|
|
323
|
+
}, state, sessionFile);
|
|
264
324
|
});
|
|
265
325
|
}
|
|
266
326
|
/** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
|
|
@@ -439,20 +499,21 @@ export async function listUnattributedActive(attributed) {
|
|
|
439
499
|
for (let i = 0; i < candidates.length; i++) {
|
|
440
500
|
const { pid, kind } = candidates[i];
|
|
441
501
|
const cwd = cwds[i];
|
|
442
|
-
const sessionFile = kind
|
|
502
|
+
const sessionFile = findSessionFileForKind(kind, cwd);
|
|
443
503
|
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
444
504
|
const host = detectHost(pid, procByPid);
|
|
445
505
|
const context = host && UI_HOSTS.has(host) ? 'terminal' : 'headless';
|
|
446
|
-
|
|
506
|
+
const state = computeLiveState(kind, sessionFile, cwd, true);
|
|
507
|
+
out.push(applyState({
|
|
447
508
|
context,
|
|
448
509
|
kind,
|
|
449
510
|
host,
|
|
450
511
|
pid,
|
|
451
512
|
cwd,
|
|
513
|
+
sessionId: sessionIdFromFile(sessionFile),
|
|
452
514
|
topic,
|
|
453
515
|
sessionFile,
|
|
454
|
-
|
|
455
|
-
});
|
|
516
|
+
}, state, sessionFile));
|
|
456
517
|
}
|
|
457
518
|
return out;
|
|
458
519
|
}
|
package/dist/lib/session/db.d.ts
CHANGED
|
@@ -30,11 +30,16 @@ export interface SessionRow {
|
|
|
30
30
|
file_size: number | null;
|
|
31
31
|
scanned_at: number | null;
|
|
32
32
|
is_team_origin: number;
|
|
33
|
+
pr_url: string | null;
|
|
34
|
+
pr_number: number | null;
|
|
35
|
+
worktree_slug: string | null;
|
|
36
|
+
ticket_id: string | null;
|
|
33
37
|
}
|
|
34
38
|
/** File stat snapshot used to detect changes between scan runs. */
|
|
35
39
|
export interface ScanStamp {
|
|
36
40
|
fileMtimeMs: number;
|
|
37
41
|
fileSize: number;
|
|
42
|
+
scannedAt?: number;
|
|
38
43
|
}
|
|
39
44
|
/** Filter and pagination options for querying the sessions table. */
|
|
40
45
|
export interface QueryOptions {
|
|
@@ -132,6 +137,13 @@ export declare function syncLabels(labelMap: Map<string, string | null>): number
|
|
|
132
137
|
* Returns the number of rows updated.
|
|
133
138
|
*/
|
|
134
139
|
export declare function syncTopics(topicMap: Map<string, string>): number;
|
|
140
|
+
/**
|
|
141
|
+
* Newest indexed session file for an agent working in `cwd`. Lets the live
|
|
142
|
+
* `--active` scanner locate a Codex transcript (whose files are date-partitioned,
|
|
143
|
+
* not cwd-keyed like Claude's) by reusing the index. Returns undefined if the
|
|
144
|
+
* session hasn't been scanned yet — the caller degrades to no live state.
|
|
145
|
+
*/
|
|
146
|
+
export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string): string | undefined;
|
|
135
147
|
/** Query sessions from the database, applying filters and ordering by timestamp descending. */
|
|
136
148
|
export declare function querySessions(options?: QueryOptions): SessionMeta[];
|
|
137
149
|
/** Count sessions matching the given filter options. */
|
package/dist/lib/session/db.js
CHANGED
|
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
|
|
|
13
13
|
const SESSIONS_DIR = getSessionsDir();
|
|
14
14
|
const DB_PATH = getSessionsDbPath();
|
|
15
15
|
/** Current schema version; bumped when migrations are added. */
|
|
16
|
-
const SCHEMA_VERSION =
|
|
16
|
+
const SCHEMA_VERSION = 7;
|
|
17
17
|
/**
|
|
18
18
|
* Canonicalize a file path for use as a scan_ledger key. The same physical
|
|
19
19
|
* session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
|
|
@@ -59,7 +59,11 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
59
59
|
file_mtime_ms INTEGER,
|
|
60
60
|
file_size INTEGER,
|
|
61
61
|
scanned_at INTEGER,
|
|
62
|
-
is_team_origin INTEGER DEFAULT 0
|
|
62
|
+
is_team_origin INTEGER DEFAULT 0,
|
|
63
|
+
pr_url TEXT,
|
|
64
|
+
pr_number INTEGER,
|
|
65
|
+
worktree_slug TEXT,
|
|
66
|
+
ticket_id TEXT
|
|
63
67
|
);
|
|
64
68
|
CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
65
69
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
@@ -157,6 +161,21 @@ function migrateSchema(db, fromVersion) {
|
|
|
157
161
|
}
|
|
158
162
|
db.exec(`DELETE FROM scan_ledger;`);
|
|
159
163
|
}
|
|
164
|
+
if (fromVersion < 7) {
|
|
165
|
+
// v6 → v7: the session-state engine now persists durable signals (PR opened,
|
|
166
|
+
// worktree, tracker ticket) at scan time. Add the columns and force a full
|
|
167
|
+
// rescan so every existing session gets them populated.
|
|
168
|
+
const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
|
|
169
|
+
if (!cols.some(c => c.name === 'pr_url'))
|
|
170
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN pr_url TEXT`);
|
|
171
|
+
if (!cols.some(c => c.name === 'pr_number'))
|
|
172
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN pr_number INTEGER`);
|
|
173
|
+
if (!cols.some(c => c.name === 'worktree_slug'))
|
|
174
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN worktree_slug TEXT`);
|
|
175
|
+
if (!cols.some(c => c.name === 'ticket_id'))
|
|
176
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN ticket_id TEXT`);
|
|
177
|
+
db.exec(`DELETE FROM scan_ledger;`);
|
|
178
|
+
}
|
|
160
179
|
}
|
|
161
180
|
/** Open (or return the cached) sessions database, applying migrations as needed. */
|
|
162
181
|
export function getDB() {
|
|
@@ -290,9 +309,9 @@ export function getDBPath() {
|
|
|
290
309
|
export function getScanStampByPath(filePath) {
|
|
291
310
|
const db = getDB();
|
|
292
311
|
const row = db
|
|
293
|
-
.prepare(`SELECT file_mtime_ms, file_size FROM scan_ledger WHERE file_path = ? LIMIT 1`)
|
|
312
|
+
.prepare(`SELECT file_mtime_ms, file_size, scanned_at FROM scan_ledger WHERE file_path = ? LIMIT 1`)
|
|
294
313
|
.get(canonicalLedgerKey(filePath));
|
|
295
|
-
return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size } : null;
|
|
314
|
+
return row ? { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at } : null;
|
|
296
315
|
}
|
|
297
316
|
/**
|
|
298
317
|
* Bulk-load the stamp ledger for a set of file paths in a single SQL query.
|
|
@@ -324,13 +343,13 @@ export function getScanStampsForPaths(filePaths) {
|
|
|
324
343
|
const placeholders = chunk.map(() => '?').join(',');
|
|
325
344
|
const rows = db
|
|
326
345
|
.prepare(`
|
|
327
|
-
SELECT file_path, file_mtime_ms, file_size
|
|
346
|
+
SELECT file_path, file_mtime_ms, file_size, scanned_at
|
|
328
347
|
FROM scan_ledger
|
|
329
348
|
WHERE file_path IN (${placeholders})
|
|
330
349
|
`)
|
|
331
350
|
.all(...chunk);
|
|
332
351
|
for (const row of rows) {
|
|
333
|
-
const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size };
|
|
352
|
+
const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at };
|
|
334
353
|
for (const original of canonicalToOriginals.get(row.file_path) || []) {
|
|
335
354
|
result.set(original, stamp);
|
|
336
355
|
}
|
|
@@ -367,12 +386,14 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
367
386
|
id, short_id, agent, version, account, timestamp,
|
|
368
387
|
project, cwd, git_branch, topic, label, message_count, token_count,
|
|
369
388
|
cost_usd, duration_ms,
|
|
370
|
-
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin
|
|
389
|
+
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
390
|
+
pr_url, pr_number, worktree_slug, ticket_id
|
|
371
391
|
) VALUES (
|
|
372
392
|
@id, @short_id, @agent, @version, @account, @timestamp,
|
|
373
393
|
@project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
|
|
374
394
|
@cost_usd, @duration_ms,
|
|
375
|
-
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin
|
|
395
|
+
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
396
|
+
@pr_url, @pr_number, @worktree_slug, @ticket_id
|
|
376
397
|
)
|
|
377
398
|
ON CONFLICT(id) DO UPDATE SET
|
|
378
399
|
short_id = excluded.short_id,
|
|
@@ -393,7 +414,11 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
393
414
|
file_mtime_ms = excluded.file_mtime_ms,
|
|
394
415
|
file_size = excluded.file_size,
|
|
395
416
|
scanned_at = excluded.scanned_at,
|
|
396
|
-
is_team_origin = excluded.is_team_origin
|
|
417
|
+
is_team_origin = excluded.is_team_origin,
|
|
418
|
+
pr_url = excluded.pr_url,
|
|
419
|
+
pr_number = excluded.pr_number,
|
|
420
|
+
worktree_slug = excluded.worktree_slug,
|
|
421
|
+
ticket_id = excluded.ticket_id
|
|
397
422
|
`);
|
|
398
423
|
const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
|
|
399
424
|
const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
|
|
@@ -436,6 +461,10 @@ export function upsertSession(meta, content, scan) {
|
|
|
436
461
|
file_size: scan?.fileSize ?? null,
|
|
437
462
|
scanned_at: Date.now(),
|
|
438
463
|
is_team_origin: meta.isTeamOrigin ? 1 : 0,
|
|
464
|
+
pr_url: meta.prUrl ?? null,
|
|
465
|
+
pr_number: meta.prNumber ?? null,
|
|
466
|
+
worktree_slug: meta.worktreeSlug ?? null,
|
|
467
|
+
ticket_id: meta.ticketId ?? null,
|
|
439
468
|
};
|
|
440
469
|
const txn = db.transaction(() => {
|
|
441
470
|
upsert.run(row);
|
|
@@ -511,6 +540,10 @@ export function upsertSessionsBatch(entries) {
|
|
|
511
540
|
file_size: scan?.fileSize ?? null,
|
|
512
541
|
scanned_at: now,
|
|
513
542
|
is_team_origin: meta.isTeamOrigin ? 1 : 0,
|
|
543
|
+
pr_url: meta.prUrl ?? null,
|
|
544
|
+
pr_number: meta.prNumber ?? null,
|
|
545
|
+
worktree_slug: meta.worktreeSlug ?? null,
|
|
546
|
+
ticket_id: meta.ticketId ?? null,
|
|
514
547
|
});
|
|
515
548
|
delText.run(meta.id);
|
|
516
549
|
insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
|
|
@@ -621,8 +654,32 @@ function rowToMeta(row) {
|
|
|
621
654
|
topic: row.topic ?? undefined,
|
|
622
655
|
label: row.label ?? undefined,
|
|
623
656
|
isTeamOrigin: row.is_team_origin === 1,
|
|
657
|
+
prUrl: row.pr_url ?? undefined,
|
|
658
|
+
prNumber: row.pr_number ?? undefined,
|
|
659
|
+
worktreeSlug: row.worktree_slug ?? undefined,
|
|
660
|
+
ticketId: row.ticket_id ?? undefined,
|
|
624
661
|
};
|
|
625
662
|
}
|
|
663
|
+
/**
|
|
664
|
+
* Newest indexed session file for an agent working in `cwd`. Lets the live
|
|
665
|
+
* `--active` scanner locate a Codex transcript (whose files are date-partitioned,
|
|
666
|
+
* not cwd-keyed like Claude's) by reusing the index. Returns undefined if the
|
|
667
|
+
* session hasn't been scanned yet — the caller degrades to no live state.
|
|
668
|
+
*/
|
|
669
|
+
export function latestSessionFileForCwd(agent, cwd) {
|
|
670
|
+
if (!cwd)
|
|
671
|
+
return undefined;
|
|
672
|
+
let normalized = cwd;
|
|
673
|
+
try {
|
|
674
|
+
normalized = fs.realpathSync(cwd);
|
|
675
|
+
}
|
|
676
|
+
catch { /* use as-is */ }
|
|
677
|
+
const db = getDB();
|
|
678
|
+
const row = db
|
|
679
|
+
.prepare(`SELECT file_path FROM sessions WHERE agent = ? AND cwd = ? ORDER BY timestamp DESC LIMIT 1`)
|
|
680
|
+
.get(agent, normalized);
|
|
681
|
+
return row?.file_path;
|
|
682
|
+
}
|
|
626
683
|
/** Build a parameterized WHERE clause from query options. */
|
|
627
684
|
function buildSessionWhere(options) {
|
|
628
685
|
const where = [];
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* subsequent queries are served entirely from the cache.
|
|
8
8
|
*/
|
|
9
9
|
import type { SessionAgentId, SessionMeta } from './types.js';
|
|
10
|
+
import { type ScanStamp } from './db.js';
|
|
10
11
|
/** Options controlling which sessions to discover and how to report progress. */
|
|
11
12
|
export interface DiscoverOptions {
|
|
12
13
|
agent?: SessionAgentId;
|
|
@@ -56,6 +57,11 @@ interface ClaudeSessionScan {
|
|
|
56
57
|
entrypoint?: string;
|
|
57
58
|
/** Concatenated user message text, ready to hand to FTS5. */
|
|
58
59
|
contentText?: string;
|
|
60
|
+
/** Durable state signals persisted to the index by the session-state engine. */
|
|
61
|
+
prUrl?: string;
|
|
62
|
+
prNumber?: number;
|
|
63
|
+
worktreeSlug?: string;
|
|
64
|
+
ticketId?: string;
|
|
59
65
|
}
|
|
60
66
|
/**
|
|
61
67
|
* Discover sessions. Scans only files whose (mtime, size) have changed since
|
|
@@ -85,6 +91,7 @@ export declare function resolveSessionById(sessions: SessionMeta[], idQuery: str
|
|
|
85
91
|
* preserving the existing SessionMeta[] contract so sessions.ts is unchanged.
|
|
86
92
|
*/
|
|
87
93
|
export declare function searchContentIndex(sessions: SessionMeta[], query: string): Map<string, SessionMeta>;
|
|
94
|
+
export declare function shouldDeferRecentAppend(prev: ScanStamp, current: ScanStamp, nowMs: number, debounceMs?: number): boolean;
|
|
88
95
|
/**
|
|
89
96
|
* Collect all directories to scan for an agent's sessions. Deduplicates by
|
|
90
97
|
* realpath to avoid double-counting symlinked version homes.
|