@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.
@@ -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
- * Union of all four sources. Teams and terminals spawn actual CLI processes
159
- * that also show up in `ps`, so headless attribution runs last with the
160
- * already-attributed PIDs removed.
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
- const projectDir = path.join(HOME, '.claude', 'projects', claudeProjectDirName(cwd));
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
- if (fs.existsSync(specific))
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 'running';
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
- const sessionFile = findSessionFileForKind(t.kind, t.cwd ?? undefined, t.sessionId);
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
- * Union of all four sources. Teams and terminals spawn actual CLI processes
707
- * that also show up in `ps`, so headless attribution runs last with the
708
- * already-attributed PIDs removed.
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
  }
@@ -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
- upsert.run({
542
- id: meta.id,
543
- short_id: meta.shortId,
544
- agent: meta.agent,
545
- version: meta.version ?? null,
546
- account: meta.account ?? null,
547
- timestamp: meta.timestamp,
548
- last_activity: resolveLastActivity(meta, scan),
549
- project: meta.project ?? null,
550
- cwd: meta.cwd ?? null,
551
- git_branch: meta.gitBranch ?? null,
552
- topic: meta.topic ?? null,
553
- label: meta.label ?? null,
554
- message_count: meta.messageCount ?? null,
555
- token_count: meta.tokenCount ?? null,
556
- cost_usd: meta.costUsd ?? null,
557
- duration_ms: meta.durationMs ?? null,
558
- file_path: meta.filePath,
559
- file_mtime_ms: scan?.fileMtimeMs ?? null,
560
- file_size: scan?.fileSize ?? null,
561
- scanned_at: now,
562
- is_team_origin: meta.isTeamOrigin ? 1 : 0,
563
- pr_url: meta.prUrl ?? null,
564
- pr_number: meta.prNumber ?? null,
565
- worktree_slug: meta.worktreeSlug ?? null,
566
- ticket_id: meta.ticketId ?? null,
567
- });
568
- delText.run(meta.id);
569
- insText.run(meta.id, meta.label ?? '', meta.topic ?? '', meta.project ?? '', content ?? '');
570
- if (scan && meta.filePath) {
571
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now);
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
- const timestamp = updatedAt || createdAt;
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
+ }
@@ -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 packages/computer-helper-win/dist. */
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
@@ -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 packages/computer-helper-win/dist. */
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 (src/lib -> repo root).
99
- path.resolve(here, '..', '..', 'packages', 'computer-helper-win', 'dist', WIN_HELPER_EXE),
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
  ];