hop2 0.11.43 → 0.11.45
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/rooms.ts +25 -3
- package/hay/apps/server/test/rooms.test.ts +38 -0
- package/hay/apps/web/src/App.tsx +30 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +11 -0
- package/hay/apps/web/src/utils/echoGuard.ts +26 -0
- package/hay/apps/web/test/echoGuard.test.ts +21 -0
- package/hop +263 -10
- package/package.json +1 -1
|
@@ -73,6 +73,12 @@ export type ClientInfo = {
|
|
|
73
73
|
// larger requests are a deliberate ask for scrollback DEPTH and keep the
|
|
74
74
|
// raw-tail replay. Unset = raw tail at the room default (CLI reattach).
|
|
75
75
|
replayBytes?: number;
|
|
76
|
+
// "attach" = the client claims the size AS it connects — the same
|
|
77
|
+
// election a resize message with claim:"attach" runs, but BEFORE the
|
|
78
|
+
// snapshot goes out, so the first frame the client renders is already
|
|
79
|
+
// its own size. A phone opening a session a desktop was driving used to
|
|
80
|
+
// paint the desktop's grid and reflow ~half a second later.
|
|
81
|
+
sizeClaim?: "attach";
|
|
76
82
|
// False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
|
|
77
83
|
// nudge). Wall tiles decline it: they arrive already showing the current
|
|
78
84
|
// grid (the daemon's headless screen, complete — not a raw byte tail), so
|
|
@@ -436,6 +442,13 @@ export class Room extends EventEmitter {
|
|
|
436
442
|
} satisfies ServerMessage)
|
|
437
443
|
);
|
|
438
444
|
|
|
445
|
+
// Connect-time size claim (see ClientInfo.sizeClaim): after hello, so
|
|
446
|
+
// the client can tell the resulting active_size is its own, and before
|
|
447
|
+
// the snapshot, so the replay is already at the claimed size.
|
|
448
|
+
if (info.sizeClaim === "attach") {
|
|
449
|
+
this.handleResize(client, info.cols, info.rows, "attach");
|
|
450
|
+
}
|
|
451
|
+
|
|
439
452
|
socket.send(
|
|
440
453
|
JSON.stringify({
|
|
441
454
|
type: "active_size",
|
|
@@ -1317,17 +1330,26 @@ export class Room extends EventEmitter {
|
|
|
1317
1330
|
* like the attach path so cursor/mouse state present identically too.
|
|
1318
1331
|
*/
|
|
1319
1332
|
serializeScreen(
|
|
1320
|
-
callback: (result: { data: string; cols: number; rows: number } | null) => void,
|
|
1333
|
+
callback: (result: { data: string; cols: number; rows: number; offset: number } | null) => void,
|
|
1321
1334
|
scrollback = 200
|
|
1322
1335
|
) {
|
|
1323
1336
|
if (this.outputBytes === 0) { callback(null); return; }
|
|
1324
1337
|
const grid = this.ensureGrid();
|
|
1325
1338
|
if (!grid) { callback(null); return; }
|
|
1339
|
+
// The output cursor this screen reflects: everything in the ring NOW is
|
|
1340
|
+
// queued into the grid ahead of the serialize marker, everything that
|
|
1341
|
+
// arrives later lands behind it. A consumer that keeps its own parser
|
|
1342
|
+
// (the daemon's preview grids) continues from here with /output?since=
|
|
1343
|
+
// and never misses or repeats a byte — which is what let it seed from a
|
|
1344
|
+
// whole screen instead of a raw tail cut mid-frame (a differential TUI
|
|
1345
|
+
// never re-sends the cells such a tail lost: Codex previews stayed
|
|
1346
|
+
// partially wrong until the app repainted, 2026-09-09).
|
|
1347
|
+
const offset = this.outputStart + this.outputBytes;
|
|
1326
1348
|
grid.serialize((serialized) => {
|
|
1327
1349
|
if (serialized === null) { callback(null); return; }
|
|
1328
1350
|
const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
1329
1351
|
+ (this.cursorHidden ? "\x1b[?25l" : "");
|
|
1330
|
-
callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows });
|
|
1352
|
+
callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows, offset });
|
|
1331
1353
|
}, scrollback);
|
|
1332
1354
|
}
|
|
1333
1355
|
|
|
@@ -1471,7 +1493,7 @@ export class RoomManager {
|
|
|
1471
1493
|
|
|
1472
1494
|
serializeRoomScreen(
|
|
1473
1495
|
id: string,
|
|
1474
|
-
callback: (result: { data: string; cols: number; rows: number } | null) => void,
|
|
1496
|
+
callback: (result: { data: string; cols: number; rows: number; offset: number } | null) => void,
|
|
1475
1497
|
scrollback?: number
|
|
1476
1498
|
) {
|
|
1477
1499
|
const room = this.rooms.get(id);
|
|
@@ -121,6 +121,31 @@ describe("Room", () => {
|
|
|
121
121
|
expect(ptyInstance!.resizes.length).toBe(before);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("a connect-time size claim sizes the PTY before the snapshot goes out", () => {
|
|
125
|
+
let ptyInstance: FakePty | null = null;
|
|
126
|
+
const factory: PtyFactory = () => {
|
|
127
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
128
|
+
return ptyInstance as any;
|
|
129
|
+
};
|
|
130
|
+
const manager = new RoomManager(factory);
|
|
131
|
+
const room = manager.getRoom("claim", { cols: 120, rows: 40 }, "/tmp");
|
|
132
|
+
// A desktop is driving the size, and the session has painted something
|
|
133
|
+
// (a room with no output sends no snapshot at all).
|
|
134
|
+
room.attachClient({ id: "desk", name: "Desk", colorIndex: 0, cols: 120, rows: 40 }, new FakeSocket());
|
|
135
|
+
ptyInstance!.emit("$ ls\r\nREADME.md\r\n");
|
|
136
|
+
// ...and a phone opens the session, claiming its own size as it attaches.
|
|
137
|
+
const phone = new FakeSocket();
|
|
138
|
+
room.attachClient({ id: "phone", name: "iPhone", colorIndex: 1, cols: 60, rows: 30, sizeClaim: "attach" }, phone);
|
|
139
|
+
expect(ptyInstance!.resizes.some((r) => r.cols === 60 && r.rows === 30)).toBe(true);
|
|
140
|
+
const types = readMessages(phone).map((m) => m.type);
|
|
141
|
+
const sized = readMessages(phone).findIndex((m) => m.type === "active_size" && m.cols === 60 && m.rows === 30);
|
|
142
|
+
const snapshot = types.indexOf("snapshot");
|
|
143
|
+
expect(sized).toBeGreaterThanOrEqual(0);
|
|
144
|
+
expect(snapshot).toBeGreaterThan(sized);
|
|
145
|
+
// The phone owns the size: the active_size it receives names it.
|
|
146
|
+
expect(readMessages(phone).some((m) => m.type === "active_size" && m.clientId === "phone")).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
124
149
|
it("broadcasts pty output to all clients", () => {
|
|
125
150
|
let ptyInstance: FakePty | null = null;
|
|
126
151
|
const factory: PtyFactory = () => {
|
|
@@ -863,6 +888,19 @@ describe("Room", () => {
|
|
|
863
888
|
expect(String(snapshot.data)).toContain("ROW-5");
|
|
864
889
|
});
|
|
865
890
|
|
|
891
|
+
it("serializeScreen reports the output cursor the screen reflects, so a delta consumer can continue exactly", async () => {
|
|
892
|
+
const { room, pty } = await setup("offset");
|
|
893
|
+
pty().emit("hello\r\n");
|
|
894
|
+
const first = await new Promise<any>((r) => room.serializeScreen((x) => r(x)));
|
|
895
|
+
expect(first.offset).toBe(room.getOutputSince(undefined).offset);
|
|
896
|
+
pty().emit("more");
|
|
897
|
+
const second = await new Promise<any>((r) => room.serializeScreen((x) => r(x)));
|
|
898
|
+
expect(second.offset).toBe(first.offset + 4);
|
|
899
|
+
const delta = room.getOutputSince(first.offset);
|
|
900
|
+
expect(delta.reset).toBe(false);
|
|
901
|
+
expect(delta.data).toBe("more");
|
|
902
|
+
});
|
|
903
|
+
|
|
866
904
|
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
867
905
|
const { room, pty } = await setup("capped");
|
|
868
906
|
const big = "\x1b[20;1H" + "y".repeat(4096);
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
16
|
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
17
17
|
import { tabTitle } from "./utils/tabTitle";
|
|
18
|
+
import { remoteAppOwnsScreen } from "./utils/echoGuard";
|
|
18
19
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
19
20
|
import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
|
|
20
21
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
@@ -639,6 +640,11 @@ const App = () => {
|
|
|
639
640
|
// local viewport scroll is a no-op there — touch scrolling must instead send the
|
|
640
641
|
// app its own scroll keys (PageUp/PageDown). See the mobile touch handler.
|
|
641
642
|
const remoteAltScreenRef = useRef(false);
|
|
643
|
+
// The other ways an app announces it owns the screen (see utils/echoGuard):
|
|
644
|
+
// Codex draws in a DECSTBM scroll region on the normal screen inside DEC
|
|
645
|
+
// 2026 synchronized-output frames, never entering the alternate screen.
|
|
646
|
+
const remoteScrollRegionRef = useRef(false);
|
|
647
|
+
const remoteSyncOutputSeenAtRef = useRef(0);
|
|
642
648
|
// Mouse tracking requested by the remote app (?1000/1002/1003) + SGR
|
|
643
649
|
// encoding (?1006). When both are on, touch scrolling drives the app with
|
|
644
650
|
// per-line SGR wheel events (smooth, momentum-capable) instead of Page keys.
|
|
@@ -1131,7 +1137,12 @@ const App = () => {
|
|
|
1131
1137
|
// the SHELL, where the terminal echoes char-by-char with no redraw to
|
|
1132
1138
|
// fight. (Reconcile's TUI guard still protects any in-flight echo when the
|
|
1133
1139
|
// app flips to alt-screen mid-keystroke.)
|
|
1134
|
-
&& !
|
|
1140
|
+
&& !remoteAppOwnsScreen({
|
|
1141
|
+
altScreen: remoteAltScreenRef.current,
|
|
1142
|
+
mouseReporting: remoteMouseReportingRef.current,
|
|
1143
|
+
scrollRegion: remoteScrollRegionRef.current,
|
|
1144
|
+
syncOutputSeenAt: remoteSyncOutputSeenAtRef.current
|
|
1145
|
+
}, Date.now())
|
|
1135
1146
|
&& !hasActiveOtherTypist(presenceRef.current, clientIdRef.current, typingLastSeenRef.current, Date.now())
|
|
1136
1147
|
&& (collabModeRef.current ? true : controllerIdRef.current === clientIdRef.current);
|
|
1137
1148
|
const optimisticActive = echoEnabledNow();
|
|
@@ -2172,11 +2183,27 @@ const App = () => {
|
|
|
2172
2183
|
if (ALT_SCREEN_PARAMS.has(n)) remoteAltScreenRef.current = enabled;
|
|
2173
2184
|
if (MOUSE_TRACK_PARAMS.has(n)) remoteMouseReportingRef.current = enabled;
|
|
2174
2185
|
if (n === 1006) remoteMouseSgrRef.current = enabled;
|
|
2186
|
+
// DEC 2026: a synchronized-output frame — only TUIs draw this way.
|
|
2187
|
+
if (n === 2026 && enabled) remoteSyncOutputSeenAtRef.current = Date.now();
|
|
2175
2188
|
}
|
|
2176
2189
|
return false; // let xterm's default handler apply the mode
|
|
2177
2190
|
};
|
|
2178
2191
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => trackAltScreen(params, true));
|
|
2179
2192
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, (params) => trackAltScreen(params, false));
|
|
2193
|
+
// DECSTBM (CSI t;b r): a scroll region narrower than the screen means an
|
|
2194
|
+
// app is composing the screen itself. Bare CSI r (or RIS) clears it.
|
|
2195
|
+
terminal.parser.registerCsiHandler({ final: 'r' }, (params) => {
|
|
2196
|
+
const top = Number(Array.isArray(params[0]) ? params[0][0] : params[0]) || 1;
|
|
2197
|
+
const bottomRaw = Array.isArray(params[1]) ? params[1][0] : params[1];
|
|
2198
|
+
const bottom = Number(bottomRaw) || terminal.rows;
|
|
2199
|
+
remoteScrollRegionRef.current = !(top <= 1 && bottom >= terminal.rows);
|
|
2200
|
+
return false;
|
|
2201
|
+
});
|
|
2202
|
+
terminal.parser.registerEscHandler({ final: 'c' }, () => {
|
|
2203
|
+
remoteScrollRegionRef.current = false;
|
|
2204
|
+
remoteSyncOutputSeenAtRef.current = 0;
|
|
2205
|
+
return false;
|
|
2206
|
+
});
|
|
2180
2207
|
|
|
2181
2208
|
setTerminalReady(true);
|
|
2182
2209
|
|
|
@@ -3129,6 +3156,8 @@ const App = () => {
|
|
|
3129
3156
|
internalName: s.internalName || s.name,
|
|
3130
3157
|
lastActivityAt: Number(s.lastActivityAt) || 0,
|
|
3131
3158
|
bellSeq: Number(s.bellSeq) || 0,
|
|
3159
|
+
attentionReason: typeof s.attentionReason === "string" ? s.attentionReason : "",
|
|
3160
|
+
attentionNote: typeof s.attentionNote === "string" ? s.attentionNote : "",
|
|
3132
3161
|
foregroundProcess: s.foregroundProcess,
|
|
3133
3162
|
agentPermitted: s.agentPermitted === true,
|
|
3134
3163
|
createdBy: s.createdBy === "agent" ? "agent" : "user",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "react";
|
|
13
13
|
import { Terminal } from "@xterm/xterm";
|
|
14
14
|
import { FitAddon } from "@xterm/addon-fit";
|
|
15
|
+
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
15
16
|
import { attachScrollFlywheel } from "../utils/scrollFlywheel";
|
|
16
17
|
import { ContextMenu, type MenuRequest } from "./ContextMenu";
|
|
17
18
|
import { CwdField } from "./CwdField";
|
|
@@ -473,6 +474,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
|
|
|
473
474
|
});
|
|
474
475
|
const fit = new FitAddon();
|
|
475
476
|
term.loadAddon(fit);
|
|
477
|
+
// Same as the full-screen terminal: URLs are links (line-wrapped OAuth
|
|
478
|
+
// and codex sign-in URLs included). The tile is where most reading
|
|
479
|
+
// happens now, and a URL you could click full-screen but not here read
|
|
480
|
+
// as "links don't work in hop". window.open inside the click handler
|
|
481
|
+
// keeps the popup-blocker happy; the tile's own click handler already
|
|
482
|
+
// stops the card from treating the click as a switch.
|
|
483
|
+
term.loadAddon(new WebLinksAddon((event, uri) => {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
window.open(uri, "_blank", "noopener");
|
|
486
|
+
}));
|
|
476
487
|
term.open(box);
|
|
477
488
|
termRef.current = term;
|
|
478
489
|
fitRef.current = fit;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Should optimistic local echo be suppressed because the remote app is a
|
|
2
|
+
// full-screen TUI that repaints on every keystroke?
|
|
3
|
+
//
|
|
4
|
+
// The alternate screen was the only signal. Claude Code, vim and less use
|
|
5
|
+
// it — Codex does not: its TUI draws with absolute cursor positioning in a
|
|
6
|
+
// scroll region on the NORMAL screen, wrapped in DEC 2026 synchronized-output
|
|
7
|
+
// brackets, and enables mouse reporting. Typing into it echoed a raw char
|
|
8
|
+
// that the next frame overwrote — the same flicker the alt-screen guard
|
|
9
|
+
// fixed for Claude, back for Codex. Any of these says "an app owns the
|
|
10
|
+
// screen": no echo.
|
|
11
|
+
export const SYNC_OUTPUT_FRESH_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
export type RemoteScreenSignals = {
|
|
14
|
+
altScreen: boolean;
|
|
15
|
+
mouseReporting: boolean;
|
|
16
|
+
/** A DECSTBM scroll region narrower than the full screen is in effect. */
|
|
17
|
+
scrollRegion: boolean;
|
|
18
|
+
/** Last time a DEC 2026 synchronized-output frame was seen (ms epoch), 0 = never. */
|
|
19
|
+
syncOutputSeenAt: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const remoteAppOwnsScreen = (s: RemoteScreenSignals, now: number): boolean =>
|
|
23
|
+
s.altScreen
|
|
24
|
+
|| s.mouseReporting
|
|
25
|
+
|| s.scrollRegion
|
|
26
|
+
|| (s.syncOutputSeenAt > 0 && now - s.syncOutputSeenAt < SYNC_OUTPUT_FRESH_MS);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { remoteAppOwnsScreen, SYNC_OUTPUT_FRESH_MS } from "../src/utils/echoGuard";
|
|
3
|
+
|
|
4
|
+
const shell = { altScreen: false, mouseReporting: false, scrollRegion: false, syncOutputSeenAt: 0 };
|
|
5
|
+
|
|
6
|
+
describe("optimistic echo guard", () => {
|
|
7
|
+
it("a plain shell gets local echo", () => {
|
|
8
|
+
expect(remoteAppOwnsScreen(shell, 1_000_000)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it("the alternate screen (Claude Code, vim) suppresses it", () => {
|
|
11
|
+
expect(remoteAppOwnsScreen({ ...shell, altScreen: true }, 1_000_000)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("Codex's normal-screen TUI suppresses it too: scroll region, sync frames, or mouse reporting", () => {
|
|
14
|
+
expect(remoteAppOwnsScreen({ ...shell, scrollRegion: true }, 1_000_000)).toBe(true);
|
|
15
|
+
expect(remoteAppOwnsScreen({ ...shell, mouseReporting: true }, 1_000_000)).toBe(true);
|
|
16
|
+
expect(remoteAppOwnsScreen({ ...shell, syncOutputSeenAt: 999_000 }, 1_000_000)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it("a synchronized-output frame ages out once the app is gone", () => {
|
|
19
|
+
expect(remoteAppOwnsScreen({ ...shell, syncOutputSeenAt: 1_000_000 }, 1_000_000 + SYNC_OUTPUT_FRESH_MS + 1)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
package/hop
CHANGED
|
@@ -5248,6 +5248,89 @@ function readTurnCount(internalName) {
|
|
|
5248
5248
|
// surfaces (dedupe + seen-marks already handle "one finish, one alert").
|
|
5249
5249
|
// Baselined at arm time (notifyBaseTurn) so arming a long-lived session does
|
|
5250
5250
|
// not fire a single notification for its entire back history.
|
|
5251
|
+
// The ASK record the Claude hook writes when the agent stops to ask the human
|
|
5252
|
+
// something (permission, a question, a plan to approve): {count, at, type,
|
|
5253
|
+
// message}. Always on — unlike a finished turn, a question is worth a buzz by
|
|
5254
|
+
// definition — its count rides bellSeq like a bell, and the message travels
|
|
5255
|
+
// beside it so the phone can say what is being asked.
|
|
5256
|
+
function readAsk(internalName) {
|
|
5257
|
+
try {
|
|
5258
|
+
const a = JSON.parse(fs.readFileSync(
|
|
5259
|
+
path.join(CLAUDE_SESSIONS_DIR, `${internalName}.ask`), 'utf8'));
|
|
5260
|
+
if (!a || !Number.isInteger(a.count) || a.count < 0) return null;
|
|
5261
|
+
const atMs = Date.parse(a.at);
|
|
5262
|
+
return {
|
|
5263
|
+
count: a.count,
|
|
5264
|
+
at: Number.isFinite(atMs) ? atMs : null,
|
|
5265
|
+
type: typeof a.type === 'string' ? a.type : '',
|
|
5266
|
+
message: typeof a.message === 'string' ? a.message : ''
|
|
5267
|
+
};
|
|
5268
|
+
} catch (e) { return null; }
|
|
5269
|
+
}
|
|
5270
|
+
// One read per session per second at most: /api/sessions maps every field
|
|
5271
|
+
// through askOf() and the list is polled by every client.
|
|
5272
|
+
const askMemo = new Map();
|
|
5273
|
+
function askOf(internalName) {
|
|
5274
|
+
const now = Date.now();
|
|
5275
|
+
const hit = askMemo.get(internalName);
|
|
5276
|
+
if (hit && now - hit.t < 1000) return hit.v;
|
|
5277
|
+
const v = readAsk(internalName);
|
|
5278
|
+
askMemo.set(internalName, { t: now, v });
|
|
5279
|
+
return v;
|
|
5280
|
+
}
|
|
5281
|
+
// When the agent's last turn ended, from the same .turn record (Claude's
|
|
5282
|
+
// Stop hook, Codex's notify chain). Used to attribute a bell: Codex rings
|
|
5283
|
+
// the terminal bell at the end of EVERY turn, and a bell that lands within
|
|
5284
|
+
// seconds of the turn record is "finished", not "needs you".
|
|
5285
|
+
function readTurnAt(internalName) {
|
|
5286
|
+
try {
|
|
5287
|
+
const at = Date.parse(JSON.parse(fs.readFileSync(
|
|
5288
|
+
path.join(CLAUDE_SESSIONS_DIR, `${internalName}.turn`), 'utf8')).at);
|
|
5289
|
+
return Number.isFinite(at) ? at : 0;
|
|
5290
|
+
} catch (e) { return 0; }
|
|
5291
|
+
}
|
|
5292
|
+
// The newest published view's time (ms) for a session, from the manifest
|
|
5293
|
+
// the views summary already reads. `hop view` rings one bell per publish.
|
|
5294
|
+
function latestViewAtMs(internalName) {
|
|
5295
|
+
try {
|
|
5296
|
+
const items = JSON.parse(fs.readFileSync(path.join(VIEWS_DIR, 'manifest.json'), 'utf8')).items || [];
|
|
5297
|
+
let best = 0, title = '';
|
|
5298
|
+
for (const it of items) {
|
|
5299
|
+
if (it.session !== internalName) continue;
|
|
5300
|
+
const ms = Number(it.mtime) < 1e11 ? Number(it.mtime) * 1000 : Number(it.mtime);
|
|
5301
|
+
if (ms > best) { best = ms; title = it.title || it.name || ''; }
|
|
5302
|
+
}
|
|
5303
|
+
return { at: best, title };
|
|
5304
|
+
} catch (e) { return { at: 0, title: '' }; }
|
|
5305
|
+
}
|
|
5306
|
+
// WHY a session wants attention — the thing Jian could not find when the UI
|
|
5307
|
+
// said a Codex session "needs your input" and the screen showed a finished
|
|
5308
|
+
// turn. Decided from timestamps hop already has, newest signal first:
|
|
5309
|
+
// ask the agent asked (hook record; also `hop notify`) — note = message
|
|
5310
|
+
// finished the bell landed with the turn record (Codex rings at turn end)
|
|
5311
|
+
// view the bell landed with a `hop view` publish — note = the title
|
|
5312
|
+
// bell a bell nothing explains
|
|
5313
|
+
function attentionReason(internalName, lastBellAt, lastActivityAt) {
|
|
5314
|
+
const ask = askOf(internalName);
|
|
5315
|
+
const now = Date.now();
|
|
5316
|
+
if (ask && ask.at && now - ask.at < 15 * 60 * 1000) {
|
|
5317
|
+
return { reason: 'ask', note: ask.message || '', askType: ask.type || '' };
|
|
5318
|
+
}
|
|
5319
|
+
const bell = Number(lastBellAt) || 0;
|
|
5320
|
+
if (bell > 0) {
|
|
5321
|
+
const turnAt = readTurnAt(internalName);
|
|
5322
|
+
if (turnAt && Math.abs(bell - turnAt) <= 4000) return { reason: 'finished', note: '' };
|
|
5323
|
+
const view = latestViewAtMs(internalName);
|
|
5324
|
+
if (view.at && Math.abs(bell - view.at) <= 4000) return { reason: 'view', note: view.title };
|
|
5325
|
+
// Still producing output: an agent that needs you is QUIET. Codex
|
|
5326
|
+
// rings mid-turn too (six bells in four minutes of "Working…" on
|
|
5327
|
+
// angler-codex) — those are pings, not questions.
|
|
5328
|
+
const active = Number(lastActivityAt) || 0;
|
|
5329
|
+
if (active && now - active < 15000) return { reason: 'working', note: '' };
|
|
5330
|
+
return { reason: 'bell', note: '' };
|
|
5331
|
+
}
|
|
5332
|
+
return { reason: '', note: '' };
|
|
5333
|
+
}
|
|
5251
5334
|
function finishBump(internalName, config) {
|
|
5252
5335
|
if (!config || config.notifyOnFinish !== true) return 0;
|
|
5253
5336
|
const base = Number.isInteger(config.notifyBaseTurn) ? config.notifyBaseTurn : 0;
|
|
@@ -6529,7 +6612,12 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6529
6612
|
// The same script is registered for two events: SessionStart (records the
|
|
6530
6613
|
// conversation id for `hop restore`) and Stop (bumps a per-turn counter a driver
|
|
6531
6614
|
// can wait on). See scripts/claude-session-hook.js.
|
|
6532
|
-
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop'];
|
|
6615
|
+
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse'];
|
|
6616
|
+
// Notification (permission prompts, elicitation) and PreToolUse (the two tools
|
|
6617
|
+
// that open a dialog and wait) bump the per-session ASK record — "whenever an
|
|
6618
|
+
// agent asks me a question, notify me" (Jian). PreToolUse gets a matcher, or
|
|
6619
|
+
// the script would run on every tool call for nothing.
|
|
6620
|
+
const CLAUDE_HOOK_MATCHERS = { PreToolUse: 'AskUserQuestion|ExitPlanMode' };
|
|
6533
6621
|
|
|
6534
6622
|
function getClaudeSettingsPath() {
|
|
6535
6623
|
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
@@ -6649,7 +6737,7 @@ function installClaudeHook() {
|
|
|
6649
6737
|
for (const ev of CLAUDE_HOOK_EVENTS) {
|
|
6650
6738
|
if (!Array.isArray(data.hooks[ev])) data.hooks[ev] = [];
|
|
6651
6739
|
if (!hookGroupsHaveScript(data.hooks[ev])) {
|
|
6652
|
-
data.hooks[ev].push({ matcher: '', hooks: [{ type: 'command', command }] });
|
|
6740
|
+
data.hooks[ev].push({ matcher: CLAUDE_HOOK_MATCHERS[ev] || '', hooks: [{ type: 'command', command }] });
|
|
6653
6741
|
}
|
|
6654
6742
|
}
|
|
6655
6743
|
try {
|
|
@@ -6693,6 +6781,65 @@ function removeClaudeHook() {
|
|
|
6693
6781
|
// there (e.g. Codex Computer Use) via "--chain <orig...>". TOML is edited
|
|
6694
6782
|
// line-by-line — `notify` is a top-level key, and we touch only that one line —
|
|
6695
6783
|
// so the rest of the user's config is never reserialized (and mangled).
|
|
6784
|
+
// Codex hooks (Claude-compatible hooks.json at $CODEX_HOME/hooks.json): the
|
|
6785
|
+
// PermissionRequest event fires before Codex asks the human to approve a
|
|
6786
|
+
// command or network access — the same hop hook script records it as an ASK
|
|
6787
|
+
// so the phone buzzes with Codex's own one-line reason. Parity with Claude's
|
|
6788
|
+
// Notification/PreToolUse asks. Codex requires the user to TRUST a new hook
|
|
6789
|
+
// once, in its `/hooks` screen; until then it does not run.
|
|
6790
|
+
function getCodexHooksPath() {
|
|
6791
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json');
|
|
6792
|
+
}
|
|
6793
|
+
function codexHookGroupsHaveScript(groups) {
|
|
6794
|
+
return Array.isArray(groups) && groups.some((g) => Array.isArray(g?.hooks) && g.hooks.some(
|
|
6795
|
+
(h) => h && typeof h.command === 'string' && h.command.includes('claude-session-hook.js')
|
|
6796
|
+
));
|
|
6797
|
+
}
|
|
6798
|
+
function readCodexHooks() {
|
|
6799
|
+
const p = getCodexHooksPath();
|
|
6800
|
+
try { return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
|
|
6801
|
+
catch (e) { return { path: p, data: { hooks: {} } }; }
|
|
6802
|
+
}
|
|
6803
|
+
function codexHookIsInstalled(data) {
|
|
6804
|
+
return codexHookGroupsHaveScript(data?.hooks?.PermissionRequest);
|
|
6805
|
+
}
|
|
6806
|
+
function installCodexHook() {
|
|
6807
|
+
const { path: p, data } = readCodexHooks();
|
|
6808
|
+
if (codexHookIsInstalled(data)) return { ok: true, already: true, path: p };
|
|
6809
|
+
if (!fs.existsSync(CLAUDE_HOOK_SCRIPT)) return { ok: false, error: `hook script missing: ${CLAUDE_HOOK_SCRIPT}` };
|
|
6810
|
+
data.hooks = data.hooks && typeof data.hooks === 'object' ? data.hooks : {};
|
|
6811
|
+
if (!Array.isArray(data.hooks.PermissionRequest)) data.hooks.PermissionRequest = [];
|
|
6812
|
+
data.hooks.PermissionRequest.push({
|
|
6813
|
+
matcher: '',
|
|
6814
|
+
hooks: [{ type: 'command', command: `${process.execPath} ${CLAUDE_HOOK_SCRIPT}`, timeout: 5,
|
|
6815
|
+
statusMessage: 'hop: noting the question for your phone' }]
|
|
6816
|
+
});
|
|
6817
|
+
if (!data.description) data.description = 'hop: notify the phone when Codex stops to ask (PermissionRequest)';
|
|
6818
|
+
try {
|
|
6819
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
6820
|
+
if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.hop-backup`);
|
|
6821
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
6822
|
+
return { ok: true, already: false, path: p };
|
|
6823
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
6824
|
+
}
|
|
6825
|
+
function removeCodexHook() {
|
|
6826
|
+
const { path: p, data } = readCodexHooks();
|
|
6827
|
+
if (!codexHookIsInstalled(data)) return { ok: true, already: true, path: p };
|
|
6828
|
+
const groups = data.hooks.PermissionRequest;
|
|
6829
|
+
for (const g of groups) {
|
|
6830
|
+
if (Array.isArray(g.hooks)) {
|
|
6831
|
+
g.hooks = g.hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes('claude-session-hook.js')));
|
|
6832
|
+
}
|
|
6833
|
+
}
|
|
6834
|
+
data.hooks.PermissionRequest = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
|
|
6835
|
+
if (data.hooks.PermissionRequest.length === 0) delete data.hooks.PermissionRequest;
|
|
6836
|
+
try {
|
|
6837
|
+
if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.hop-backup`);
|
|
6838
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
6839
|
+
return { ok: true, already: false, path: p };
|
|
6840
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
6841
|
+
}
|
|
6842
|
+
|
|
6696
6843
|
const CODEX_NOTIFY_SCRIPT = path.join(__dirname, 'scripts', 'codex-notify-hook.js');
|
|
6697
6844
|
function getCodexConfigPath() {
|
|
6698
6845
|
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'config.toml');
|
|
@@ -7836,6 +7983,38 @@ setInterval(() => {
|
|
|
7836
7983
|
}
|
|
7837
7984
|
}, 60_000).unref();
|
|
7838
7985
|
|
|
7986
|
+
// The host's whole current screen for a room (RIS + serialized grid + modes),
|
|
7987
|
+
// with the output cursor it reflects. Seeds a daemon preview grid without the
|
|
7988
|
+
// mid-frame holes a raw tail carries. null: host unreachable, room gone, or
|
|
7989
|
+
// an old host without the endpoint — callers fall back to the tail.
|
|
7990
|
+
function fetchHayRoomSerializedScreen(internalName) {
|
|
7991
|
+
const host = hayHostState || readHayHostStateFile();
|
|
7992
|
+
if (!host || !Number.isInteger(host.port) || host.port > 0 === false) return Promise.resolve(null);
|
|
7993
|
+
return new Promise((resolve) => {
|
|
7994
|
+
let settled = false;
|
|
7995
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
7996
|
+
const req = http.request({
|
|
7997
|
+
hostname: '127.0.0.1',
|
|
7998
|
+
port: host.port,
|
|
7999
|
+
path: `/rooms/${encodeURIComponent(internalName)}/serialized`,
|
|
8000
|
+
method: 'GET'
|
|
8001
|
+
}, (res) => {
|
|
8002
|
+
let data = '';
|
|
8003
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
8004
|
+
res.on('end', () => {
|
|
8005
|
+
if ((res.statusCode || 0) !== 200) { done(null); return; }
|
|
8006
|
+
try {
|
|
8007
|
+
const parsed = JSON.parse(data || 'null');
|
|
8008
|
+
done(parsed && typeof parsed.data === 'string' && parsed.data ? parsed : null);
|
|
8009
|
+
} catch (e) { done(null); }
|
|
8010
|
+
});
|
|
8011
|
+
});
|
|
8012
|
+
req.on('error', () => done(null));
|
|
8013
|
+
req.setTimeout(6000, () => { req.destroy(); done(null); });
|
|
8014
|
+
req.end();
|
|
8015
|
+
});
|
|
8016
|
+
}
|
|
8017
|
+
|
|
7839
8018
|
function fetchHayRoomOutputSince(internalName, since) {
|
|
7840
8019
|
const host = hayHostState || readHayHostStateFile();
|
|
7841
8020
|
if (!host || !Number.isInteger(host.port) || host.port <= 0) return Promise.resolve(null);
|
|
@@ -7988,7 +8167,7 @@ async function getRoomGridPreview(internalName) {
|
|
|
7988
8167
|
const Term = loadHeadlessTerminal();
|
|
7989
8168
|
if (!Term) return null;
|
|
7990
8169
|
let grid = roomGrids.get(internalName);
|
|
7991
|
-
|
|
8170
|
+
let res = await fetchHayRoomOutputSince(internalName, grid ? grid.offset : undefined);
|
|
7992
8171
|
if (!res) {
|
|
7993
8172
|
// Host unreachable: an existing grid still holds the last true
|
|
7994
8173
|
// screen — serve that rather than nothing.
|
|
@@ -8000,11 +8179,31 @@ async function getRoomGridPreview(internalName) {
|
|
|
8000
8179
|
return null;
|
|
8001
8180
|
}
|
|
8002
8181
|
outputSinceProbe.supported = true;
|
|
8182
|
+
// A grid that must (re)start seeds from the host's WHOLE screen, then
|
|
8183
|
+
// continues with deltas from the cursor that screen reflects. Seeding
|
|
8184
|
+
// from a bounded raw tail cut at an arbitrary byte left a differential
|
|
8185
|
+
// TUI (Codex, Claude's inline UI) with holes it never re-sent: previews
|
|
8186
|
+
// stayed partially wrong until the app repainted in full. An old host
|
|
8187
|
+
// without an offset continues from the delta call's cursor instead —
|
|
8188
|
+
// a few bytes may be skipped once, still far better than the tail.
|
|
8189
|
+
const reseed = !grid || res.reset;
|
|
8190
|
+
let seed = null;
|
|
8191
|
+
if (reseed) {
|
|
8192
|
+
const snap = await fetchHayRoomSerializedScreen(internalName);
|
|
8193
|
+
if (snap) {
|
|
8194
|
+
const from = Number.isFinite(Number(snap.offset)) ? Number(snap.offset) : res.offset;
|
|
8195
|
+
const delta = await fetchHayRoomOutputSince(internalName, from);
|
|
8196
|
+
if (delta && !delta.notFound && !delta.reset) {
|
|
8197
|
+
seed = { data: snap.data, cols: Number(snap.cols) || res.cols, rows: Number(snap.rows) || res.rows };
|
|
8198
|
+
res = delta;
|
|
8199
|
+
}
|
|
8200
|
+
}
|
|
8201
|
+
}
|
|
8003
8202
|
// Generous clamps: rendering NARROWER than the PTY re-wraps every long
|
|
8004
8203
|
// line into garbage (the old 400 clamp mangled a real 476-col session).
|
|
8005
|
-
const cols = Math.max(2, Math.min(1000, Math.floor(Number(res.cols) || 80)));
|
|
8006
|
-
const rows = Math.max(2, Math.min(200, Math.floor(Number(res.rows) || 24)));
|
|
8007
|
-
if (
|
|
8204
|
+
const cols = Math.max(2, Math.min(1000, Math.floor(Number((seed || res).cols) || 80)));
|
|
8205
|
+
const rows = Math.max(2, Math.min(200, Math.floor(Number((seed || res).rows) || 24)));
|
|
8206
|
+
if (reseed) {
|
|
8008
8207
|
disposeRoomGrid(internalName);
|
|
8009
8208
|
let term;
|
|
8010
8209
|
try {
|
|
@@ -8024,6 +8223,11 @@ async function getRoomGridPreview(internalName) {
|
|
|
8024
8223
|
}
|
|
8025
8224
|
grid = { term, serialize, offset: res.offset, cols, rows, lastUsedAt: Date.now() };
|
|
8026
8225
|
roomGrids.set(internalName, grid);
|
|
8226
|
+
if (seed) {
|
|
8227
|
+
await new Promise((resolve) => {
|
|
8228
|
+
try { term.write(seed.data, () => resolve()); } catch (e) { resolve(); }
|
|
8229
|
+
});
|
|
8230
|
+
}
|
|
8027
8231
|
if (roomGrids.size > ROOM_GRID_MAX) {
|
|
8028
8232
|
let oldestKey = null;
|
|
8029
8233
|
let oldestAt = Infinity;
|
|
@@ -11067,8 +11271,18 @@ happens until someone clicks it.
|
|
|
11067
11271
|
// adds to bellSeq so the phone treats a finished response like a
|
|
11068
11272
|
// bell (dedupe/seen-marks make it one alert per finish). Plain
|
|
11069
11273
|
// sessions are unaffected — finishBump returns 0.
|
|
11070
|
-
bellSeq: s.bellSeq + finishBump(s.internalName, getEffectiveSessionConfig(s.internalName))
|
|
11071
|
-
|
|
11274
|
+
bellSeq: s.bellSeq + finishBump(s.internalName, getEffectiveSessionConfig(s.internalName))
|
|
11275
|
+
+ (askOf(s.internalName)?.count || 0),
|
|
11276
|
+
// The bell's timestamp advances to the ask's, so clients that
|
|
11277
|
+
// key seen-markers on time (the web) see a question as new.
|
|
11278
|
+
lastBellAt: Math.max(Number(s.lastBellAt) || 0, askOf(s.internalName)?.at || 0) || s.lastBellAt,
|
|
11279
|
+
// Why it wants you (see attentionReason): ask / finished / view / bell.
|
|
11280
|
+
attentionReason: attentionReason(s.internalName, s.lastBellAt, s.lastActivityAt).reason,
|
|
11281
|
+
attentionNote: attentionReason(s.internalName, s.lastBellAt, s.lastActivityAt).note,
|
|
11282
|
+
askSeq: askOf(s.internalName)?.count || 0,
|
|
11283
|
+
askAt: askOf(s.internalName)?.at || null,
|
|
11284
|
+
askType: askOf(s.internalName)?.type || null,
|
|
11285
|
+
askMessage: askOf(s.internalName)?.message || null,
|
|
11072
11286
|
shell: s.shell,
|
|
11073
11287
|
env: s.env,
|
|
11074
11288
|
cols: s.cols,
|
|
@@ -13225,6 +13439,9 @@ happens until someone clicks it.
|
|
|
13225
13439
|
const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
|
|
13226
13440
|
const source = wsUrl.searchParams.get('source') || '';
|
|
13227
13441
|
const claimId = wsUrl.searchParams.get('claim') || null;
|
|
13442
|
+
// Connect-time size claim (hay ClientInfo.sizeClaim): the phone's
|
|
13443
|
+
// first frame at its own size, not a desktop peer's then a reflow.
|
|
13444
|
+
const sizeClaim = wsUrl.searchParams.get('sizeClaim') === 'attach' ? 'attach' : undefined;
|
|
13228
13445
|
// Client-supplied tab identity; the room evicts a same-key
|
|
13229
13446
|
// predecessor on attach (reconnect replaces its own ghost).
|
|
13230
13447
|
const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
|
|
@@ -13266,7 +13483,7 @@ happens until someone clicks it.
|
|
|
13266
13483
|
|
|
13267
13484
|
// Attach client to hay room
|
|
13268
13485
|
session.room.attachClient(
|
|
13269
|
-
{ id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows },
|
|
13486
|
+
{ id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows, sizeClaim },
|
|
13270
13487
|
hay.createSocketAdapter(ws)
|
|
13271
13488
|
);
|
|
13272
13489
|
});
|
|
@@ -15145,7 +15362,7 @@ async function attachToAllSessions(existingTunnel) {
|
|
|
15145
15362
|
const KNOWN_COMMANDS = [
|
|
15146
15363
|
'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
|
|
15147
15364
|
'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
|
|
15148
|
-
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
|
|
15365
|
+
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
|
|
15149
15366
|
'mail', 'registration', 'canonical'
|
|
15150
15367
|
];
|
|
15151
15368
|
|
|
@@ -16129,6 +16346,29 @@ async function main() {
|
|
|
16129
16346
|
// Stop hook, so "notify me when it finishes" works for Codex sessions too.
|
|
16130
16347
|
// Installs a WRAPPER into ~/.codex/config.toml that chains to any existing
|
|
16131
16348
|
// notify (Computer Use), so nothing already there breaks.
|
|
16349
|
+
if (process.argv[2] === 'codex-hook') {
|
|
16350
|
+
const sub = process.argv[3] || 'status';
|
|
16351
|
+
if (sub === 'install') {
|
|
16352
|
+
const r = installCodexHook();
|
|
16353
|
+
if (!r.ok) { log(`Failed to install Codex hook: ${r.error}`, RED); process.exit(1); }
|
|
16354
|
+
log(r.already ? `Codex PermissionRequest hook already installed (${r.path}).`
|
|
16355
|
+
: `${GREEN}Installed${RESET} Codex PermissionRequest hook → ${r.path}`, RESET);
|
|
16356
|
+
log(`Codex runs a new hook only after you trust it once: in Codex, type ${CYAN}/hooks${RESET} and choose Trust.`, DIM);
|
|
16357
|
+
process.exit(0);
|
|
16358
|
+
}
|
|
16359
|
+
if (sub === 'remove') {
|
|
16360
|
+
const r = removeCodexHook();
|
|
16361
|
+
if (!r.ok) { log(`Failed to remove Codex hook: ${r.error}`, RED); process.exit(1); }
|
|
16362
|
+
log(r.already ? `Codex PermissionRequest hook was not installed.` : `${GREEN}Removed${RESET} Codex PermissionRequest hook from ${r.path}`, RESET);
|
|
16363
|
+
process.exit(0);
|
|
16364
|
+
}
|
|
16365
|
+
const { path: p, data } = readCodexHooks();
|
|
16366
|
+
log(codexHookIsInstalled(data)
|
|
16367
|
+
? `${GREEN}✓${RESET} Codex PermissionRequest hook installed (${p}) — trusted in Codex's /hooks?`
|
|
16368
|
+
: `${YELLOW}!${RESET} Codex PermissionRequest hook not installed. Run ${CYAN}hop codex-hook install${RESET} to get a phone notification when Codex asks.`, RESET);
|
|
16369
|
+
process.exit(0);
|
|
16370
|
+
}
|
|
16371
|
+
|
|
16132
16372
|
if (process.argv[2] === 'codex-notify') {
|
|
16133
16373
|
const sub = process.argv[3] || 'status';
|
|
16134
16374
|
if (sub === 'install') {
|
|
@@ -16406,6 +16646,19 @@ async function main() {
|
|
|
16406
16646
|
log('Usage: hop notify <why you need the human>', YELLOW);
|
|
16407
16647
|
process.exit(1);
|
|
16408
16648
|
}
|
|
16649
|
+
// The message rides the ASK record too, so the phone can say WHAT is
|
|
16650
|
+
// needed instead of a bare "wants you" (see readAsk / attentionReason).
|
|
16651
|
+
const hs = process.env.HOP_SESSION;
|
|
16652
|
+
if (hs && /^[A-Za-z0-9_-]+$/.test(hs)) {
|
|
16653
|
+
try {
|
|
16654
|
+
const file = path.join(CLAUDE_SESSIONS_DIR, `${hs}.ask`);
|
|
16655
|
+
let count = 0;
|
|
16656
|
+
try { count = JSON.parse(fs.readFileSync(file, 'utf8')).count || 0; } catch (e) { /* first */ }
|
|
16657
|
+
fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
|
|
16658
|
+
fs.writeFileSync(file, JSON.stringify({ sessionId: null, count: count + 1,
|
|
16659
|
+
at: new Date().toISOString(), type: 'notify', message: msg.slice(0, 300) }));
|
|
16660
|
+
} catch (e) { /* the bell still rings */ }
|
|
16661
|
+
}
|
|
16409
16662
|
process.stdout.write(`\n\u2691 NEEDS YOU: ${msg}\n`);
|
|
16410
16663
|
process.stdout.write('\x07');
|
|
16411
16664
|
process.exit(0);
|
package/package.json
CHANGED