@phnx-labs/agents-cli 1.20.41 → 1.20.42
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 +7 -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 +2 -0
- package/dist/commands/go.js +6 -1
- package/dist/commands/secrets.js +26 -0
- package/dist/commands/sessions.js +30 -12
- package/dist/lib/computer-rpc.js +3 -3
- package/dist/lib/exec.d.ts +52 -0
- package/dist/lib/exec.js +150 -0
- package/dist/lib/menubar/install-menubar.js +2 -2
- 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 +47 -3
- package/dist/lib/session/active.js +132 -10
- package/dist/lib/session/db.js +45 -31
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +9 -2
- 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/package.json +1 -1
|
@@ -84,11 +84,35 @@ export interface ActiveSession {
|
|
|
84
84
|
* (after the --json/--waiting gates) — NOT emitted on the discovery path.
|
|
85
85
|
*/
|
|
86
86
|
tmuxTarget?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Which host app + tab a tmux-hosted session is currently being VIEWED in,
|
|
89
|
+
* resolved from the attached tmux client (its terminal PID -> app via
|
|
90
|
+
* HOST_MATCHERS, its tab via the per-app resolver). `undefined` means no
|
|
91
|
+
* client is attached — the session is running detached. Transient,
|
|
92
|
+
* renderer-set (see src/lib/session/viewing-in.ts) — NOT on the discovery path.
|
|
93
|
+
*/
|
|
94
|
+
viewingIn?: {
|
|
95
|
+
app: string;
|
|
96
|
+
tab?: number;
|
|
97
|
+
};
|
|
87
98
|
}
|
|
88
99
|
export interface ActiveQueryOptions {
|
|
89
100
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
90
101
|
skipHeadless?: boolean;
|
|
91
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Pick a Claude transcript file within a project dir.
|
|
105
|
+
*
|
|
106
|
+
* With a CONCRETE session id: return that id's `<id>.jsonl` or undefined — NEVER a
|
|
107
|
+
* sibling's. Falling back to the newest file here is the bug that made N distinct
|
|
108
|
+
* co-located sessions (e.g. several editor tabs in one cwd, or two worktree siblings)
|
|
109
|
+
* all collapse onto ONE file and render identical preview + topic (they look like
|
|
110
|
+
* duplicate cards). The mtime fallback is only sound when NO id is known.
|
|
111
|
+
*
|
|
112
|
+
* With NO id: return the newest `.jsonl` by mtime (the legitimate single-session
|
|
113
|
+
* heuristic for a directly-launched agent with no registry entry).
|
|
114
|
+
*/
|
|
115
|
+
export declare function pickSessionFile(projectDir: string, sessionId?: string): string | undefined;
|
|
92
116
|
/**
|
|
93
117
|
* Locate the live transcript for an agent process. Claude files are keyed by
|
|
94
118
|
* cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
|
|
@@ -117,6 +141,14 @@ export declare function parseWin32ProcessCsv(out: string): ProcRow[];
|
|
|
117
141
|
* for testing the bound; production always uses the real `lsof`-backed probe.
|
|
118
142
|
*/
|
|
119
143
|
export declare function resolveCwds(pids: number[], probe?: (pid: number) => Promise<string | undefined>): Promise<(string | undefined)[]>;
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the host app for a single pid by walking its process ancestry with the
|
|
146
|
+
* same HOST_MATCHERS logic `detectHost` uses. Reads the whole process table per
|
|
147
|
+
* call, so it's for the low-cardinality renderer path (one tmux client per
|
|
148
|
+
* session), not a hot loop. Returns undefined when nothing above the pid is a
|
|
149
|
+
* recognised UI. Exported for the "viewing in <app>" resolver.
|
|
150
|
+
*/
|
|
151
|
+
export declare function hostFromPid(pid: number): Promise<string | undefined>;
|
|
120
152
|
export interface AgentCandidate {
|
|
121
153
|
pid: number;
|
|
122
154
|
kind: string;
|
|
@@ -155,9 +187,21 @@ export declare function foldSubordinateAgents(candidates: AgentCandidate[], ppid
|
|
|
155
187
|
*/
|
|
156
188
|
export declare function listUnattributedActive(attributed: Set<number>): Promise<ActiveSession[]>;
|
|
157
189
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
190
|
+
* Agents hosted in the shared-socket tmux server — the authoritative source for
|
|
191
|
+
* tmux-wrapped interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
|
|
192
|
+
* every pane on the shared socket and keeps those whose session meta was stamped
|
|
193
|
+
* with `labels.agent` + `labels.sessionId` by the spawn-wrap. Because tmux (not a
|
|
194
|
+
* per-window `live-terminals.json`) is the source of truth, a tmux-hosted agent is
|
|
195
|
+
* ALWAYS captured with its exact `%pane` even when the extension registry is stale
|
|
196
|
+
* or absent. `source: 'teams'` is skipped — teammates are surfaced by listTeamsActive.
|
|
197
|
+
*/
|
|
198
|
+
export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
|
|
199
|
+
/**
|
|
200
|
+
* Union of all sources. Teams and terminals spawn actual CLI processes that
|
|
201
|
+
* also show up in `ps`, so headless attribution runs last with the already-
|
|
202
|
+
* attributed PIDs removed. The tmux source goes FIRST into the dedupe so a
|
|
203
|
+
* tmux-hosted agent's row (which carries the exact `%pane`) wins over a staler
|
|
204
|
+
* terminal/headless row for the same session id.
|
|
161
205
|
*/
|
|
162
206
|
export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
|
|
163
207
|
export {};
|
|
@@ -120,11 +120,24 @@ function claudeProjectDirName(cwd) {
|
|
|
120
120
|
* fall back to the most-recent-mtime .jsonl in the project's folder.
|
|
121
121
|
*/
|
|
122
122
|
function findClaudeSessionFile(cwd, sessionId) {
|
|
123
|
-
|
|
123
|
+
return pickSessionFile(path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd)), sessionId);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Pick a Claude transcript file within a project dir.
|
|
127
|
+
*
|
|
128
|
+
* With a CONCRETE session id: return that id's `<id>.jsonl` or undefined — NEVER a
|
|
129
|
+
* sibling's. Falling back to the newest file here is the bug that made N distinct
|
|
130
|
+
* co-located sessions (e.g. several editor tabs in one cwd, or two worktree siblings)
|
|
131
|
+
* all collapse onto ONE file and render identical preview + topic (they look like
|
|
132
|
+
* duplicate cards). The mtime fallback is only sound when NO id is known.
|
|
133
|
+
*
|
|
134
|
+
* With NO id: return the newest `.jsonl` by mtime (the legitimate single-session
|
|
135
|
+
* heuristic for a directly-launched agent with no registry entry).
|
|
136
|
+
*/
|
|
137
|
+
export function pickSessionFile(projectDir, sessionId) {
|
|
124
138
|
if (sessionId) {
|
|
125
139
|
const specific = path.join(projectDir, `${sessionId}.jsonl`);
|
|
126
|
-
|
|
127
|
-
return specific;
|
|
140
|
+
return fs.existsSync(specific) ? specific : undefined;
|
|
128
141
|
}
|
|
129
142
|
let files;
|
|
130
143
|
try {
|
|
@@ -146,8 +159,12 @@ function findClaudeSessionFile(cwd, sessionId) {
|
|
|
146
159
|
return best?.path;
|
|
147
160
|
}
|
|
148
161
|
function classifyActivity(sessionFile) {
|
|
162
|
+
// No resolvable transcript is NOT evidence of activity — default to idle. (Before
|
|
163
|
+
// the no-borrow fix in pickSessionFile this rarely fired because every terminal
|
|
164
|
+
// borrowed the newest file; now a session with an unresolved id lands here, and
|
|
165
|
+
// "running" would wrongly light it up.)
|
|
149
166
|
if (!sessionFile)
|
|
150
|
-
return '
|
|
167
|
+
return 'idle';
|
|
151
168
|
try {
|
|
152
169
|
const mtimeMs = fs.statSync(sessionFile).mtimeMs;
|
|
153
170
|
return Date.now() - mtimeMs < ACTIVE_MTIME_WINDOW_MS ? 'running' : 'idle';
|
|
@@ -326,7 +343,17 @@ export async function listTerminalsActive() {
|
|
|
326
343
|
// Build label map from Claude's sessions/*.json for /rename support
|
|
327
344
|
const labelMap = buildClaudeLabelMap();
|
|
328
345
|
return entries.map((t) => {
|
|
329
|
-
|
|
346
|
+
// The id cached in live-terminals.json goes stale when Claude rotates its
|
|
347
|
+
// transcript uuid on resume/compact, so it often no longer matches any
|
|
348
|
+
// <id>.jsonl. When the pid registry knows this pid's current id, prefer it —
|
|
349
|
+
// the same source the headless path uses. NOTE: live-terminals.json stores the
|
|
350
|
+
// SHELL pid, while the by-pid registry is keyed by the AGENT pid, so for
|
|
351
|
+
// editor-launched terminals this lookup returns undefined today and we fall
|
|
352
|
+
// back to the stale cached id — the duplicate-card fix comes from
|
|
353
|
+
// pickSessionFile no longer borrowing a sibling, not from this lookup. Kept as
|
|
354
|
+
// a forward-looking hook for the cases where the pid does line up.
|
|
355
|
+
const resolvedId = readPidSessionEntry(t.pid)?.sessionId ?? t.sessionId;
|
|
356
|
+
const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, resolvedId);
|
|
330
357
|
// Prefer label from live terminal, fall back to Claude's session label
|
|
331
358
|
const label = t.label ?? (t.sessionId ? labelMap.get(t.sessionId) : undefined) ?? undefined;
|
|
332
359
|
// Extract topic from session file (first meaningful user message)
|
|
@@ -539,6 +566,21 @@ function detectHost(pid, procByPid) {
|
|
|
539
566
|
}
|
|
540
567
|
return undefined;
|
|
541
568
|
}
|
|
569
|
+
/**
|
|
570
|
+
* Resolve the host app for a single pid by walking its process ancestry with the
|
|
571
|
+
* same HOST_MATCHERS logic `detectHost` uses. Reads the whole process table per
|
|
572
|
+
* call, so it's for the low-cardinality renderer path (one tmux client per
|
|
573
|
+
* session), not a hot loop. Returns undefined when nothing above the pid is a
|
|
574
|
+
* recognised UI. Exported for the "viewing in <app>" resolver.
|
|
575
|
+
*/
|
|
576
|
+
export async function hostFromPid(pid) {
|
|
577
|
+
if (!pid || pid < 1)
|
|
578
|
+
return undefined;
|
|
579
|
+
const procByPid = new Map();
|
|
580
|
+
for (const r of await readProcessTable())
|
|
581
|
+
procByPid.set(r.pid, r);
|
|
582
|
+
return detectHost(pid, procByPid);
|
|
583
|
+
}
|
|
542
584
|
/** IDE / terminal / multiplexer hosts all count as UI-hosted. Absence = truly headless. */
|
|
543
585
|
const UI_HOSTS = new Set([
|
|
544
586
|
'code', 'cursor', 'codium', 'windsurf',
|
|
@@ -703,17 +745,97 @@ export async function listUnattributedActive(attributed) {
|
|
|
703
745
|
return out;
|
|
704
746
|
}
|
|
705
747
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
748
|
+
* Agents hosted in the shared-socket tmux server — the authoritative source for
|
|
749
|
+
* tmux-wrapped interactive spawns (see src/lib/exec.ts `runInTmux`). Enumerates
|
|
750
|
+
* every pane on the shared socket and keeps those whose session meta was stamped
|
|
751
|
+
* with `labels.agent` + `labels.sessionId` by the spawn-wrap. Because tmux (not a
|
|
752
|
+
* per-window `live-terminals.json`) is the source of truth, a tmux-hosted agent is
|
|
753
|
+
* ALWAYS captured with its exact `%pane` even when the extension registry is stale
|
|
754
|
+
* or absent. `source: 'teams'` is skipped — teammates are surfaced by listTeamsActive.
|
|
755
|
+
*/
|
|
756
|
+
export async function listTmuxAgentSessions() {
|
|
757
|
+
const { getDefaultSocketPath } = await import('../tmux/paths.js');
|
|
758
|
+
const { readSessionMeta } = await import('../tmux/session.js');
|
|
759
|
+
const { runTmux } = await import('../tmux/binary.js');
|
|
760
|
+
const socket = getDefaultSocketPath();
|
|
761
|
+
if (!fs.existsSync(socket))
|
|
762
|
+
return [];
|
|
763
|
+
let res;
|
|
764
|
+
try {
|
|
765
|
+
res = await runTmux({
|
|
766
|
+
socket,
|
|
767
|
+
args: ['list-panes', '-a', '-F', '#{pane_id}\t#{session_name}\t#{pane_pid}\t#{pane_current_path}'],
|
|
768
|
+
throwOnError: false,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
return [];
|
|
773
|
+
}
|
|
774
|
+
if (res.code !== 0)
|
|
775
|
+
return [];
|
|
776
|
+
const out = [];
|
|
777
|
+
const seen = new Set();
|
|
778
|
+
for (const line of res.stdout.split('\n')) {
|
|
779
|
+
if (!line.trim())
|
|
780
|
+
continue;
|
|
781
|
+
const [pane, sessName, pidRaw, curPath] = line.split('\t');
|
|
782
|
+
if (!pane || !sessName)
|
|
783
|
+
continue;
|
|
784
|
+
const meta = readSessionMeta(sessName);
|
|
785
|
+
const agent = meta?.labels?.agent;
|
|
786
|
+
const sessionId = meta?.labels?.sessionId;
|
|
787
|
+
if (!agent || !sessionId)
|
|
788
|
+
continue; // only our stamped agent sessions
|
|
789
|
+
if (meta?.source === 'teams')
|
|
790
|
+
continue; // teammates come from listTeamsActive
|
|
791
|
+
if (seen.has(sessionId))
|
|
792
|
+
continue; // first pane per session wins
|
|
793
|
+
seen.add(sessionId);
|
|
794
|
+
const pid = parseInt(pidRaw, 10) || undefined;
|
|
795
|
+
const cwd = meta?.cwd ?? (curPath || undefined);
|
|
796
|
+
const sessionFile = findSessionFileForKind(agent, cwd, sessionId);
|
|
797
|
+
const topic = sessionFile ? quickExtractTopic(sessionFile) : undefined;
|
|
798
|
+
const state = computeLiveState(agent, sessionFile, cwd, pid ? isPidAlive(pid) : true);
|
|
799
|
+
// Provenance is known exactly here (the pane IS a tmux pane) — set it so
|
|
800
|
+
// enrichProvenance skips it and the locator/reply rails resolve off the pane.
|
|
801
|
+
const provenance = {
|
|
802
|
+
host: os.hostname(),
|
|
803
|
+
transport: 'local',
|
|
804
|
+
mux: { kind: 'tmux', socket, pane },
|
|
805
|
+
reply: { rail: 'tmux', target: pane, socket },
|
|
806
|
+
};
|
|
807
|
+
out.push(applyState({
|
|
808
|
+
context: 'terminal',
|
|
809
|
+
kind: agent,
|
|
810
|
+
host: 'tmux',
|
|
811
|
+
pid,
|
|
812
|
+
sessionId,
|
|
813
|
+
cwd,
|
|
814
|
+
topic,
|
|
815
|
+
sessionFile,
|
|
816
|
+
provenance,
|
|
817
|
+
}, state, sessionFile));
|
|
818
|
+
}
|
|
819
|
+
return out;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Union of all sources. Teams and terminals spawn actual CLI processes that
|
|
823
|
+
* also show up in `ps`, so headless attribution runs last with the already-
|
|
824
|
+
* attributed PIDs removed. The tmux source goes FIRST into the dedupe so a
|
|
825
|
+
* tmux-hosted agent's row (which carries the exact `%pane`) wins over a staler
|
|
826
|
+
* terminal/headless row for the same session id.
|
|
709
827
|
*/
|
|
710
828
|
export async function getActiveSessions(opts = {}) {
|
|
711
|
-
const [teams, terminals, cloud] = await Promise.all([
|
|
829
|
+
const [tmuxAgents, teams, terminals, cloud] = await Promise.all([
|
|
830
|
+
listTmuxAgentSessions().catch(() => []),
|
|
712
831
|
listTeamsActive().catch(() => []),
|
|
713
832
|
listTerminalsActive().catch(() => []),
|
|
714
833
|
Promise.resolve(listCloudActive()),
|
|
715
834
|
]);
|
|
716
835
|
const knownPids = new Set();
|
|
836
|
+
for (const s of tmuxAgents)
|
|
837
|
+
if (s.pid)
|
|
838
|
+
knownPids.add(s.pid);
|
|
717
839
|
for (const s of teams)
|
|
718
840
|
if (s.pid)
|
|
719
841
|
knownPids.add(s.pid);
|
|
@@ -721,7 +843,7 @@ export async function getActiveSessions(opts = {}) {
|
|
|
721
843
|
if (s.pid)
|
|
722
844
|
knownPids.add(s.pid);
|
|
723
845
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
724
|
-
const merged = dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
|
|
846
|
+
const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
|
|
725
847
|
await enrichProvenance(merged);
|
|
726
848
|
return merged;
|
|
727
849
|
}
|
package/dist/lib/session/db.js
CHANGED
|
@@ -538,37 +538,51 @@ export function upsertSessionsBatch(entries) {
|
|
|
538
538
|
for (const { meta, content, scan } of items) {
|
|
539
539
|
if (alreadyIndexed.has(meta.id))
|
|
540
540
|
continue;
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
541
|
+
// Per-row guard: one malformed session (e.g. a required field that resolves to
|
|
542
|
+
// NULL) must not abort the whole batch and take down the entire `agents sessions`
|
|
543
|
+
// listing. A constraint error uses SQLite's ABORT resolution — it reverts only the
|
|
544
|
+
// failing statement, not the transaction — and the db.transaction wrapper only rolls
|
|
545
|
+
// back when the error escapes `fn`, so catching + skipping here leaves the txn valid
|
|
546
|
+
// and committable. We deliberately do NOT stamp the ledger for a skipped row, so the
|
|
547
|
+
// next scan re-tries it (self-healing once the underlying parser is fixed).
|
|
548
|
+
try {
|
|
549
|
+
upsert.run({
|
|
550
|
+
id: meta.id,
|
|
551
|
+
short_id: meta.shortId,
|
|
552
|
+
agent: meta.agent,
|
|
553
|
+
version: meta.version ?? null,
|
|
554
|
+
account: meta.account ?? null,
|
|
555
|
+
timestamp: meta.timestamp,
|
|
556
|
+
last_activity: resolveLastActivity(meta, scan),
|
|
557
|
+
project: meta.project ?? null,
|
|
558
|
+
cwd: meta.cwd ?? null,
|
|
559
|
+
git_branch: meta.gitBranch ?? null,
|
|
560
|
+
topic: meta.topic ?? null,
|
|
561
|
+
label: meta.label ?? null,
|
|
562
|
+
message_count: meta.messageCount ?? null,
|
|
563
|
+
token_count: meta.tokenCount ?? null,
|
|
564
|
+
cost_usd: meta.costUsd ?? null,
|
|
565
|
+
duration_ms: meta.durationMs ?? null,
|
|
566
|
+
file_path: meta.filePath,
|
|
567
|
+
file_mtime_ms: scan?.fileMtimeMs ?? null,
|
|
568
|
+
file_size: scan?.fileSize ?? null,
|
|
569
|
+
scanned_at: now,
|
|
570
|
+
is_team_origin: meta.isTeamOrigin ? 1 : 0,
|
|
571
|
+
pr_url: meta.prUrl ?? null,
|
|
572
|
+
pr_number: meta.prNumber ?? null,
|
|
573
|
+
worktree_slug: meta.worktreeSlug ?? null,
|
|
574
|
+
ticket_id: meta.ticketId ?? null,
|
|
575
|
+
});
|
|
576
|
+
delText.run(meta.id);
|
|
577
|
+
insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
|
|
578
|
+
if (scan && meta.filePath) {
|
|
579
|
+
ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
if (process.stderr.isTTY) {
|
|
584
|
+
console.error(`Warning: skipped unindexable session ${meta.id}: ${err.message}`);
|
|
585
|
+
}
|
|
572
586
|
}
|
|
573
587
|
}
|
|
574
588
|
});
|
|
@@ -150,6 +150,11 @@ export declare function readCodexMeta(filePath: string, resolveAccount?: () => s
|
|
|
150
150
|
export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
|
|
151
151
|
/** Read up to maxLines non-empty lines from the beginning of a file. */
|
|
152
152
|
export declare function readFirstLines(filePath: string, maxLines: number): Promise<string[]>;
|
|
153
|
+
/** Parse a single Kimi session state.json file to extract session metadata. */
|
|
154
|
+
export declare function readKimiMeta(filePath: string): {
|
|
155
|
+
meta: SessionMeta;
|
|
156
|
+
content: string;
|
|
157
|
+
} | null;
|
|
153
158
|
/** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
|
|
154
159
|
export declare function parseTimeFilter(input: string): number;
|
|
155
160
|
export {};
|
|
@@ -2345,7 +2345,7 @@ async function scanKimiIncremental(onProgress) {
|
|
|
2345
2345
|
recordScans(touched);
|
|
2346
2346
|
}
|
|
2347
2347
|
/** Parse a single Kimi session state.json file to extract session metadata. */
|
|
2348
|
-
function readKimiMeta(filePath) {
|
|
2348
|
+
export function readKimiMeta(filePath) {
|
|
2349
2349
|
let state;
|
|
2350
2350
|
try {
|
|
2351
2351
|
state = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
@@ -2362,7 +2362,14 @@ function readKimiMeta(filePath) {
|
|
|
2362
2362
|
const topic = title || lastPrompt || undefined;
|
|
2363
2363
|
const createdAt = typeof state.createdAt === 'string' ? state.createdAt : undefined;
|
|
2364
2364
|
const updatedAt = typeof state.updatedAt === 'string' ? state.updatedAt : undefined;
|
|
2365
|
-
|
|
2365
|
+
// Coerce to never-null, the same way every other parser does (Rush/Hermes/Droid/…):
|
|
2366
|
+
// a real createdAt/updatedAt still wins; otherwise fall back to the state.json mtime.
|
|
2367
|
+
// Kimi was the lone parser that could yield `undefined`, which binds NULL into
|
|
2368
|
+
// `timestamp TEXT NOT NULL` and aborts the whole batch index. mtime also matches how
|
|
2369
|
+
// the listing already ranks Kimi (last_activity resolves to the file mtime).
|
|
2370
|
+
const stat = safeStatSync(filePath);
|
|
2371
|
+
const timestamp = updatedAt || createdAt
|
|
2372
|
+
|| (stat ? stat.mtime.toISOString() : new Date().toISOString());
|
|
2366
2373
|
const shortId = sessionId.replace(/^session_/, '').slice(0, 8);
|
|
2367
2374
|
// Try to infer project from session directory path
|
|
2368
2375
|
// ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/
|
|
@@ -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
|
];
|