hop2 0.11.82 → 0.11.83
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/web/src/App.tsx +9 -7
- package/hay/apps/web/src/components/SecondaryPane.tsx +10 -5
- package/hay/apps/web/src/components/SessionSwitcher.tsx +16 -8
- package/hay/apps/web/src/utils/localLink.ts +37 -6
- package/hay/apps/web/test/localLink.test.ts +7 -1
- package/hop +55 -21
- package/package.json +1 -1
- package/scripts/trajectory-extract.js +28 -1
- package/tests/terminal-api.integration.test.cjs +80 -0
- package/tests/trajectory-extract.unit.test.cjs +29 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -88,10 +88,12 @@ const getLocationRoom = () => {
|
|
|
88
88
|
|
|
89
89
|
const buildSessionPath = (sessionName: string) => `/s/${encodeURIComponent(sessionName)}/`;
|
|
90
90
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
// On the machine hop runs on, terminal sockets skip the tunnel.
|
|
92
|
+
const resolveWsUrl = () => localWsBase() ?? resolveTunnelWsUrl();
|
|
93
|
+
|
|
94
|
+
// The page's own socket url. Wall tiles and panes take this and choose the
|
|
95
|
+
// fast path per connect (currentWsBase), so a dead fast path never strands them.
|
|
96
|
+
const resolveTunnelWsUrl = () => {
|
|
95
97
|
// Check for hop session config (when embedded in hop)
|
|
96
98
|
const hopSession = (window as unknown as { __HOP_SESSION__?: { wsUrl?: string } }).__HOP_SESSION__;
|
|
97
99
|
if (hopSession?.wsUrl) {
|
|
@@ -4350,7 +4352,7 @@ const App = () => {
|
|
|
4350
4352
|
}}
|
|
4351
4353
|
onRefresh={() => fetchSessions({ showLoading: false })}
|
|
4352
4354
|
onNotice={showToast}
|
|
4353
|
-
tileWsBase={
|
|
4355
|
+
tileWsBase={resolveTunnelWsUrl()}
|
|
4354
4356
|
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
4355
4357
|
onOpenCheckbacks={(scope) => setCheckbacksOpen(scope)}
|
|
4356
4358
|
userName={name}
|
|
@@ -4975,7 +4977,7 @@ const App = () => {
|
|
|
4975
4977
|
<SecondaryPane
|
|
4976
4978
|
sessionName={leaf.session as string}
|
|
4977
4979
|
procLabel={paneProc === "zsh" || paneProc === "bash" ? "" : paneProc}
|
|
4978
|
-
wsUrl={
|
|
4980
|
+
wsUrl={resolveTunnelWsUrl()}
|
|
4979
4981
|
userName={name}
|
|
4980
4982
|
cols={120}
|
|
4981
4983
|
rows={30}
|
|
@@ -5015,7 +5017,7 @@ const App = () => {
|
|
|
5015
5017
|
}}
|
|
5016
5018
|
onRefresh={() => fetchSessions({ showLoading: false })}
|
|
5017
5019
|
onNotice={showToast}
|
|
5018
|
-
tileWsBase={
|
|
5020
|
+
tileWsBase={resolveTunnelWsUrl()}
|
|
5019
5021
|
onFocusSession={focusSessionInPlace}
|
|
5020
5022
|
folders={folders}
|
|
5021
5023
|
onOpenViews={(scope, item) => setViewsOpen({ session: scope, item })}
|
|
@@ -8,6 +8,7 @@ import { PaneButtons, PaneIcon, type DragHandle } from "./PaneLayout";
|
|
|
8
8
|
import { enableMathHover } from "../utils/mathLinkProvider";
|
|
9
9
|
import { getMathTip } from "../utils/mathTooltip";
|
|
10
10
|
import type { Side } from "../utils/paneTree";
|
|
11
|
+
import { currentWsBase, noteSocketClosed } from "../utils/localLink";
|
|
11
12
|
|
|
12
13
|
// A lightweight additional pane: its own WS attach + xterm for one session,
|
|
13
14
|
// typing forwarded when focused. A FOCUSED pane claims its box size (crisp
|
|
@@ -115,14 +116,17 @@ export const SecondaryPane = ({
|
|
|
115
116
|
};
|
|
116
117
|
|
|
117
118
|
const connect = () => {
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
// The fast path while it works, else the tunnel url this pane was given.
|
|
120
|
+
const base = currentWsBase(wsUrl);
|
|
121
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
122
|
+
const url = `${base}${sep}room=${encodeURIComponent(sessionName)}&name=${encodeURIComponent(userName + " (pane)")}` +
|
|
123
|
+
`&cols=${sizeRef.current.cols}&rows=${sizeRef.current.rows}`;
|
|
124
|
+
const ws = new WebSocket(url);
|
|
123
125
|
wsRef.current = ws;
|
|
124
126
|
showConnecting();
|
|
127
|
+
let opened = false;
|
|
125
128
|
ws.onopen = () => {
|
|
129
|
+
opened = true;
|
|
126
130
|
everConnected = true;
|
|
127
131
|
attempt = 0; // a healed link earns a fresh immediate retry next time
|
|
128
132
|
window.clearTimeout(chromeTimer);
|
|
@@ -167,6 +171,7 @@ export const SecondaryPane = ({
|
|
|
167
171
|
}
|
|
168
172
|
};
|
|
169
173
|
ws.onclose = () => {
|
|
174
|
+
noteSocketClosed(url, opened);
|
|
170
175
|
if (!shouldReconnect) return;
|
|
171
176
|
showConnecting();
|
|
172
177
|
// Immediate first retry: most drops are a tunnel blip that has
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
|
|
40
40
|
import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
|
|
41
41
|
import { chipWhen } from "../utils/checkbackText";
|
|
42
|
+
import { currentWsBase, noteSocketClosed } from "../utils/localLink";
|
|
42
43
|
import { DigestCard } from "./DigestCard";
|
|
43
44
|
import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
|
|
44
45
|
|
|
@@ -839,11 +840,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
839
840
|
return;
|
|
840
841
|
}
|
|
841
842
|
lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
|
|
842
|
-
const
|
|
843
|
+
const base = currentWsBase(wsBase);
|
|
844
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
843
845
|
// Claim-only socket: no replay, closed as soon as the claim is sent. The
|
|
844
846
|
// watch poll then repaints at the new size.
|
|
845
847
|
const sock = new WebSocket(
|
|
846
|
-
|
|
848
|
+
base + sep + "room=" + encodeURIComponent(room)
|
|
847
849
|
+ "&name=" + encodeURIComponent((userName || "user") + " (wall)")
|
|
848
850
|
+ "&source=monitor&replay=0&cols=" + dims.cols + "&rows=" + dims.rows
|
|
849
851
|
);
|
|
@@ -970,10 +972,11 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
970
972
|
// plenty, and preview → live becomes the SAME renderer painting the same
|
|
971
973
|
// bytes at the same geometry. Full screen keeps WebGL.
|
|
972
974
|
setConn("live");
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
975
|
+
const wsUrl = () => {
|
|
976
|
+
const base = currentWsBase(wsBase);
|
|
977
|
+
return base + (base.includes("?") ? "&" : "?") + "room=" + encodeURIComponent(room) + "&name=" + encodeURIComponent(userName || "user")
|
|
976
978
|
+ "&replay=65536&nudge=0&cols=" + term.cols + "&rows=" + term.rows;
|
|
979
|
+
};
|
|
977
980
|
let ws: WebSocket | null = null;
|
|
978
981
|
let disposed = false;
|
|
979
982
|
let reconnectTimer = 0;
|
|
@@ -1030,9 +1033,12 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
1030
1033
|
if (disposed) return;
|
|
1031
1034
|
window.clearTimeout(reconnectTimer);
|
|
1032
1035
|
if (ws) { try { ws.close(); } catch { /* replacing */ } }
|
|
1033
|
-
const
|
|
1036
|
+
const url = wsUrl();
|
|
1037
|
+
const sock = new WebSocket(url);
|
|
1034
1038
|
ws = sock;
|
|
1039
|
+
let opened = false;
|
|
1035
1040
|
sock.onopen = () => {
|
|
1041
|
+
opened = true;
|
|
1036
1042
|
if (disposed || ws !== sock) return;
|
|
1037
1043
|
reconnectAttempt = 0;
|
|
1038
1044
|
markLive();
|
|
@@ -1091,6 +1097,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
1091
1097
|
} catch { /* non-JSON frame */ }
|
|
1092
1098
|
};
|
|
1093
1099
|
sock.onclose = () => {
|
|
1100
|
+
noteSocketClosed(url, opened);
|
|
1094
1101
|
if (disposed || ws !== sock) return;
|
|
1095
1102
|
scheduleReconnect();
|
|
1096
1103
|
};
|
|
@@ -2397,9 +2404,10 @@ export const SessionSwitcher = ({
|
|
|
2397
2404
|
if (session.type !== "port" && !session.active && interactiveTiles && tileWsBase) {
|
|
2398
2405
|
const key = sessionKey(session);
|
|
2399
2406
|
try {
|
|
2400
|
-
const
|
|
2407
|
+
const base = currentWsBase(tileWsBase);
|
|
2408
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
2401
2409
|
const kick = new WebSocket(
|
|
2402
|
-
|
|
2410
|
+
base + sep + "room=" + encodeURIComponent(key)
|
|
2403
2411
|
+ "&name=" + encodeURIComponent((userName || "user") + " (start)")
|
|
2404
2412
|
+ "&replay=0&nudge=0&cols=80&rows=24"
|
|
2405
2413
|
);
|
|
@@ -11,12 +11,19 @@
|
|
|
11
11
|
// services on this device"); the probe waits while that prompt is up. It runs
|
|
12
12
|
// beside the tunnel connection, so waiting costs nothing.
|
|
13
13
|
const PROBE_TIMEOUT_MS = 120_000;
|
|
14
|
-
|
|
14
|
+
// After the fast path drops (almost always a daemon restart, which takes a
|
|
15
|
+
// few seconds), look again soon and back off: 1 s, 2 s, 4 s … 30 s, then
|
|
16
|
+
// stop until the next drop. A flat 30 s left a tab on the slow tunnel and
|
|
17
|
+
// then yanked it back with a second reconnect half a minute later.
|
|
18
|
+
const RETRY_BASE_MS = 1_000;
|
|
19
|
+
const RETRY_MAX_MS = 30_000;
|
|
20
|
+
const RETRY_LIMIT = 10;
|
|
15
21
|
const OPT_OUT_KEY = "hop.localLink"; // "off" disables the fast path in this browser
|
|
16
22
|
|
|
17
23
|
let localBase: string | null = null;
|
|
18
24
|
let probing = false;
|
|
19
25
|
let retryTimer = 0;
|
|
26
|
+
let retries = -1; // -1: the fast path never worked here, so a failure is final
|
|
20
27
|
let onFound: (() => void) | null = null;
|
|
21
28
|
|
|
22
29
|
export const isLocalLinkUrl = (url: string) => /^ws:\/\/127\.0\.0\.1:\d+\//.test(url);
|
|
@@ -25,6 +32,19 @@ export const isLocalLinkUrl = (url: string) => /^ws:\/\/127\.0\.0\.1:\d+\//.test
|
|
|
25
32
|
// or null to use the page's host.
|
|
26
33
|
export const localWsBase = () => localBase;
|
|
27
34
|
|
|
35
|
+
// The base a socket should dial NOW: the fast path while it works, else the
|
|
36
|
+
// tunnel base it was given. Wall tiles and panes ask on every (re)connect —
|
|
37
|
+
// holding the fast-path url from their first render, they kept redialing it
|
|
38
|
+
// after it died (a daemon restart, a lapsed ticket) and only full screen,
|
|
39
|
+
// which falls back, could connect.
|
|
40
|
+
export const currentWsBase = (tunnelBase: string) => localBase ?? tunnelBase;
|
|
41
|
+
|
|
42
|
+
// Any terminal socket that closed: one dialed on the fast path that never
|
|
43
|
+
// opened means the fast path is down for every socket on the page.
|
|
44
|
+
export function noteSocketClosed(url: string, opened: boolean) {
|
|
45
|
+
if (!opened && isLocalLinkUrl(url)) localLinkFailed();
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
export function localLinkEligible(
|
|
29
49
|
loc: { protocol: string; hostname: string } = window.location,
|
|
30
50
|
ua: string = navigator.userAgent,
|
|
@@ -64,9 +84,18 @@ function probe(port: number, ticket: string): Promise<boolean> {
|
|
|
64
84
|
});
|
|
65
85
|
}
|
|
66
86
|
|
|
87
|
+
function scheduleRetry() {
|
|
88
|
+
if (retries < 0 || retries >= RETRY_LIMIT) return;
|
|
89
|
+
const delay = Math.min(RETRY_BASE_MS * 2 ** retries, RETRY_MAX_MS);
|
|
90
|
+
retries += 1;
|
|
91
|
+
window.clearTimeout(retryTimer);
|
|
92
|
+
retryTimer = window.setTimeout(() => void attempt(), delay);
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
async function attempt() {
|
|
68
96
|
if (probing || localBase) return;
|
|
69
97
|
probing = true;
|
|
98
|
+
let found = false;
|
|
70
99
|
try {
|
|
71
100
|
const res = await fetch("/api/local-link", { credentials: "same-origin", cache: "no-store" });
|
|
72
101
|
if (!res.ok) return;
|
|
@@ -74,11 +103,14 @@ async function attempt() {
|
|
|
74
103
|
if (!Number.isInteger(body.port) || typeof body.ticket !== "string") return;
|
|
75
104
|
if (!(await probe(body.port as number, body.ticket))) return;
|
|
76
105
|
localBase = `ws://127.0.0.1:${body.port}/ws?token=${encodeURIComponent(body.ticket)}`;
|
|
106
|
+
found = true;
|
|
107
|
+
retries = 0;
|
|
77
108
|
onFound?.();
|
|
78
109
|
} catch {
|
|
79
110
|
/* no fast path; the tunnel works as before */
|
|
80
111
|
} finally {
|
|
81
112
|
probing = false;
|
|
113
|
+
if (!found) scheduleRetry();
|
|
82
114
|
}
|
|
83
115
|
}
|
|
84
116
|
|
|
@@ -90,12 +122,11 @@ export function startLocalLink(found: () => void) {
|
|
|
90
122
|
void attempt();
|
|
91
123
|
}
|
|
92
124
|
|
|
93
|
-
// A terminal socket to the fast path closed before it opened (the daemon
|
|
94
|
-
//
|
|
95
|
-
// again a little later with a fresh ticket.
|
|
125
|
+
// A terminal socket to the fast path closed before it opened (the daemon is
|
|
126
|
+
// restarting, or stopped): use the tunnel meanwhile, and look again shortly.
|
|
96
127
|
export function localLinkFailed() {
|
|
97
128
|
if (!localBase) return;
|
|
98
129
|
localBase = null;
|
|
99
|
-
|
|
100
|
-
|
|
130
|
+
retries = 0;
|
|
131
|
+
scheduleRetry();
|
|
101
132
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { isLocalLinkUrl, localLinkEligible } from "../src/utils/localLink";
|
|
2
|
+
import { currentWsBase, isLocalLinkUrl, localLinkEligible, noteSocketClosed } from "../src/utils/localLink";
|
|
3
3
|
|
|
4
4
|
const MAC = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36";
|
|
5
5
|
const IPHONE = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148";
|
|
@@ -21,4 +21,10 @@ describe("same-machine fast path", () => {
|
|
|
21
21
|
expect(isLocalLinkUrl("ws://127.0.0.1:13575/ws?token=ll_x&room=a")).toBe(true);
|
|
22
22
|
expect(isLocalLinkUrl("wss://hop.zhoulab.io/ws?room=a")).toBe(false);
|
|
23
23
|
});
|
|
24
|
+
it("hands tiles the tunnel base while there is no fast path", () => {
|
|
25
|
+
expect(currentWsBase("wss://hop.zhoulab.io/ws")).toBe("wss://hop.zhoulab.io/ws");
|
|
26
|
+
// A tunnel socket that never opened says nothing about the fast path.
|
|
27
|
+
noteSocketClosed("wss://hop.zhoulab.io/ws?room=a", false);
|
|
28
|
+
expect(currentWsBase("wss://hop.zhoulab.io/ws")).toBe("wss://hop.zhoulab.io/ws");
|
|
29
|
+
});
|
|
24
30
|
});
|
package/hop
CHANGED
|
@@ -976,6 +976,18 @@ ensurePrivateDir(LOG_DIR);
|
|
|
976
976
|
ensurePrivateDir(WORKSPACES_DIR);
|
|
977
977
|
ensurePrivateDir(RUNTIME_DATA_DIR);
|
|
978
978
|
|
|
979
|
+
// A session name made from free text (a Codex thread title, a display name
|
|
980
|
+
// from before names were checked): runs of anything outside the name
|
|
981
|
+
// alphabet become one dash, accents fold to their letters, and an empty
|
|
982
|
+
// result falls back. "Find grant opportunities and draft" →
|
|
983
|
+
// "Find-grant-opportunities-and-draft".
|
|
984
|
+
function sessionNameFromText(text, fallback = 'session', max = 40) {
|
|
985
|
+
const slug = String(text || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
|
|
986
|
+
.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '')
|
|
987
|
+
.slice(0, max).replace(/[-_]+$/, '');
|
|
988
|
+
return slug || fallback;
|
|
989
|
+
}
|
|
990
|
+
|
|
979
991
|
function toInternalSessionName(displayName) {
|
|
980
992
|
if (!displayName || typeof displayName !== 'string') return null;
|
|
981
993
|
if (!SESSION_NAME_RE.test(displayName)) return null;
|
|
@@ -1422,7 +1434,11 @@ function isDisplayNameInUse(displayName) {
|
|
|
1422
1434
|
// the internal name so it always renames the session the user clicked.
|
|
1423
1435
|
function renameSessionDisplayName(oldDisplayName, newDisplayName, explicitInternalName) {
|
|
1424
1436
|
const resolvedOldDisplayName = resolveDisplayNameAlias(oldDisplayName);
|
|
1425
|
-
|
|
1437
|
+
// A session addressed by its internal name can always be renamed — that
|
|
1438
|
+
// is how one carrying a malformed old name (spaces, from a Codex title)
|
|
1439
|
+
// gets a valid one.
|
|
1440
|
+
const explicitOk = !!(explicitInternalName && SESSION_NAME_RE.test(explicitInternalName));
|
|
1441
|
+
if (!explicitOk && !SESSION_NAME_RE.test(resolvedOldDisplayName)) {
|
|
1426
1442
|
const err = new Error('Invalid old name');
|
|
1427
1443
|
err.status = 400;
|
|
1428
1444
|
throw err;
|
|
@@ -10651,24 +10667,30 @@ function isDirectLoopbackRequest(req) {
|
|
|
10651
10667
|
// can open a WebSocket to 127.0.0.1 with it, attaches there instead. A ticket
|
|
10652
10668
|
// stands in for the login that asked for it and only on a DIRECT loopback
|
|
10653
10669
|
// connection, so a copy is worthless off this machine; revoking the login
|
|
10654
|
-
// ends its tickets too.
|
|
10655
|
-
//
|
|
10656
|
-
|
|
10657
|
-
|
|
10670
|
+
// ends its tickets too. Signed rather than remembered: a ticket names the
|
|
10671
|
+
// login and its expiry under an HMAC keyed by the daemon's persistent
|
|
10672
|
+
// secret, so it outlives a daemon restart. (Kept in memory, every restart
|
|
10673
|
+
// knocked each tab off the fast path onto the tunnel and, half a minute
|
|
10674
|
+
// later, back again — two drops per deploy.)
|
|
10675
|
+
const LOCAL_LINK_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
10676
|
+
function localLinkSignature(body) {
|
|
10677
|
+
return crypto.createHmac('sha256', String(SESSION_SECRET)).update('local-link:' + body).digest('base64url');
|
|
10678
|
+
}
|
|
10658
10679
|
function issueLocalLinkTicket(sessionToken) {
|
|
10659
|
-
const
|
|
10660
|
-
|
|
10661
|
-
|
|
10662
|
-
|
|
10663
|
-
localLinkTickets.set(ticket, { token: sessionToken, expiresAt: now + LOCAL_LINK_TTL_MS });
|
|
10664
|
-
return ticket;
|
|
10680
|
+
const session = authSessions.verify(sessionToken);
|
|
10681
|
+
if (!session) return null;
|
|
10682
|
+
const body = `${session.id}.${(Date.now() + LOCAL_LINK_TTL_MS).toString(36)}`;
|
|
10683
|
+
return `ll_${body}.${localLinkSignature(body)}`;
|
|
10665
10684
|
}
|
|
10666
10685
|
function verifyLocalLinkTicket(ticket) {
|
|
10667
|
-
const
|
|
10668
|
-
if (!
|
|
10669
|
-
const
|
|
10670
|
-
|
|
10671
|
-
|
|
10686
|
+
const m = /^ll_([A-Za-z0-9_-]+)\.([0-9a-z]+)\.([A-Za-z0-9_-]+)$/.exec(String(ticket || ''));
|
|
10687
|
+
if (!m) return null;
|
|
10688
|
+
const body = `${m[1]}.${m[2]}`;
|
|
10689
|
+
const want = Buffer.from(localLinkSignature(body));
|
|
10690
|
+
const got = Buffer.from(m[3]);
|
|
10691
|
+
if (want.length !== got.length || !crypto.timingSafeEqual(want, got)) return null;
|
|
10692
|
+
if (parseInt(m[2], 36) <= Date.now()) return null;
|
|
10693
|
+
return authSessions.list().find((s) => s.id === m[1]) || null;
|
|
10672
10694
|
}
|
|
10673
10695
|
|
|
10674
10696
|
// Every way a request can prove itself, in one place.
|
|
@@ -12593,7 +12615,13 @@ happens until someone clicks it.
|
|
|
12593
12615
|
// says "node" and cannot), or a plain shell.
|
|
12594
12616
|
const turn = readTurnInfo(source);
|
|
12595
12617
|
const codexId = turn?.agent === 'codex' ? codexRootThreadId(turn.sessionId) : null;
|
|
12596
|
-
|
|
12618
|
+
// A session continued from the Codex app names its thread
|
|
12619
|
+
// in its record from the start; the turn file only comes
|
|
12620
|
+
// with its first finished turn in hop, and until then the
|
|
12621
|
+
// session read as a plain shell ("no claude or codex
|
|
12622
|
+
// conversation to hand off").
|
|
12623
|
+
const codexAppId = /^[0-9a-f-]{36}$/.test(String(meta?.fromCodexApp || '')) ? meta.fromCodexApp : null;
|
|
12624
|
+
const runsCodex = !!codexId || !!codexAppId
|
|
12597
12625
|
|| /codex/i.test(String(config.foregroundProcess || ''))
|
|
12598
12626
|
|| /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''));
|
|
12599
12627
|
const sourceKind = rec ? 'claude' : runsCodex ? 'codex' : 'shell';
|
|
@@ -12618,15 +12646,21 @@ happens until someone clicks it.
|
|
|
12618
12646
|
return;
|
|
12619
12647
|
}
|
|
12620
12648
|
const codexRollout = sourceKind === 'codex'
|
|
12621
|
-
|
|
12649
|
+
// The app thread itself, not hop's fork of it: a fork's
|
|
12650
|
+
// rollout points back at its parent instead of copying
|
|
12651
|
+
// the history, so it alone reads as an empty conversation.
|
|
12652
|
+
? ((codexId && findCodexRolloutById(codexId)) || (codexAppId && findCodexRolloutById(codexAppId)) || resolveCodexTranscriptByCwd(cwd))
|
|
12622
12653
|
: null;
|
|
12623
12654
|
|
|
12624
12655
|
const sourceDisplay = getSessionDisplayName(source) || source;
|
|
12625
12656
|
const suffix = !handoff ? '-fork' : wantTarget === sourceKind ? '-cont' : '-' + wantTarget;
|
|
12626
|
-
|
|
12657
|
+
// The fork's name is also its internal name, so it must be
|
|
12658
|
+
// a valid one even when the source's display name is not.
|
|
12659
|
+
const forkBase = SESSION_NAME_RE.test(sourceDisplay) ? sourceDisplay : sessionNameFromText(sourceDisplay);
|
|
12660
|
+
let forkName = forkBase + suffix;
|
|
12627
12661
|
let n = 2;
|
|
12628
12662
|
while (isDisplayNameInUse(forkName) || hopSessionExists(toInternalSessionName(forkName))) {
|
|
12629
|
-
forkName =
|
|
12663
|
+
forkName = forkBase + suffix + n;
|
|
12630
12664
|
n += 1;
|
|
12631
12665
|
}
|
|
12632
12666
|
const forkInternal = toInternalSessionName(forkName);
|
|
@@ -12653,7 +12687,7 @@ happens until someone clicks it.
|
|
|
12653
12687
|
const stage = path.join(HOME_DIR, 'uploads', forkInternal);
|
|
12654
12688
|
fs.mkdirSync(stage, { recursive: true, mode: 0o700 });
|
|
12655
12689
|
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
12656
|
-
const extractPath = path.join(stage, `handoff-from-${
|
|
12690
|
+
const extractPath = path.join(stage, `handoff-from-${forkBase}-${stamp}.md`);
|
|
12657
12691
|
writePrivateFile(extractPath, result.text);
|
|
12658
12692
|
const bytes = Buffer.byteLength(result.text);
|
|
12659
12693
|
const prompt = trajectoryExtract.handoffPrompt(extractPath, bytes, sourceKind);
|
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// Streams line by line: real transcripts run to hundreds of MB.
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
19
20
|
const readline = require('readline');
|
|
20
21
|
|
|
21
22
|
const NOISE_PREFIXES = [
|
|
@@ -170,10 +171,36 @@ async function codexRolloutId(file) {
|
|
|
170
171
|
* character budget fills — the most recent context is what the receiver
|
|
171
172
|
* needs — then harness noise is dropped.
|
|
172
173
|
*/
|
|
174
|
+
// Every file of one Codex thread, oldest first. Codex starts a new segment
|
|
175
|
+
// file (rollout-<time>-<thread>_<segment>.jsonl) partway through a long
|
|
176
|
+
// thread, so the newest file alone is only its last stretch — a handoff from
|
|
177
|
+
// it carried the final hour of a two-day conversation. Segments live in the
|
|
178
|
+
// day folders under the same sessions/ root.
|
|
179
|
+
function codexThreadSegments(file) {
|
|
180
|
+
const id = (/rollout-[0-9T-]+-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/.exec(path.basename(file)) || [])[1];
|
|
181
|
+
const sessions = path.resolve(path.dirname(file), '..', '..', '..');
|
|
182
|
+
if (!id || path.basename(sessions) !== 'sessions') return [file];
|
|
183
|
+
const found = [];
|
|
184
|
+
const list = (dir) => { try { return fs.readdirSync(dir); } catch { return []; } };
|
|
185
|
+
for (const y of list(sessions)) for (const m of list(path.join(sessions, y))) for (const d of list(path.join(sessions, y, m))) {
|
|
186
|
+
for (const n of list(path.join(sessions, y, m, d))) {
|
|
187
|
+
if (n.endsWith('.jsonl') && n.includes(`-${id}`)) found.push(path.join(sessions, y, m, d, n));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
found.sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
|
|
191
|
+
return found.includes(file) ? found : [file];
|
|
192
|
+
}
|
|
193
|
+
|
|
173
194
|
async function extractConversation(file, { maxChars = 800000, tool } = {}) {
|
|
174
195
|
const kind = tool || detectTool(file);
|
|
175
196
|
if (kind === 'gemini') throw new Error('Gemini transcripts are not supported for handoff');
|
|
176
|
-
|
|
197
|
+
let turns;
|
|
198
|
+
if (kind === 'codex') {
|
|
199
|
+
turns = [];
|
|
200
|
+
for (const f of codexThreadSegments(file)) turns.push(...await readCodexTurns(f));
|
|
201
|
+
} else {
|
|
202
|
+
turns = await readClaudeTurns(file);
|
|
203
|
+
}
|
|
177
204
|
const selected = [];
|
|
178
205
|
let total = 0;
|
|
179
206
|
for (let i = turns.length - 1; i >= 0; i--) {
|
|
@@ -207,6 +207,8 @@ async function startDaemon() {
|
|
|
207
207
|
...process.env,
|
|
208
208
|
HOP_HOME: hopHome,
|
|
209
209
|
HOP_NO_TUNNEL: '1',
|
|
210
|
+
// Never the real ~/.codex: tests plant their own rollouts here.
|
|
211
|
+
CODEX_HOME: path.join(tempDir, 'codex_home'),
|
|
210
212
|
PATH: `${binDir}:${process.env.PATH || ''}`,
|
|
211
213
|
CLOUDFLARED_LOG: cloudflaredLog
|
|
212
214
|
};
|
|
@@ -1027,6 +1029,84 @@ test('condense into the same agent hands off the trajectory to a fresh session',
|
|
|
1027
1029
|
});
|
|
1028
1030
|
|
|
1029
1031
|
|
|
1032
|
+
// A session continued from the Codex app used to carry the thread's title
|
|
1033
|
+
// verbatim ("Find grant opportunities and draft") — spaces a session name
|
|
1034
|
+
// may not hold, so renaming it and Continue-in both failed. Such a session
|
|
1035
|
+
// can still be renamed by its internal name and forked under a valid name.
|
|
1036
|
+
test('a session whose display name has spaces can be renamed and forked', async () => {
|
|
1037
|
+
const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
|
|
1038
|
+
name: 'spacedsrc',
|
|
1039
|
+
cwd: tempDir
|
|
1040
|
+
}, agentHeaders);
|
|
1041
|
+
assert.equal(create.status, 200);
|
|
1042
|
+
const internal = create.data.sessionName;
|
|
1043
|
+
const metaPath = path.join(hopHome, 'claude-sessions', `${internal}.meta`);
|
|
1044
|
+
const meta = JSON.parse(await fs.readFile(metaPath, 'utf8').catch(() => '{}'));
|
|
1045
|
+
await fs.writeFile(metaPath, JSON.stringify({ ...meta, internalName: internal, displayName: 'Find grant opportunities' }), { mode: 0o600 });
|
|
1046
|
+
await fs.writeFile(path.join(hopHome, 'claude-sessions', `${internal}.json`), JSON.stringify({
|
|
1047
|
+
sessionId: '22222222-3333-4444-5555-666666666666', cwd: tempDir, launchCmd: 'claude',
|
|
1048
|
+
updatedAt: new Date().toISOString()
|
|
1049
|
+
}), { mode: 0o600 });
|
|
1050
|
+
|
|
1051
|
+
const before = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
1052
|
+
assert.equal(before.data.sessions.find((s) => s.internalName === internal)?.displayName, 'Find grant opportunities');
|
|
1053
|
+
|
|
1054
|
+
const fork = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/fork', {
|
|
1055
|
+
internalName: internal
|
|
1056
|
+
});
|
|
1057
|
+
assert.equal(fork.status, 200, JSON.stringify(fork.data));
|
|
1058
|
+
assert.match(fork.data.internalName, /^[A-Za-z0-9_-]+$/);
|
|
1059
|
+
|
|
1060
|
+
const rename = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename', {
|
|
1061
|
+
oldName: 'Find grant opportunities', newName: 'grant-search', internalName: internal
|
|
1062
|
+
});
|
|
1063
|
+
assert.equal(rename.status, 200, JSON.stringify(rename.data));
|
|
1064
|
+
|
|
1065
|
+
for (const name of [internal, fork.data.internalName]) {
|
|
1066
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
|
|
1067
|
+
{ name, internalName: name });
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
// A session continued from the Codex app has no turn record until its first
|
|
1073
|
+
// turn finishes in hop — and until then Continue-in-Claude refused it as a
|
|
1074
|
+
// plain shell. Its record names the app thread, and that thread's rollout
|
|
1075
|
+
// is the conversation to hand off (hop's own fork of it holds only a pointer).
|
|
1076
|
+
test('a Codex-app session hands off to claude before its first turn in hop', async () => {
|
|
1077
|
+
const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
|
|
1078
|
+
name: 'codexappsrc',
|
|
1079
|
+
cwd: tempDir
|
|
1080
|
+
}, agentHeaders);
|
|
1081
|
+
assert.equal(create.status, 200);
|
|
1082
|
+
const internal = create.data.sessionName;
|
|
1083
|
+
const threadId = '01a14846-0000-7000-8000-00000000abcd';
|
|
1084
|
+
const day = path.join(daemonEnv.CODEX_HOME, 'sessions', '2026', '10', '18');
|
|
1085
|
+
await fs.mkdir(day, { recursive: true });
|
|
1086
|
+
await fs.writeFile(path.join(day, `rollout-2026-10-18T10-00-00-${threadId}.jsonl`), [
|
|
1087
|
+
{ type: 'session_meta', timestamp: '2026-10-18T10:00:00Z', payload: { id: threadId, cwd: tempDir } },
|
|
1088
|
+
{ type: 'event_msg', timestamp: '2026-10-18T10:00:01Z', payload: { type: 'user_message', message: 'find grant opportunities for the lab' } },
|
|
1089
|
+
{ type: 'response_item', timestamp: '2026-10-18T10:00:05Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'NSF MCB fits best.' }] } }
|
|
1090
|
+
].map((e) => JSON.stringify(e)).join('\n') + '\n');
|
|
1091
|
+
const metaPath = path.join(hopHome, 'claude-sessions', `${internal}.meta`);
|
|
1092
|
+
const meta = JSON.parse(await fs.readFile(metaPath, 'utf8').catch(() => '{}'));
|
|
1093
|
+
await fs.writeFile(metaPath, JSON.stringify({ ...meta, internalName: internal, fromCodexApp: threadId }), { mode: 0o600 });
|
|
1094
|
+
|
|
1095
|
+
const handoff = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/fork', {
|
|
1096
|
+
internalName: internal, target: 'claude'
|
|
1097
|
+
});
|
|
1098
|
+
assert.equal(handoff.status, 200, JSON.stringify(handoff.data));
|
|
1099
|
+
assert.equal(handoff.data.kind, 'handoff');
|
|
1100
|
+
const extract = await fs.readFile(handoff.data.extract.path, 'utf8');
|
|
1101
|
+
assert.match(extract, /find grant opportunities for the lab/);
|
|
1102
|
+
|
|
1103
|
+
for (const name of [internal, handoff.data.internalName]) {
|
|
1104
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
|
|
1105
|
+
{ name, internalName: name });
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
|
|
1030
1110
|
// The briefing generator's coverage signal: the server witnesses who was
|
|
1031
1111
|
// looking at what, and when. Only a HUMAN-viewing attach may stamp it —
|
|
1032
1112
|
// wall tiles are spectators and terminal-api clients are agents.
|
|
@@ -65,6 +65,35 @@ test('codex rollout: user turns deduped, assistant paired, id readable', async (
|
|
|
65
65
|
assert.equal(await codexRolloutId(file), 'abc-123');
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
// A long Codex thread continues in a segment file in a later day folder;
|
|
69
|
+
// the handoff reads the thread from its first file, not just the newest.
|
|
70
|
+
test('codex thread split across segment files is read whole, oldest first', async () => {
|
|
71
|
+
const dir = tmp();
|
|
72
|
+
const id = '01a14846-f12b-79b0-a607-19de9fdc0dbf';
|
|
73
|
+
const day1 = path.join(dir, '.codex', 'sessions', '2026', '10', '17');
|
|
74
|
+
const day2 = path.join(dir, '.codex', 'sessions', '2026', '10', '18');
|
|
75
|
+
fs.mkdirSync(day1, { recursive: true });
|
|
76
|
+
fs.mkdirSync(day2, { recursive: true });
|
|
77
|
+
const first = path.join(day1, `rollout-2026-10-17T00-12-51-${id}.jsonl`);
|
|
78
|
+
const later = path.join(day2, `rollout-2026-10-18T21-44-03-${id}_01a1520b-6e45-7560-af50-845d3f9983d4.jsonl`);
|
|
79
|
+
jsonl(first, [
|
|
80
|
+
{ type: 'session_meta', timestamp: '2026-10-17T00:12:51.000Z', payload: { id, cwd: '/Users/me/grants' } },
|
|
81
|
+
{ type: 'event_msg', timestamp: '2026-10-17T00:13:00.000Z', payload: { type: 'user_message', message: 'find grant opportunities' } }
|
|
82
|
+
]);
|
|
83
|
+
jsonl(later, [
|
|
84
|
+
{ type: 'session_meta', timestamp: '2026-10-18T21:44:03.000Z', payload: { id, cwd: '/Users/me/grants' } },
|
|
85
|
+
{ type: 'event_msg', timestamp: '2026-10-18T21:45:00.000Z', payload: { type: 'user_message', message: 'draft the PO email' } }
|
|
86
|
+
]);
|
|
87
|
+
// An unrelated thread in the same folder stays out.
|
|
88
|
+
jsonl(path.join(day2, 'rollout-2026-10-18T09-00-00-11111111-2222-3333-4444-555555555555.jsonl'), [
|
|
89
|
+
{ type: 'event_msg', timestamp: '2026-10-18T09:00:01.000Z', payload: { type: 'user_message', message: 'unrelated work' } }
|
|
90
|
+
]);
|
|
91
|
+
const r = await extractConversation(later, { tool: 'codex' });
|
|
92
|
+
assert.equal(r.totalTurns, 2);
|
|
93
|
+
assert.ok(r.text.indexOf('find grant opportunities') < r.text.indexOf('draft the PO email'));
|
|
94
|
+
assert.ok(!r.text.includes('unrelated work'));
|
|
95
|
+
});
|
|
96
|
+
|
|
68
97
|
test('handoff prompt names the file, its size, and the source tool', () => {
|
|
69
98
|
const p = handoffPrompt('/x/handoff.md', 5 * 1024, 'claude');
|
|
70
99
|
assert.match(p, /Read the ENTIRE file \/x\/handoff\.md \(5KB\)/);
|