hop2 0.11.68 → 0.11.70

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/hop CHANGED
@@ -21,6 +21,7 @@ const { AuthSessionStore, isSessionToken: isAuthSessionToken } = require('./lib/
21
21
  const { agentPhase, screenSaysWorking } = require('./lib/agent-phase');
22
22
  const { snapshotFromMetas, planRefiles, pruneList, snapshotFileName } = require('./lib/identity-snapshot');
23
23
  const { withCodexIdentity } = require('./lib/codex-identity');
24
+ const { resolveCallerSession, sessionFromEnvLine } = require('./lib/session-identity');
24
25
  const checkbacks = require('./lib/checkbacks');
25
26
  const digestReads = require('./lib/digest-reads');
26
27
  const { rotateAuditFile, sweepStaleAuditDirs } = require('./lib/audit-rotation');
@@ -5101,6 +5102,7 @@ if (DAEMON_FLAG && TERMINAL_API_IDLE_TTL_MS > 0) {
5101
5102
  }
5102
5103
 
5103
5104
  function closeTerminalApiEntry(terminalId) {
5105
+ const entry = terminalApiSessions.get(terminalId);
5104
5106
  terminalApiSessions.delete(terminalId);
5105
5107
  const streams = terminalApiStreams.get(terminalId);
5106
5108
  if (streams) {
@@ -5110,6 +5112,12 @@ function closeTerminalApiEntry(terminalId) {
5110
5112
  terminalApiStreams.delete(terminalId);
5111
5113
  }
5112
5114
  clearTerminalEventBuffer(terminalId);
5115
+ // Detach the room client too. This only dropped the daemon's record and
5116
+ // left the socket open, so every check-back delivery and reconcile
5117
+ // redrive left a ghost "checkback"/"reconcile" client on the session
5118
+ // (57 on one room) — idle presence chips across the top bar. The
5119
+ // adapter's close() calls back here with the entry already gone.
5120
+ if (entry?.adapter?.close) { try { entry.adapter.close(); } catch (e) { /* already closing */ } }
5113
5121
  }
5114
5122
 
5115
5123
  function handleTerminalApiServerMessage(terminalId, parsed) {
@@ -8101,6 +8109,56 @@ function isProcessAlive(pid) {
8101
8109
  }
8102
8110
  }
8103
8111
 
8112
+ // WHICH session this CLI process is in — by the process tree (its shell's
8113
+ // place under the host), not by the inherited HOP_SESSION alone; see
8114
+ // lib/session-identity.js. Cached: the tree does not change mid-command.
8115
+ let callerSessionMemo = null;
8116
+ function readProcForIdentity(pid) {
8117
+ try {
8118
+ if (process.platform === 'linux') {
8119
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
8120
+ const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
8121
+ const ppid = Number(after[1]) || 0;
8122
+ let command = '';
8123
+ try { command = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8').replace(/\0/g, ' ').trim(); } catch (e) { /* gone */ }
8124
+ let session = null;
8125
+ try { const env = fs.readFileSync(`/proc/${pid}/environ`).toString('utf8'); const m = /(?:^|\0)HOP_SESSION=([A-Za-z0-9_-]+)/.exec(env); session = m ? m[1] : null; } catch (e) { /* not ours */ }
8126
+ return { ppid, command, session };
8127
+ }
8128
+ const line = execFileSync('ps', ['-ww', '-o', 'ppid=,command=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim();
8129
+ if (!line) return null;
8130
+ const m = /^\s*(\d+)\s+(.*)$/s.exec(line);
8131
+ if (!m) return null;
8132
+ let session = null;
8133
+ try { session = sessionFromEnvLine(execFileSync('ps', ['-Eww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }), m[2]); } catch (e) { /* no env access */ }
8134
+ return { ppid: Number(m[1]) || 0, command: m[2], session };
8135
+ } catch (e) { return null; }
8136
+ }
8137
+ function callerSession() {
8138
+ if (callerSessionMemo) return callerSessionMemo;
8139
+ const host = readHayHostStateFile();
8140
+ callerSessionMemo = resolveCallerSession({
8141
+ pid: process.pid,
8142
+ hostPid: host && Number.isInteger(host.pid) ? host.pid : null,
8143
+ envSession: process.env.HOP_SESSION || null,
8144
+ readProc: readProcForIdentity
8145
+ });
8146
+ if (process.env.HOP_IDENTITY_DEBUG) {
8147
+ process.stderr.write(`[identity] host=${host?.pid} env=${process.env.HOP_SESSION || ''} → ${JSON.stringify({ ok: callerSessionMemo.ok, session: callerSessionMemo.session, via: callerSessionMemo.via, reason: callerSessionMemo.reason })}\n`);
8148
+ for (const c of callerSessionMemo.chain) process.stderr.write(`[identity] ${c.pid} ${c.command.slice(0, 90)}\n`);
8149
+ }
8150
+ return callerSessionMemo;
8151
+ }
8152
+ // The session a CLI command acts on when none was named: the verified one,
8153
+ // or nothing — with the reason printed — never an inherited guess.
8154
+ function callerSessionOrExplain(what) {
8155
+ const r = callerSession();
8156
+ if (r.ok) { if (r.note) log(r.note, DIM); return r.session; }
8157
+ log(r.note || 'Not inside a hop session.', YELLOW);
8158
+ log(`${what} needs an explicit target: ${CYAN}--session <name>${RESET}`, DIM);
8159
+ return null;
8160
+ }
8161
+
8104
8162
  function readHayHostStateFile() {
8105
8163
  if (!fs.existsSync(HAY_HOST_STATE_FILE)) return null;
8106
8164
  try {
@@ -16950,7 +17008,7 @@ async function main() {
16950
17008
  const args = process.argv.slice(3);
16951
17009
  const dryRun = args.includes('--dry-run') || args.includes('-n');
16952
17010
  const named = args.find((a) => !a.startsWith('-')) || null;
16953
- const here = process.env.HOP_SESSION || null;
17011
+ const here = callerSession().ok ? callerSession().session : null;
16954
17012
  if (!named && !here) {
16955
17013
  log(`Run ${CYAN}hop recover${RESET} inside a hop session, or name one: ${CYAN}hop recover <session>${RESET}`, YELLOW);
16956
17014
  process.exit(1);
@@ -17524,11 +17582,13 @@ async function main() {
17524
17582
  // INTERNAL name, invisible in any UI once the session is renamed, so
17525
17583
  // this resolves the display name humans actually see. --json for agents.
17526
17584
  if (process.argv[2] === 'whoami') {
17527
- const internal = process.env.HOP_SESSION || '';
17585
+ const who = callerSession();
17586
+ const internal = who.ok ? who.session : '';
17528
17587
  const asJson = process.argv.includes('--json');
17588
+ if (who.ok && who.note && !asJson) log(who.note, DIM);
17529
17589
  if (!internal) {
17530
- if (asJson) { console.log(JSON.stringify({ insideHopSession: false })); process.exit(1); }
17531
- log('Not inside a hop session (HOP_SESSION is unset).', YELLOW);
17590
+ if (asJson) { console.log(JSON.stringify({ insideHopSession: false, reason: who.reason || null, note: who.note || null, envSession: process.env.HOP_SESSION || null })); process.exit(1); }
17591
+ log(who.note || 'Not inside a hop session (HOP_SESSION is unset).', YELLOW);
17532
17592
  log(`Publishing from here needs an explicit target: ${CYAN}hop view --session <name> …${RESET}`, DIM);
17533
17593
  process.exit(1);
17534
17594
  }
@@ -17577,7 +17637,7 @@ async function main() {
17577
17637
  process.exit(0);
17578
17638
  }
17579
17639
  if (has('--cancel') || has('--cancel-all')) {
17580
- const body = has('--cancel') ? { id: flagValue('--cancel') } : { session: flagValue('--session') || process.env.HOP_SESSION || null };
17640
+ const body = has('--cancel') ? { id: flagValue('--cancel') } : { session: flagValue('--session') || callerSessionOrExplain('hop checkback --cancel-all') };
17581
17641
  if (has('--cancel-all') && !body.session) { log('--cancel-all needs --session <name> (or run it inside the session).', RED); process.exit(1); }
17582
17642
  const r = await callLocalApi(state, '/api/checkbacks/cancel', 'POST', body);
17583
17643
  if (!r?.data?.ok) { log(r?.data?.error || 'Cancel failed', RED); process.exit(1); }
@@ -17592,8 +17652,8 @@ async function main() {
17592
17652
  positional.push(argv[i]);
17593
17653
  }
17594
17654
  const message = positional.join(' ').trim();
17595
- const session = flagValue('--session') || process.env.HOP_SESSION || '';
17596
- if (!message || !session) { usage(); if (!session) log('No session: pass --session <name> or run inside a hop session.', RED); process.exit(1); }
17655
+ const session = flagValue('--session') || callerSessionOrExplain('hop checkback') || '';
17656
+ if (!message || !session) { usage(); process.exit(1); }
17597
17657
  const body = {
17598
17658
  session, message, force: has('--force'),
17599
17659
  in: flagValue('--in') || undefined, at: flagValue('--at') || undefined,
@@ -17616,7 +17676,7 @@ async function main() {
17616
17676
  }
17617
17677
  // The message rides the ASK record too, so the phone can say WHAT is
17618
17678
  // needed instead of a bare "wants you" (see readAsk / attentionReason).
17619
- const hs = process.env.HOP_SESSION;
17679
+ const hs = callerSession().ok ? callerSession().session : null;
17620
17680
  if (hs && /^[A-Za-z0-9_-]+$/.test(hs)) {
17621
17681
  try {
17622
17682
  const file = path.join(CLAUDE_SESSIONS_DIR, `${hs}.ask`);
@@ -17681,7 +17741,12 @@ async function main() {
17681
17741
  // So: explicit --session wins, the environment is the convenience
17682
17742
  // default, and having neither is an ERROR that says how to fix it —
17683
17743
  // never a quiet write into the void.
17684
- const session = wantSession || process.env.HOP_SESSION || '';
17744
+ // Explicit --session wins; otherwise the VERIFIED caller session (by
17745
+ // the process tree), and with neither this is an error that says why
17746
+ // — never a quiet write into another session's views.
17747
+ const session = wantSession || (callerSession().ok ? callerSession().session : '') || '';
17748
+ if (!wantSession && !session && !wantList) { callerSessionOrExplain('hop view'); process.exit(1); }
17749
+ if (!wantSession && callerSession().note && callerSession().ok) log(callerSession().note, DIM);
17685
17750
  const destDir = path.join(VIEWS_DIR, session);
17686
17751
 
17687
17752
  if (wantList) {
@@ -17804,7 +17869,7 @@ async function main() {
17804
17869
  // three requests past its purpose because nothing in the output
17805
17870
  // ever said "not the session you are in" — the human asked where
17806
17871
  // their files were, and they were in the other room.
17807
- const here = process.env.HOP_SESSION || '';
17872
+ const here = callerSession().ok ? callerSession().session : '';
17808
17873
  if (here && here === session) {
17809
17874
  log(`Session: ${session} (this terminal)`, DIM);
17810
17875
  } else if (here) {
@@ -17845,7 +17910,8 @@ async function main() {
17845
17910
  }
17846
17911
  const state = isServerRunning();
17847
17912
  if (!state) { printNotRunningError(); process.exit(1); }
17848
- const caller = process.env.HOP_SESSION || null;
17913
+ const caller = callerSession().ok ? callerSession().session : null;
17914
+ if (!caller && !flags.has('--standalone') && callerSession().reason === 'shared-daemon') log(callerSession().note, YELLOW);
17849
17915
  const standalone = flags.has('--standalone') || !caller;
17850
17916
  const label = positional[1] || `port-${port}`;
17851
17917
  const name = standalone ? label : `${caller}-${label}`;
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ // WHICH hop session is this process in? The honest answer comes from the
3
+ // process tree, not from an inherited variable.
4
+ //
5
+ // HOP_SESSION is set on each session's shell by the host, and children
6
+ // inherit it — which is right until something re-parents the work: Codex's
7
+ // shared app-server daemon ran every session's commands and MCP servers
8
+ // under the FIRST session's environment, so `hop view`, `hop checkback` and
9
+ // the MCP tools all defaulted to that one session. An arbitrary default is
10
+ // worse than none.
11
+ //
12
+ // So: walk the parents. The terminal host spawns one shell per session; the
13
+ // ancestor that is a direct child of the host IS the session's shell, and its
14
+ // HOP_SESSION is the truth. If the walk passes through a known multiplexer
15
+ // (Codex's app-server daemon or its code-mode host) the answer is ambiguous
16
+ // — the caller may belong to any session that daemon serves — and the
17
+ // verdict says so instead of guessing. With no host in the chain at all
18
+ // (a plain terminal, a cron), the environment is all there is, and the
19
+ // verdict says that too.
20
+
21
+ const MAX_HOPS = 24;
22
+ // A multiplexer is recognised by WHAT RUNS, the program and its first word,
23
+ // never by text anywhere in the arguments — a shell whose command line
24
+ // merely mentions "codex app-server" (a test, a grep) is not one.
25
+ const isMultiplexer = (command) => {
26
+ const words = String(command || '').trim().split(/\s+/);
27
+ const program = (words[0] || '').split('/').pop();
28
+ return program === 'codex-code-mode-host' || (program === 'codex' && words[1] === 'app-server');
29
+ };
30
+
31
+ /**
32
+ * @param {object} o
33
+ * @param {number} o.pid the calling process
34
+ * @param {number|null} o.hostPid the terminal host's pid (null = unknown)
35
+ * @param {string|null} o.envSession process.env.HOP_SESSION as inherited
36
+ * @param {(pid:number)=>{ppid:number, command:string, session?:string|null}|null} o.readProc
37
+ * one process: its parent, command line, and the HOP_SESSION in its environment
38
+ * @returns {{ ok: boolean, session: string|null, via: 'ancestry'|'env'|null, reason?: string, note?: string, chain: Array<{pid:number, command:string}> }}
39
+ */
40
+ function resolveCallerSession(o) {
41
+ const chain = [];
42
+ const env = o.envSession && /^[A-Za-z0-9_-]+$/.test(o.envSession) ? o.envSession : null;
43
+ let pid = Number(o.pid);
44
+ let viaDaemon = null;
45
+ let shellSession = null;
46
+ // The nearest ancestor whose environment could be read. The session
47
+ // shell itself is a login zsh whose environment macOS will not show
48
+ // (`ps -E` prints nothing for it); everything under it inherited the
49
+ // shell's HOP_SESSION, so the nearest readable one carries the truth —
50
+ // unless a multiplexer sits in between, which is caught separately.
51
+ let nearestSession = null;
52
+ for (let hops = 0; hops < MAX_HOPS && pid && pid > 1; hops++) {
53
+ const info = o.readProc(pid);
54
+ if (!info) break;
55
+ chain.push({ pid, command: String(info.command || '').slice(0, 120) });
56
+ if (isMultiplexer(info.command)) viaDaemon = viaDaemon || { pid, command: String(info.command || '').slice(0, 80) };
57
+ // The calling process's own variable is what we are checking, so it
58
+ // does not count as evidence; its ancestors' do.
59
+ if (hops > 0 && !nearestSession && info.session) nearestSession = info.session;
60
+ if (o.hostPid && info.ppid === o.hostPid) {
61
+ // This process is the session's own shell.
62
+ shellSession = info.session || nearestSession || null;
63
+ break;
64
+ }
65
+ if (!info.ppid || info.ppid === pid) break;
66
+ pid = info.ppid;
67
+ }
68
+ if (viaDaemon) {
69
+ return {
70
+ ok: false, session: null, via: null, chain,
71
+ reason: 'shared-daemon',
72
+ note: `This process runs under a shared agent daemon (${viaDaemon.command.trim()}), which serves several sessions — the inherited HOP_SESSION${env ? ` ("${env}")` : ''} may belong to another one. Pass the session explicitly.`
73
+ };
74
+ }
75
+ if (shellSession) {
76
+ const note = env && env !== shellSession
77
+ ? `HOP_SESSION says "${env}" but this process lives in session "${shellSession}" (by its shell); using the latter.`
78
+ : undefined;
79
+ return { ok: true, session: shellSession, via: 'ancestry', chain, note };
80
+ }
81
+ if (env) return { ok: true, session: env, via: 'env', chain, note: o.hostPid ? 'Not under a hop session shell; trusting HOP_SESSION as inherited.' : undefined };
82
+ return { ok: false, session: null, via: null, chain, reason: 'no-session', note: 'Not inside a hop session (no session shell above this process, and HOP_SESSION is unset).' };
83
+ }
84
+
85
+ /**
86
+ * Parse `HOP_SESSION=…` out of a `ps -E` line (macOS): the command line
87
+ * followed by the environment. Given the plain command line too, only the
88
+ * ENVIRONMENT part is read — a shell whose arguments contain
89
+ * "HOP_SESSION=x" (a test, a script) is not thereby in session x.
90
+ */
91
+ function sessionFromEnvLine(line, plainCommand) {
92
+ let text = String(line || '');
93
+ const plain = String(plainCommand || '').trim();
94
+ if (plain && text.startsWith(plain)) text = text.slice(plain.length);
95
+ const m = /(?:^|\s)HOP_SESSION=([A-Za-z0-9_-]+)/.exec(text);
96
+ return m ? m[1] : null;
97
+ }
98
+
99
+ module.exports = { resolveCallerSession, sessionFromEnvLine, isMultiplexer };
package/mcp/hop-mcp.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const fs = require('fs');
5
+ const { resolveCallerSession: resolveCallerSessionLib, sessionFromEnvLine: sessionFromEnvLineLib } = require(require('path').join(__dirname, '..', 'lib', 'session-identity'));
5
6
  const os = require('os');
6
7
  const path = require('path');
7
8
  const http = require('http');
@@ -2951,6 +2952,43 @@ class TerminalStreamManager {
2951
2952
  }
2952
2953
  }
2953
2954
 
2955
+ // WHICH session this MCP server serves — by the process tree, not the
2956
+ // inherited HOP_SESSION alone (lib/session-identity.js). Under Codex's
2957
+ // shared app-server daemon the inherited value belonged to the first
2958
+ // session; the verdict is then "ambiguous", and tools that default to the
2959
+ // caller's session ask for it explicitly instead of guessing.
2960
+ let callerSessionMemo = null;
2961
+ function readProcForIdentity(pid) {
2962
+ const fs = require('fs');
2963
+ const { execFileSync } = require('child_process');
2964
+ try {
2965
+ if (process.platform === 'linux') {
2966
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
2967
+ const after = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
2968
+ let command = ''; try { command = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8').replace(/\0/g, ' ').trim(); } catch { /* gone */ }
2969
+ let session = null; try { const env = fs.readFileSync(`/proc/${pid}/environ`).toString('utf8'); const mm = /(?:^|\0)HOP_SESSION=([A-Za-z0-9_-]+)/.exec(env); session = mm ? mm[1] : null; } catch { /* not ours */ }
2970
+ return { ppid: Number(after[1]) || 0, command, session };
2971
+ }
2972
+ const line = execFileSync('ps', ['-ww', '-o', 'ppid=,command=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }).trim();
2973
+ const mm = /^\s*(\d+)\s+(.*)$/s.exec(line);
2974
+ if (!mm) return null;
2975
+ let session = null;
2976
+ try { session = sessionFromEnvLineLib(execFileSync('ps', ['-Eww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 }), mm[2]); } catch { /* no env access */ }
2977
+ return { ppid: Number(mm[1]) || 0, command: mm[2], session };
2978
+ } catch { return null; }
2979
+ }
2980
+ function callerSession() {
2981
+ if (callerSessionMemo) return callerSessionMemo;
2982
+ let hostPid = null;
2983
+ try {
2984
+ const fs = require('fs'); const os = require('os'); const path = require('path');
2985
+ const st = JSON.parse(fs.readFileSync(path.join(process.env.HOP_HOME || path.join(os.homedir(), '.hop2'), '.hay-host-state'), 'utf8'));
2986
+ hostPid = Number.isInteger(st.pid) ? st.pid : null;
2987
+ } catch { /* no host state: env is all there is */ }
2988
+ callerSessionMemo = resolveCallerSessionLib({ pid: process.pid, hostPid, envSession: process.env.HOP_SESSION || null, readProc: readProcForIdentity });
2989
+ return callerSessionMemo;
2990
+ }
2991
+
2954
2992
  class HopMCPServer {
2955
2993
  constructor() {
2956
2994
  this.agentId = randomUUID();
@@ -3755,7 +3793,11 @@ class HopMCPServer {
3755
3793
  if (name === 'hop_current_session') {
3756
3794
  // Answerable WITHOUT a daemon: the env fact stands on its own, and an
3757
3795
  // agent asking "where am I" mid-outage still deserves the truth.
3758
- const internal = process.env.HOP_SESSION || null;
3796
+ const who = callerSession();
3797
+ const internal = who.ok ? who.session : null;
3798
+ if (!internal && who.reason === 'shared-daemon') {
3799
+ return this.wrapApiResult({ insideHopSession: false, ambiguous: true, reason: who.reason, note: who.note, envSession: process.env.HOP_SESSION || null }, { endpoint: 'local' });
3800
+ }
3759
3801
  const payload = {
3760
3802
  insideHopSession: !!internal,
3761
3803
  internalName: internal,
@@ -3785,8 +3827,9 @@ class HopMCPServer {
3785
3827
  return this.wrapApiResult(await this.callApi('GET', '/api/sessions'), { endpoint: '/api/sessions' });
3786
3828
  case 'hop_checkback': {
3787
3829
  const a = args || {};
3788
- const session = a.session || process.env.HOP_SESSION || null;
3789
- if (!session) return this.wrapApiResult({ error: 'No session: pass `session` (this MCP is not running inside a hop session).' }, { endpoint: '/api/checkbacks' });
3830
+ const who = callerSession();
3831
+ const session = a.session || (who.ok ? who.session : null);
3832
+ if (!session) return this.wrapApiResult({ error: `No session: pass \`session\`. ${who.note || ''}`.trim() }, { endpoint: '/api/checkbacks' });
3790
3833
  const body = {
3791
3834
  session, message: a.message, force: a.force === true,
3792
3835
  in: a.in, at: a.at, every: a.every, until: a.until,
@@ -3804,7 +3847,7 @@ class HopMCPServer {
3804
3847
  return this.wrapApiResult({ items }, { endpoint: '/api/checkbacks' });
3805
3848
  }
3806
3849
  case 'hop_cancel_checkback':
3807
- return this.wrapApiResult(await this.callApi('POST', '/api/checkbacks/cancel', { id: (args || {}).id, session: (args || {}).session || (!(args || {}).id ? process.env.HOP_SESSION : undefined) }), { endpoint: '/api/checkbacks/cancel' });
3850
+ return this.wrapApiResult(await this.callApi('POST', '/api/checkbacks/cancel', { id: (args || {}).id, session: (args || {}).session || (!(args || {}).id && callerSession().ok ? callerSession().session : undefined) }), { endpoint: '/api/checkbacks/cancel' });
3808
3851
  case 'hop_list_terminals': {
3809
3852
  const listed = await this.callApi('GET', '/api/terminals');
3810
3853
  if (!this.isApiFailurePayload(listed) && Array.isArray(listed.terminals)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.68",
3
+ "version": "0.11.70",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -0,0 +1,79 @@
1
+ // Which session a process is in: by its shell's place under the host, never
2
+ // by an inherited variable when a multiplexer sits in between.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { resolveCallerSession, sessionFromEnvLine, isMultiplexer } = require('../lib/session-identity');
6
+
7
+ const HOST = 1428;
8
+ const table = (rows) => (pid) => rows[pid] || null;
9
+
10
+ test('a command under claude under the session shell resolves by ancestry', () => {
11
+ const readProc = table({
12
+ 900: { ppid: 800, command: 'hop view report.html' },
13
+ 800: { ppid: 700, command: 'claude --resume abc' },
14
+ 700: { ppid: HOST, command: '/bin/zsh -lc env -u CLAUDE_CONFIG_DIR claude --resume abc', session: 's_room1' }
15
+ });
16
+ const r = resolveCallerSession({ pid: 900, hostPid: HOST, envSession: 's_room1', readProc });
17
+ assert.equal(r.ok, true); assert.equal(r.session, 's_room1'); assert.equal(r.via, 'ancestry'); assert.equal(r.note, undefined);
18
+ });
19
+
20
+ test('the shell wins over a lying environment, and says so', () => {
21
+ const readProc = table({
22
+ 900: { ppid: 700, command: 'hop checkback --in 5m x' },
23
+ 700: { ppid: HOST, command: '/bin/zsh', session: 's_room1' }
24
+ });
25
+ const r = resolveCallerSession({ pid: 900, hostPid: HOST, envSession: 's_other', readProc });
26
+ assert.equal(r.session, 's_room1'); assert.equal(r.via, 'ancestry'); assert.match(r.note, /HOP_SESSION says "s_other"/);
27
+ });
28
+
29
+ test("the session shell's environment is unreadable on macOS: the nearest readable ancestor below it speaks for it", () => {
30
+ const readProc = table({
31
+ 900: { ppid: 850, command: 'hop whoami', session: 's_lie' }, // the caller's own variable is not evidence
32
+ 850: { ppid: 800, command: '/bin/zsh -c snapshot', session: 's_room1' },
33
+ 800: { ppid: 700, command: 'claude --resume abc', session: 's_room1' },
34
+ 700: { ppid: HOST, command: '/bin/zsh -lc env -u CLAUDE_CONFIG_DIR claude', session: null }
35
+ });
36
+ const r = resolveCallerSession({ pid: 900, hostPid: HOST, envSession: 's_lie', readProc });
37
+ assert.equal(r.session, 's_room1'); assert.equal(r.via, 'ancestry'); assert.match(r.note, /says "s_lie"/);
38
+ });
39
+
40
+ test("under Codex's shared app-server daemon the answer is AMBIGUOUS, not the daemon owner's", () => {
41
+ const readProc = table({
42
+ 950: { ppid: 940, command: 'node /x/hop-mcp.js' },
43
+ 940: { ppid: 930, command: '/x/bin/codex app-server --listen unix:// --managed-daemon' },
44
+ 930: { ppid: 920, command: '/x/bin/codex app-server daemon pid-update-loop' },
45
+ 920: { ppid: 910, command: '/x/bin/codex resume 01a0' },
46
+ 910: { ppid: HOST, command: '/bin/zsh', session: 'Accessibility-fork-codex' }
47
+ });
48
+ const r = resolveCallerSession({ pid: 950, hostPid: HOST, envSession: 'Accessibility-fork-codex', readProc });
49
+ assert.equal(r.ok, false); assert.equal(r.session, null); assert.equal(r.reason, 'shared-daemon');
50
+ assert.match(r.note, /shared agent daemon/); assert.match(r.note, /Pass the session explicitly/);
51
+ });
52
+
53
+ test('no session shell above and no variable: not inside hop; with the variable: trusted as inherited', () => {
54
+ const readProc = table({ 300: { ppid: 200, command: 'bash' }, 200: { ppid: 1, command: 'login' } });
55
+ const none = resolveCallerSession({ pid: 300, hostPid: HOST, envSession: null, readProc });
56
+ assert.equal(none.ok, false); assert.equal(none.reason, 'no-session');
57
+ const env = resolveCallerSession({ pid: 300, hostPid: HOST, envSession: 's_cron', readProc });
58
+ assert.equal(env.ok, true); assert.equal(env.session, 's_cron'); assert.equal(env.via, 'env');
59
+ });
60
+
61
+ test('a process the table cannot read stops the walk gracefully', () => {
62
+ const r = resolveCallerSession({ pid: 5, hostPid: HOST, envSession: 's_x', readProc: () => null });
63
+ assert.equal(r.session, 's_x'); assert.equal(r.via, 'env');
64
+ });
65
+
66
+ test('HOP_SESSION is read out of the ENVIRONMENT part of a ps -E line, never out of the arguments', () => {
67
+ assert.equal(sessionFromEnvLine('/bin/zsh -lc claude HOP_SESSION=s_abc PATH=/usr/bin TERM=xterm', '/bin/zsh -lc claude'), 's_abc');
68
+ assert.equal(sessionFromEnvLine('/bin/zsh PATH=/usr/bin', '/bin/zsh'), null);
69
+ // The shell's own command text mentions a session; its environment says another.
70
+ assert.equal(sessionFromEnvLine('/bin/zsh -c HOP_SESSION=s_lie ./hop whoami HOME=/u HOP_SESSION=s_true', '/bin/zsh -c HOP_SESSION=s_lie ./hop whoami'), 's_true');
71
+ assert.equal(sessionFromEnvLine('/bin/zsh -c HOP_SESSION=s_lie ./hop whoami HOME=/u', '/bin/zsh -c HOP_SESSION=s_lie ./hop whoami'), null);
72
+ });
73
+
74
+ test('a multiplexer is the program that runs, not a mention in some argument list', () => {
75
+ assert.equal(isMultiplexer('/x/releases/0.159/bin/codex app-server --listen unix:// --managed-daemon'), true);
76
+ assert.equal(isMultiplexer('/x/bin/codex-code-mode-host'), true);
77
+ assert.equal(isMultiplexer('/opt/homebrew/bin/codex --no-daemon resume 01a0'), false);
78
+ assert.equal(isMultiplexer('/bin/zsh -c "echo codex app-server is a string"'), false);
79
+ });