hop2 0.11.69 → 0.11.71
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/hay/apps/server/src/hyperlinks.ts +133 -0
- package/hay/apps/server/src/lib.ts +1 -1
- package/hay/apps/server/src/rooms.ts +27 -5
- package/hay/apps/server/src/screenGrid.ts +30 -1
- package/hay/apps/server/test/hyperlinks.test.ts +99 -0
- package/hay/apps/server/test/rooms.test.ts +23 -1
- package/hay/apps/web/src/App.tsx +11 -2
- package/hay/apps/web/src/components/SessionSwitcher.tsx +15 -3
- package/hay/apps/web/src/utils/urlAtCell.ts +26 -0
- package/hay/apps/web/test/urlAtCell.test.ts +18 -1
- package/hop +79 -13
- package/lib/session-identity.js +99 -0
- package/mcp/hop-mcp.js +47 -4
- package/package.json +1 -1
- package/scripts/hay-host.js +17 -0
- package/tests/session-identity.unit.test.cjs +79 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// OSC 8 hyperlinks survive the serialized attach snapshot.
|
|
2
|
+
//
|
|
3
|
+
// A room's attach replay is the headless grid serialized by xterm's
|
|
4
|
+
// serialize addon, which carries colours and styles but not hyperlinks: a
|
|
5
|
+
// cell's link id (OSC 8) is dropped. Codex prints its markdown links as a
|
|
6
|
+
// TITLE with the URL riding underneath, so once a client attached — the
|
|
7
|
+
// usual way of reading a session — every link already on screen was plain
|
|
8
|
+
// text: "codex tile links are not clickable". Live output after the attach
|
|
9
|
+
// still carried them, which is why it looked intermittent.
|
|
10
|
+
//
|
|
11
|
+
// Rather than fork the addon, the serialized string is walked in step with
|
|
12
|
+
// the buffer it came from. The addon's emission is regular: per row, each
|
|
13
|
+
// cell of width > 0 either appends its characters or, when empty, is counted
|
|
14
|
+
// and later skipped with `ESC[nC` (after an optional `ESC[nX`); rows end
|
|
15
|
+
// with `\r\n`, or nothing when the next row is a wrap; width-0 cells (the
|
|
16
|
+
// second half of a wide char) are not emitted. So printable text maps onto
|
|
17
|
+
// cells one by one, and a link run can be wrapped where its first cell is
|
|
18
|
+
// consumed and closed where it ends. Any desync (a cell whose characters are
|
|
19
|
+
// not what the string says next) returns the original string unchanged — a
|
|
20
|
+
// snapshot without links beats a corrupted one.
|
|
21
|
+
|
|
22
|
+
export type LinkCell = { chars: string; width: number; urlId: number };
|
|
23
|
+
|
|
24
|
+
export type InjectOptions = {
|
|
25
|
+
/** Cell at (buffer row, col) — null past the end. */
|
|
26
|
+
getCell: (row: number, col: number) => LinkCell | null;
|
|
27
|
+
cols: number;
|
|
28
|
+
/** First buffer row the serialized string covers. */
|
|
29
|
+
startRow: number;
|
|
30
|
+
/** Number of buffer rows it covers. */
|
|
31
|
+
rowCount: number;
|
|
32
|
+
/** The link target for a cell's link id, if any. */
|
|
33
|
+
uriOf: (urlId: number) => string | undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ESC = "\x1b";
|
|
37
|
+
const BEL = "\x07";
|
|
38
|
+
|
|
39
|
+
/** Length of the escape sequence starting at `i` (which is ESC). */
|
|
40
|
+
const escapeLength = (s: string, i: number): number => {
|
|
41
|
+
const next = s[i + 1];
|
|
42
|
+
if (next === "[") {
|
|
43
|
+
let j = i + 2;
|
|
44
|
+
while (j < s.length) {
|
|
45
|
+
const c = s.charCodeAt(j);
|
|
46
|
+
if (c >= 0x40 && c <= 0x7e) return j + 1 - i;
|
|
47
|
+
j++;
|
|
48
|
+
}
|
|
49
|
+
return s.length - i;
|
|
50
|
+
}
|
|
51
|
+
if (next === "]") {
|
|
52
|
+
const bel = s.indexOf(BEL, i + 2);
|
|
53
|
+
const st = s.indexOf(ESC + "\\", i + 2);
|
|
54
|
+
const end = bel === -1 ? st : st === -1 ? bel : Math.min(bel, st);
|
|
55
|
+
if (end === -1) return s.length - i;
|
|
56
|
+
return end + (s[end] === BEL ? 1 : 2) - i;
|
|
57
|
+
}
|
|
58
|
+
return Math.min(2, s.length - i);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** `ESC[nC` → n, else 0. */
|
|
62
|
+
const cursorForward = (seq: string): number => {
|
|
63
|
+
const m = /^\x1b\[(\d*)C$/.exec(seq);
|
|
64
|
+
return m ? Math.max(1, Number(m[1] || 1)) : 0;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const injectHyperlinks = (serialized: string, o: InjectOptions): string => {
|
|
68
|
+
const endRow = o.startRow + o.rowCount; // exclusive
|
|
69
|
+
let row = o.startRow;
|
|
70
|
+
let col = 0;
|
|
71
|
+
let out = "";
|
|
72
|
+
let openId = 0;
|
|
73
|
+
const open = (id: number): boolean => {
|
|
74
|
+
const uri = o.uriOf(id);
|
|
75
|
+
if (!uri) return false;
|
|
76
|
+
out += `${ESC}]8;id=hop${id};${uri}${ESC}\\`;
|
|
77
|
+
openId = id;
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
const close = () => {
|
|
81
|
+
if (openId) out += `${ESC}]8;;${ESC}\\`;
|
|
82
|
+
openId = 0;
|
|
83
|
+
};
|
|
84
|
+
// The content cell at the cursor, skipping the placeholders a wide char
|
|
85
|
+
// leaves behind. Null past the row's end.
|
|
86
|
+
const contentCell = (): LinkCell | null => {
|
|
87
|
+
while (col < o.cols) {
|
|
88
|
+
const cell = o.getCell(row, col);
|
|
89
|
+
if (!cell) return null;
|
|
90
|
+
if (cell.width > 0) return cell;
|
|
91
|
+
col++;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < serialized.length) {
|
|
97
|
+
const ch = serialized[i];
|
|
98
|
+
if (ch === ESC) {
|
|
99
|
+
const n = escapeLength(serialized, i);
|
|
100
|
+
const seq = serialized.slice(i, i + n);
|
|
101
|
+
// A link is a property of the characters written while it is open;
|
|
102
|
+
// cursor moves and erases must not carry it, so close across them.
|
|
103
|
+
// Styling (SGR) inside a run is fine: the id keeps the run one link.
|
|
104
|
+
if (!/m$/.test(seq)) close();
|
|
105
|
+
const fwd = cursorForward(seq);
|
|
106
|
+
if (fwd && row < endRow) col = Math.min(o.cols, col + fwd);
|
|
107
|
+
out += seq;
|
|
108
|
+
i += n;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "\r" || ch === "\n") {
|
|
112
|
+
close();
|
|
113
|
+
out += ch;
|
|
114
|
+
if (ch === "\n") { row++; col = 0; }
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Printable: the next content cell.
|
|
119
|
+
if (row >= endRow) { close(); out += serialized.slice(i); return out; }
|
|
120
|
+
if (col >= o.cols) { row++; col = 0; } // a wrapped row ran straight on
|
|
121
|
+
const cell = contentCell();
|
|
122
|
+
if (!cell || !cell.chars || !serialized.startsWith(cell.chars, i)) return serialized;
|
|
123
|
+
if (cell.urlId !== openId) {
|
|
124
|
+
close();
|
|
125
|
+
if (cell.urlId) open(cell.urlId);
|
|
126
|
+
}
|
|
127
|
+
out += cell.chars;
|
|
128
|
+
i += cell.chars.length;
|
|
129
|
+
col += cell.width;
|
|
130
|
+
}
|
|
131
|
+
close();
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Termshare Server Library
|
|
2
2
|
// Export everything needed for embedding into other apps
|
|
3
3
|
|
|
4
|
-
export { Room, RoomManager, type SocketAdapter, type ClientInfo, type RoomSummary } from "./rooms";
|
|
4
|
+
export { Room, RoomManager, setDefaultViewerColors, getDefaultViewerColors, type SocketAdapter, type ClientInfo, type RoomSummary } from "./rooms";
|
|
5
5
|
export { createPty, type PtyFactory } from "./pty";
|
|
6
6
|
export { sanitizeName, sanitizeRoom } from "./utils";
|
|
7
7
|
|
|
@@ -87,6 +87,27 @@ export type ClientInfo = {
|
|
|
87
87
|
nudge?: boolean;
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
// The colors of the LAST viewer anyone had, host-wide. A room usually
|
|
91
|
+
// starts headless — the daemon creates it, the app launches and asks "what's
|
|
92
|
+
// your background?" within milliseconds, and the browser attaches only
|
|
93
|
+
// after — so a per-room memory of "its own last viewer" is empty exactly
|
|
94
|
+
// when it is needed. Codex and Claude pick light vs dark from this answer
|
|
95
|
+
// ONCE, at startup: told the dark default, Codex drew a charcoal composer
|
|
96
|
+
// box on the user's white theme for the rest of the session. The newest
|
|
97
|
+
// viewer's colors are what the user is looking at; every new room answers
|
|
98
|
+
// with them until a viewer of its own says otherwise. Persisted by the host
|
|
99
|
+
// so a restart (restore, keeper re-adoption) keeps the answer.
|
|
100
|
+
const viewerColors = { bg: "0d1117", fg: "e6edf3" };
|
|
101
|
+
const HEX6 = /^[0-9a-f]{6}$/i;
|
|
102
|
+
/** Remember the viewer's colors for every room created from now on. Returns true when they changed. */
|
|
103
|
+
export function setDefaultViewerColors(bg?: string | null, fg?: string | null): boolean {
|
|
104
|
+
let changed = false;
|
|
105
|
+
if (bg && HEX6.test(bg) && bg.toLowerCase() !== viewerColors.bg) { viewerColors.bg = bg.toLowerCase(); changed = true; }
|
|
106
|
+
if (fg && HEX6.test(fg) && fg.toLowerCase() !== viewerColors.fg) { viewerColors.fg = fg.toLowerCase(); changed = true; }
|
|
107
|
+
return changed;
|
|
108
|
+
}
|
|
109
|
+
export const getDefaultViewerColors = () => ({ ...viewerColors });
|
|
110
|
+
|
|
90
111
|
export type RoomCreateOptions = {
|
|
91
112
|
cwd: string;
|
|
92
113
|
env?: Record<string, string>;
|
|
@@ -286,9 +307,9 @@ export class Room extends EventEmitter {
|
|
|
286
307
|
// getOutputSince hand out stable cursors into an ever-trimming ring.
|
|
287
308
|
private outputStart = 0;
|
|
288
309
|
// Last viewer's terminal colors (6-hex, no '#'), used to answer OSC 10/11
|
|
289
|
-
// while headless.
|
|
290
|
-
private clientBg =
|
|
291
|
-
private clientFg =
|
|
310
|
+
// while headless. Starts as the host-wide last viewer's (see viewerColors).
|
|
311
|
+
private clientBg = viewerColors.bg;
|
|
312
|
+
private clientFg = viewerColors.fg;
|
|
292
313
|
private alternateScreen = false;
|
|
293
314
|
// Enhanced keyboard reporting requested by the remote app (kitty keyboard protocol
|
|
294
315
|
// or xterm modifyOtherKeys). Tracked like alternateScreen so a reattaching client
|
|
@@ -1364,8 +1385,9 @@ export class Room extends EventEmitter {
|
|
|
1364
1385
|
*/
|
|
1365
1386
|
/** Remember the viewer's terminal colors for headless OSC 10/11 answers. */
|
|
1366
1387
|
setClientColors(bg?: string | null, fg?: string | null) {
|
|
1367
|
-
if (bg &&
|
|
1368
|
-
if (fg &&
|
|
1388
|
+
if (bg && HEX6.test(bg)) this.clientBg = bg.toLowerCase();
|
|
1389
|
+
if (fg && HEX6.test(fg)) this.clientFg = fg.toLowerCase();
|
|
1390
|
+
setDefaultViewerColors(bg, fg);
|
|
1369
1391
|
}
|
|
1370
1392
|
|
|
1371
1393
|
getOutputSince(
|
|
@@ -16,7 +16,15 @@
|
|
|
16
16
|
// room silently falls back to the raw-tail snapshot (+ wiggle), so a broken
|
|
17
17
|
// dependency degrades to the old behavior instead of breaking attach.
|
|
18
18
|
|
|
19
|
+
import { injectHyperlinks } from "./hyperlinks";
|
|
20
|
+
|
|
21
|
+
type HeadlessCell = { getChars(): string; getWidth(): number; extended?: { urlId?: number } };
|
|
19
22
|
type HeadlessTerminal = {
|
|
23
|
+
rows?: number;
|
|
24
|
+
cols?: number;
|
|
25
|
+
buffer?: { active: { length: number; getLine(y: number): { getCell(x: number): HeadlessCell | undefined } | undefined } };
|
|
26
|
+
// Internals the serialize addon drops (OSC 8 targets); read guardedly.
|
|
27
|
+
_core?: { _oscLinkService?: { getLinkData(id: number): { uri?: string } | undefined; _dataByLinkId?: Map<number, unknown> } };
|
|
20
28
|
write(data: string, callback?: () => void): void;
|
|
21
29
|
resize(cols: number, rows: number): void;
|
|
22
30
|
loadAddon(addon: unknown): void;
|
|
@@ -96,6 +104,26 @@ export type ScreenGrid = {
|
|
|
96
104
|
dispose(): void;
|
|
97
105
|
};
|
|
98
106
|
|
|
107
|
+
// The serialize addon drops OSC 8 hyperlinks; put them back from the
|
|
108
|
+
// buffer's own link ids (see hyperlinks.ts). Skipped outright when the grid
|
|
109
|
+
// has registered no link at all — the common case, at no cost.
|
|
110
|
+
const withHyperlinks = (term: HeadlessTerminal, serialized: string, scrollback: number): string => {
|
|
111
|
+
try {
|
|
112
|
+
const svc = term._core?._oscLinkService;
|
|
113
|
+
const buf = term.buffer?.active;
|
|
114
|
+
if (!svc || !buf || !term.rows) return serialized;
|
|
115
|
+
if (svc._dataByLinkId && svc._dataByLinkId.size === 0) return serialized;
|
|
116
|
+
const rowCount = Math.min(buf.length, scrollback + term.rows);
|
|
117
|
+
return injectHyperlinks(serialized, {
|
|
118
|
+
cols: term.cols ?? 0, startRow: buf.length - rowCount, rowCount,
|
|
119
|
+
getCell: (y, x) => { const c = buf.getLine(y)?.getCell(x); return c ? { chars: c.getChars(), width: c.getWidth(), urlId: c.extended?.urlId || 0 } : null; },
|
|
120
|
+
uriOf: (id) => svc.getLinkData(id)?.uri
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
return serialized;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
99
127
|
export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null => {
|
|
100
128
|
if (!deps) return null;
|
|
101
129
|
let term: HeadlessTerminal;
|
|
@@ -165,7 +193,8 @@ export const createScreenGrid = (cols: number, rows: number): ScreenGrid | null
|
|
|
165
193
|
term.write("", () => {
|
|
166
194
|
if (disposed) { callback(null); return; }
|
|
167
195
|
try {
|
|
168
|
-
|
|
196
|
+
const sb = Math.max(0, Math.min(scrollback, GRID_SCROLLBACK));
|
|
197
|
+
callback(withHyperlinks(term, addon.serialize({ scrollback: sb }), sb));
|
|
169
198
|
} catch {
|
|
170
199
|
callback(null);
|
|
171
200
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// OSC 8 links survive a serialize → inject → replay round trip through real
|
|
2
|
+
// xterm: the replayed terminal's cells carry the same targets, and its text
|
|
3
|
+
// is the serialized terminal's text.
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { injectHyperlinks } from "../src/hyperlinks";
|
|
6
|
+
|
|
7
|
+
type Cell = { getChars(): string; getWidth(): number; extended?: { urlId?: number } };
|
|
8
|
+
type Term = {
|
|
9
|
+
rows: number; cols: number;
|
|
10
|
+
buffer: { active: { length: number; getLine(y: number): { getCell(x: number): Cell | undefined; translateToString(trim?: boolean): string } | undefined } };
|
|
11
|
+
write(d: string, cb?: () => void): void;
|
|
12
|
+
loadAddon(a: unknown): void;
|
|
13
|
+
_core: { _oscLinkService: { getLinkData(id: number): { uri: string } | undefined } };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const g = globalThis as Record<string, unknown>;
|
|
17
|
+
if (!g.window) g.window = g;
|
|
18
|
+
if (!g.self) g.self = g;
|
|
19
|
+
|
|
20
|
+
const make = async (cols = 40, rows = 6, scrollback = 100) => {
|
|
21
|
+
// UMD/CJS under ESM interop: the named exports may live on `default`
|
|
22
|
+
// (screenGrid.ts resolves them the same way).
|
|
23
|
+
const h = await import("@xterm/headless") as { Terminal?: unknown; default?: { Terminal?: unknown } };
|
|
24
|
+
const sz = await import("@xterm/addon-serialize") as { SerializeAddon?: unknown; default?: { SerializeAddon?: unknown } };
|
|
25
|
+
const Terminal = (h.Terminal ?? h.default?.Terminal) as new (o: Record<string, unknown>) => Term;
|
|
26
|
+
const SerializeAddon = (sz.SerializeAddon ?? sz.default?.SerializeAddon) as new () => { serialize(o: { scrollback?: number }): string };
|
|
27
|
+
const term = new Terminal({ cols, rows, scrollback, allowProposedApi: true });
|
|
28
|
+
const addon = new SerializeAddon();
|
|
29
|
+
term.loadAddon(addon);
|
|
30
|
+
const write = (d: string) => new Promise<void>((r) => term.write(d, r));
|
|
31
|
+
const serialize = (sb = scrollback) => {
|
|
32
|
+
const s = addon.serialize({ scrollback: sb });
|
|
33
|
+
const buf = term.buffer.active;
|
|
34
|
+
const rowCount = Math.min(buf.length, sb + term.rows);
|
|
35
|
+
return injectHyperlinks(s, {
|
|
36
|
+
cols: term.cols, startRow: buf.length - rowCount, rowCount,
|
|
37
|
+
getCell: (y, x) => { const c = buf.getLine(y)?.getCell(x); return c ? { chars: c.getChars(), width: c.getWidth(), urlId: c.extended?.urlId || 0 } : null; },
|
|
38
|
+
uriOf: (id) => term._core._oscLinkService.getLinkData(id)?.uri
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
return { term, write, serialize };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const linksOf = (term: Term) => {
|
|
45
|
+
const buf = term.buffer.active;
|
|
46
|
+
const out: { row: number; text: string; uri: string }[] = [];
|
|
47
|
+
for (let y = 0; y < buf.length; y++) {
|
|
48
|
+
const line = buf.getLine(y)!;
|
|
49
|
+
let cur: { row: number; text: string; uri: string } | null = null;
|
|
50
|
+
for (let x = 0; x < term.cols; x++) {
|
|
51
|
+
const c = line.getCell(x)!;
|
|
52
|
+
const uri = c.extended?.urlId ? term._core._oscLinkService.getLinkData(c.extended.urlId)?.uri || "" : "";
|
|
53
|
+
if (uri && cur && cur.uri === uri) cur.text += c.getChars();
|
|
54
|
+
else if (uri) { cur = { row: y, text: c.getChars(), uri }; out.push(cur); }
|
|
55
|
+
else cur = null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
const textOf = (term: Term) => Array.from({ length: term.buffer.active.length }, (_, y) => term.buffer.active.getLine(y)!.translateToString(true)).join("\n").replace(/\n+$/, "");
|
|
61
|
+
|
|
62
|
+
const L = (uri: string, text: string) => `\x1b]8;;${uri}\x07${text}\x1b]8;;\x07`;
|
|
63
|
+
|
|
64
|
+
describe("injectHyperlinks — OSC 8 links ride the serialized snapshot", () => {
|
|
65
|
+
it("a titled link (Codex's markdown link) comes back as a link, text intact", async () => {
|
|
66
|
+
const a = await make(80);
|
|
67
|
+
await a.write(`• Wrote the plan: ${L("https://hop.zhoulab.io/view/s_1/plan.html/inline", "proposed next experiments")}.\r\nnext line\r\n`);
|
|
68
|
+
const b = await make(80);
|
|
69
|
+
await b.write(a.serialize());
|
|
70
|
+
expect(textOf(b.term)).toBe(textOf(a.term));
|
|
71
|
+
expect(linksOf(b.term)).toEqual([{ row: 0, text: "proposed next experiments", uri: "https://hop.zhoulab.io/view/s_1/plan.html/inline" }]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("colours inside a link, empty gaps, wide characters and a wrapped row all keep their places", async () => {
|
|
75
|
+
const a = await make(20, 5);
|
|
76
|
+
await a.write(`\x1b[31mred\x1b[0m ${L("https://x.test/a", "li\x1b[1mnk\x1b[22m 漢字")} tail\r\n`);
|
|
77
|
+
await a.write(`${L("https://x.test/b", "0123456789abcdefghijKLMNOP")}\r\n`); // wraps at 20
|
|
78
|
+
await a.write(`gap ${L("https://x.test/c", "c")}\x1b[5C${L("https://x.test/d", "d")}\r\n`);
|
|
79
|
+
const b = await make(20, 5);
|
|
80
|
+
await b.write(a.serialize());
|
|
81
|
+
expect(textOf(b.term)).toBe(textOf(a.term));
|
|
82
|
+
expect(linksOf(b.term)).toEqual(linksOf(a.term));
|
|
83
|
+
expect(linksOf(b.term).map((l) => l.text)).toEqual(["link 漢字", "0123456789abcdefghij", "KLMNOP", "c", "d"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a snapshot with scrollback trimmed off keeps the links of the rows it covers", async () => {
|
|
87
|
+
const a = await make(30, 3, 50);
|
|
88
|
+
for (let i = 0; i < 8; i++) await a.write(`${L(`https://x.test/${i}`, `line ${i}`)}\r\n`);
|
|
89
|
+
const b = await make(30, 3, 50);
|
|
90
|
+
await b.write(a.serialize(2)); // 2 rows of history + 3 of screen
|
|
91
|
+
expect(linksOf(b.term).map((l) => l.uri)).toEqual(["https://x.test/4", "https://x.test/5", "https://x.test/6", "https://x.test/7"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("a desync returns the string untouched rather than a corrupted screen", () => {
|
|
95
|
+
const s = "\x1b[31mhello\x1b[0m";
|
|
96
|
+
const out = injectHyperlinks(s, { cols: 10, startRow: 0, rowCount: 1, getCell: () => ({ chars: "x", width: 1, urlId: 1 }), uriOf: () => "https://x" });
|
|
97
|
+
expect(out).toBe(s);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { RoomManager } from "../src/rooms";
|
|
2
|
+
import { RoomManager, getDefaultViewerColors, setDefaultViewerColors } from "../src/rooms";
|
|
3
3
|
import type { PtyFactory } from "../src/pty";
|
|
4
4
|
|
|
5
5
|
type Message = { type: string; [key: string]: unknown };
|
|
@@ -766,6 +766,28 @@ describe("Room", () => {
|
|
|
766
766
|
const factory: PtyFactory = () => {
|
|
767
767
|
ptyInstance = new FakePty() as unknown as FakePty;
|
|
768
768
|
return ptyInstance as any;
|
|
769
|
+
|
|
770
|
+
it("a NEW headless room answers with the colors the last viewer (of any room) had — a light theme stays light", () => {
|
|
771
|
+
const ptys: FakePty[] = [];
|
|
772
|
+
const factory: PtyFactory = () => { const p = new FakePty(); ptys.push(p); return p as any; };
|
|
773
|
+
const manager = new RoomManager(factory);
|
|
774
|
+
const first = manager.getRoom("seen", { cols: 80, rows: 24 }, "/tmp");
|
|
775
|
+
// The browser attached on a white theme (its bg/fg ride on the attach URL).
|
|
776
|
+
first.setClientColors("ffffff", "1f2328");
|
|
777
|
+
expect(getDefaultViewerColors()).toEqual({ bg: "ffffff", fg: "1f2328" });
|
|
778
|
+
|
|
779
|
+
// The next room is created by the daemon and probed by Codex before any
|
|
780
|
+
// browser attaches: it is told white, not the dark default.
|
|
781
|
+
manager.getRoom("fresh", { cols: 80, rows: 24 }, "/tmp");
|
|
782
|
+
ptys[1].emit("\x1b]10;?\x07\x1b]11;?\x1b\\");
|
|
783
|
+
const answered = ptys[1].writes.join("");
|
|
784
|
+
expect(answered).toContain("]11;rgb:ffff/ffff/ffff");
|
|
785
|
+
expect(answered).toContain("]10;rgb:1f1f/2323/2828");
|
|
786
|
+
|
|
787
|
+
// Garbage never replaces a good answer; the dark default is restorable.
|
|
788
|
+
expect(setDefaultViewerColors("#fff", "nope")).toBe(false);
|
|
789
|
+
expect(setDefaultViewerColors("0d1117", "e6edf3")).toBe(true);
|
|
790
|
+
});
|
|
769
791
|
};
|
|
770
792
|
const manager = new RoomManager(factory);
|
|
771
793
|
const room = manager.getRoom(roomId, initialSize, "/tmp");
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSea
|
|
|
16
16
|
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
17
17
|
import { tabTitle } from "./utils/tabTitle";
|
|
18
18
|
import { remoteAppOwnsScreen } from "./utils/echoGuard";
|
|
19
|
-
import { urlAtCell, cellAtPoint } from "./utils/urlAtCell";
|
|
19
|
+
import { urlAtCell, cellAtPoint, oscUrlAtCell } from "./utils/urlAtCell";
|
|
20
20
|
import { enableMathHover } from "./utils/mathLinkProvider";
|
|
21
21
|
import { getMathTip } from "./utils/mathTooltip";
|
|
22
22
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
@@ -2147,6 +2147,15 @@ const App = () => {
|
|
|
2147
2147
|
scrollSensitivity: 4,
|
|
2148
2148
|
fastScrollSensitivity: 12,
|
|
2149
2149
|
minimumContrastRatio: contrastFloorFor(themeMode),
|
|
2150
|
+
// OSC 8 hyperlinks — Codex prints a markdown link as its title with the
|
|
2151
|
+
// URL underneath. xterm's default for these is a confirm() dialog;
|
|
2152
|
+
// open them like the addon's links instead.
|
|
2153
|
+
linkHandler: {
|
|
2154
|
+
activate: (event, uri) => {
|
|
2155
|
+
event.preventDefault();
|
|
2156
|
+
window.open(uri, "_blank", "noopener");
|
|
2157
|
+
}
|
|
2158
|
+
},
|
|
2150
2159
|
theme: resolveTerminalTheme(themeMode)
|
|
2151
2160
|
});
|
|
2152
2161
|
const fitAddon = new FitAddon();
|
|
@@ -3866,7 +3875,7 @@ const App = () => {
|
|
|
3866
3875
|
return urlAtCell(
|
|
3867
3876
|
(i) => { const l = buf.getLine(i); return l ? { text: l.translateToString(false), wrapped: l.isWrapped } : null; },
|
|
3868
3877
|
buf.length, term.cols, bufferRow, col
|
|
3869
|
-
);
|
|
3878
|
+
) || oscUrlAtCell(term, bufferRow, col);
|
|
3870
3879
|
}, []);
|
|
3871
3880
|
|
|
3872
3881
|
// Enroll this device's platform authenticator (Touch ID / Face ID) as a
|
|
@@ -19,7 +19,7 @@ import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
|
|
|
19
19
|
import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
|
|
20
20
|
import { installDragSelect } from "../utils/dragSelect";
|
|
21
21
|
import { enableViewBlocks } from "../utils/viewBlocks";
|
|
22
|
-
import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
|
|
22
|
+
import { urlAtCell, cellAtPoint, oscUrlAtCell } from "../utils/urlAtCell";
|
|
23
23
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
24
24
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
25
25
|
import { CwdField } from "./CwdField";
|
|
@@ -396,10 +396,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
396
396
|
const rowsEl = boxRef.current?.querySelector(".xterm-rows") as HTMLElement | null;
|
|
397
397
|
const cell = cellAtPoint(screen, rowsEl, term.cols, term.rows, clientX, clientY);
|
|
398
398
|
const buf = term.buffer.active;
|
|
399
|
-
|
|
399
|
+
// A URL in the text, reassembled across rows — or the OSC 8 target a
|
|
400
|
+
// TUI hung on a title (Codex's markdown links), which has no URL text.
|
|
401
|
+
const url = cell ? (urlAtCell(
|
|
400
402
|
(i) => { const l = buf.getLine(i); return l ? { text: l.translateToString(false), wrapped: l.isWrapped } : null; },
|
|
401
403
|
buf.length, term.cols, buf.viewportY + cell.row, cell.col
|
|
402
|
-
) : null;
|
|
404
|
+
) || oscUrlAtCell(term, buf.viewportY + cell.row, cell.col)) : null;
|
|
403
405
|
// Diagnostics for "clicking a link in a tile does nothing" reports:
|
|
404
406
|
// localStorage.hay_debug_links=1 prints what the click resolved to.
|
|
405
407
|
try {
|
|
@@ -525,6 +527,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
525
527
|
fontFamily: monoStack,
|
|
526
528
|
cursorBlink: false,
|
|
527
529
|
disableStdin: true,
|
|
530
|
+
// OSC 8 hyperlinks (Codex's markdown links show a title, the URL rides
|
|
531
|
+
// underneath): without a handler xterm throws up a confirm() dialog,
|
|
532
|
+
// which read as "the link does nothing". Open it like any other link.
|
|
533
|
+
linkHandler: {
|
|
534
|
+
activate: (event, uri) => {
|
|
535
|
+
event.preventDefault();
|
|
536
|
+
lastLinkOpenAtRef.current = Date.now();
|
|
537
|
+
window.open(uri, "_blank", "noopener");
|
|
538
|
+
}
|
|
539
|
+
},
|
|
528
540
|
theme: theme as never
|
|
529
541
|
});
|
|
530
542
|
const fit = new FitAddon();
|
|
@@ -12,6 +12,32 @@
|
|
|
12
12
|
|
|
13
13
|
export type CellLine = { text: string; wrapped: boolean };
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The OSC 8 hyperlink under a cell, if the app printed one. Codex renders a
|
|
17
|
+
* markdown link as its TITLE text carrying the URL as an OSC 8 target —
|
|
18
|
+
* "proposed next experiments" on screen, the hop view URL underneath — so
|
|
19
|
+
* there is no URL text for the regex-based addon or urlAtCell to find.
|
|
20
|
+
* xterm keeps the target on the cell (its link id) and in its link service;
|
|
21
|
+
* neither is public API, so this reads them guardedly and returns null on
|
|
22
|
+
* any other xterm. http(s) only: a file:// or custom-scheme target is not
|
|
23
|
+
* something a click in a browser can open.
|
|
24
|
+
*/
|
|
25
|
+
export const oscUrlAtCell = (term: unknown, bufferRow: number, col: number): string | null => {
|
|
26
|
+
try {
|
|
27
|
+
const t = term as {
|
|
28
|
+
buffer: { active: { getLine: (r: number) => { getCell: (c: number) => unknown } | undefined } };
|
|
29
|
+
_core?: { _oscLinkService?: { getLinkData: (id: number) => { uri?: string } | undefined } };
|
|
30
|
+
};
|
|
31
|
+
const cell = t.buffer.active.getLine(bufferRow)?.getCell(col) as { extended?: { urlId?: number } } | undefined;
|
|
32
|
+
const id = cell?.extended?.urlId;
|
|
33
|
+
if (!id) return null;
|
|
34
|
+
const uri = t._core?._oscLinkService?.getLinkData(id)?.uri;
|
|
35
|
+
return uri && /^https?:\/\//i.test(uri) ? uri : null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
15
41
|
/**
|
|
16
42
|
* The (row, col) under a viewport point. With the DOM renderer each row is
|
|
17
43
|
* an element, so the row comes from whichever one contains the point —
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { urlAtCell } from "../src/utils/urlAtCell";
|
|
2
|
+
import { oscUrlAtCell, urlAtCell } from "../src/utils/urlAtCell";
|
|
3
3
|
|
|
4
4
|
const COLS = 42;
|
|
5
5
|
const pad = (s: string) => s.padEnd(COLS, " ");
|
|
@@ -48,3 +48,20 @@ describe("urlAtCell", () => {
|
|
|
48
48
|
expect(urlAtCell(b.get, b.length, COLS, 0, 3)).toBeNull();
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
describe("oscUrlAtCell — the OSC 8 target under a title (Codex's markdown links)", () => {
|
|
53
|
+
const fakeTerm = (urlId: number | undefined, uri: string | undefined) => ({
|
|
54
|
+
buffer: { active: { getLine: (r: number) => (r === 3 ? { getCell: (c: number) => (c === 5 ? { extended: { urlId } } : {}) } : undefined) } },
|
|
55
|
+
_core: { _oscLinkService: { getLinkData: (id: number) => (id === 7 ? { uri } : undefined) } }
|
|
56
|
+
});
|
|
57
|
+
it("resolves the link's URL from the cell's link id", () => {
|
|
58
|
+
expect(oscUrlAtCell(fakeTerm(7, "https://hop.zhoulab.io/view/s_1/plan.html/inline"), 3, 5)).toBe("https://hop.zhoulab.io/view/s_1/plan.html/inline");
|
|
59
|
+
});
|
|
60
|
+
it("nothing for a plain cell, another row, a non-http target, or an xterm without the internals", () => {
|
|
61
|
+
expect(oscUrlAtCell(fakeTerm(7, "https://x.test/"), 3, 6)).toBeNull();
|
|
62
|
+
expect(oscUrlAtCell(fakeTerm(7, "https://x.test/"), 2, 5)).toBeNull();
|
|
63
|
+
expect(oscUrlAtCell(fakeTerm(undefined, "https://x.test/"), 3, 5)).toBeNull();
|
|
64
|
+
expect(oscUrlAtCell(fakeTerm(7, "file:///etc/hosts"), 3, 5)).toBeNull();
|
|
65
|
+
expect(oscUrlAtCell({ buffer: { active: { getLine: () => ({ getCell: () => ({ extended: { urlId: 7 } }) }) } } }, 3, 5)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
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');
|
|
@@ -4787,7 +4788,7 @@ function buildSessionEntries(haySessions, opts = {}) {
|
|
|
4787
4788
|
createdAt: config?.createdAt,
|
|
4788
4789
|
createdBy: effective?.createdBy || 'user',
|
|
4789
4790
|
agentPermitted: config?.agentPermitted === true,
|
|
4790
|
-
agent: sessionAgentKind(
|
|
4791
|
+
agent: sessionAgentKind(name),
|
|
4791
4792
|
localCliCount: clientSummary.localCliCount,
|
|
4792
4793
|
hasLocalCli: clientSummary.hasLocalCli,
|
|
4793
4794
|
...getSessionSeenInfo(name)
|
|
@@ -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 =
|
|
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
|
|
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') ||
|
|
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') ||
|
|
17596
|
-
if (!message || !session) { usage();
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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}`;
|
|
@@ -17901,7 +17967,7 @@ async function main() {
|
|
|
17901
17967
|
}
|
|
17902
17968
|
// Name the session it was filed under, so a wrong one is visible at
|
|
17903
17969
|
// once (an inherited HOP_SESSION can belong to another session).
|
|
17904
|
-
console.log(`Filed under ${getSessionDisplayName(
|
|
17970
|
+
console.log(`Filed under ${getSessionDisplayName(caller) || caller}.`);
|
|
17905
17971
|
// Same one-bell convention as `hop view` — the human is usually
|
|
17906
17972
|
// waiting on this, and the link scrolls away unannounced otherwise.
|
|
17907
17973
|
process.stdout.write('\x07');
|
|
@@ -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
|
|
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
|
|
3789
|
-
|
|
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 ?
|
|
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
package/scripts/hay-host.js
CHANGED
|
@@ -80,6 +80,21 @@ if (HOP_HOME.startsWith(os.tmpdir())) {
|
|
|
80
80
|
}, 30000).unref();
|
|
81
81
|
}
|
|
82
82
|
const BUFFER_DIR = path.join(HOP_HOME, 'session-buffers');
|
|
83
|
+
// The last viewer's terminal colors, kept across host restarts: a room that
|
|
84
|
+
// starts headless answers an app's "what's your background?" (OSC 10/11)
|
|
85
|
+
// with these, so Codex/Claude theme themselves light or dark to match what
|
|
86
|
+
// the user actually looks at — restored rooms included.
|
|
87
|
+
const VIEWER_COLORS_FILE = path.join(HOP_HOME, '.hay-viewer-colors');
|
|
88
|
+
function loadViewerColors(hay) {
|
|
89
|
+
try {
|
|
90
|
+
const saved = JSON.parse(fs.readFileSync(VIEWER_COLORS_FILE, 'utf8'));
|
|
91
|
+
if (typeof hay.setDefaultViewerColors === 'function') hay.setDefaultViewerColors(saved.bg, saved.fg);
|
|
92
|
+
} catch (e) { /* none yet: the dark default */ }
|
|
93
|
+
}
|
|
94
|
+
function rememberViewerColors(hay, bg, fg) {
|
|
95
|
+
if (typeof hay.setDefaultViewerColors !== 'function') return;
|
|
96
|
+
if (!hay.setDefaultViewerColors(bg, fg)) return;
|
|
97
|
+
try { fs.writeFileSync(VIEWER_COLORS_FILE, JSON.stringify(hay.getDefaultViewerColors())); } catch (e) { /* best effort */ }
|
|
83
98
|
const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
|
|
84
99
|
|
|
85
100
|
// Scrub Claude Code SESSION markers from our environment before any PTY is
|
|
@@ -220,6 +235,7 @@ function normalizeEnv(rawEnv) {
|
|
|
220
235
|
async function main() {
|
|
221
236
|
const libPath = path.join(__dirname, '..', 'hay', 'apps', 'server', 'dist', 'lib.js');
|
|
222
237
|
const hay = await import(pathToFileURL(libPath));
|
|
238
|
+
loadViewerColors(hay);
|
|
223
239
|
const rooms = new hay.RoomManager(hay.createPty);
|
|
224
240
|
const server = http.createServer(async (req, res) => {
|
|
225
241
|
const reqUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
@@ -439,6 +455,7 @@ async function main() {
|
|
|
439
455
|
const clientFg = hex(wsUrl.searchParams.get('fg'));
|
|
440
456
|
if (clientBg && typeof room.setClientColors === 'function') {
|
|
441
457
|
room.setClientColors(clientBg, clientFg);
|
|
458
|
+
rememberViewerColors(hay, clientBg, clientFg);
|
|
442
459
|
}
|
|
443
460
|
// Tab identity forwarded by the daemon: the room evicts a same-key
|
|
444
461
|
// predecessor on attach, so a phone's reconnect replaces its own
|
|
@@ -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
|
+
});
|