Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a20c146
refactor(app): annotate 14 empty catch blocks with rationale
almogdepaz May 15, 2026
46369bd
refactor(app): add type annotations to 18 primitive-param functions
almogdepaz May 15, 2026
1e9db03
refactor(app): extract diagnostic tracer into public/app-debug.ts
almogdepaz May 15, 2026
329a1f9
fix(app): remove dead displaced-banner branch in setConnState
almogdepaz May 16, 2026
51d361f
feat(types): add public/types-globals.d.ts for ambient browser globals
almogdepaz May 16, 2026
fb7cc0a
refactor(app): mark optional params optional, fix synthetic event call
almogdepaz May 16, 2026
1b1c2d0
refactor(types): cast DOM element handles to specific subtypes
almogdepaz May 16, 2026
462cf48
refactor(types): fix remaining TS2339/TS2322/TS2345 in public/
almogdepaz May 16, 2026
cc1fc44
fix(types): unify BrokerClientApi.unsubscribe return type with impl
almogdepaz May 16, 2026
c6ddcf3
feat(types): add public/tsconfig.json with relaxed strictness
almogdepaz May 16, 2026
477aebf
ci: enforce typecheck on root + public/ in tests workflow
almogdepaz May 16, 2026
6c1467a
chore: regenerate embedded public assets
almogdepaz May 16, 2026
6b02acc
fix(types): simplify errorMessage narrowing, drop redundant union in …
almogdepaz May 17, 2026
c1edfa1
chore: strip obvious comments, keep only non-self-evident ones
almogdepaz May 17, 2026
db853b0
fix: typescript-style improvements — pin typescript, remove avoidable…
almogdepaz May 17, 2026
549aeca
fix: force canvas repaint on hydration reveal (grid stale render)
almogdepaz May 17, 2026
60b6723
fix(types): type opts params for createPtyTerminalController + create…
almogdepaz May 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
- name: Check public-assets.ts is in sync with public/
run: git diff --exit-code src/public-assets.ts public/wolfpack-lib.js

- name: Typecheck (root + public/)
run: bun run typecheck

- name: Run tests
run: bun test tests/unit/ tests/snapshot/ tests/integration/api.test.ts tests/integration/ralph-api.test.ts tests/integration/ralph-aggregation.test.ts tests/integration/ws-dispatch.test.ts tests/integration/ws-terminal.test.ts tests/integration/prompt-reconnect.test.ts

Expand Down
19 changes: 11 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test": "bun test",
"test:e2e": "bunx playwright test",
"test:e2e:headed": "bunx playwright test --headed",
"typecheck": "bunx tsc --noEmit -p . && bunx tsc --noEmit -p public/",
"postinstall": "node bin/install.cjs"
},
"optionalDependencies": {
Expand Down Expand Up @@ -46,6 +47,7 @@
"@playwright/test": "^1.58.2",
"@types/ws": "^8.18.1",
"bun-types": "^1.3.10",
"playwright": "^1.58.2"
"playwright": "^1.58.2",
"typescript": "^6.0.3"
}
}
200 changes: 200 additions & 0 deletions public/app-debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// ── Diagnostic Tracer (scrolldown investigation) ──
//
// Captures timestamped events per (session@machine) attach so we can
// reconstruct the WS frame timing, prefill vs replay byte distribution,
// _writeTermData call shape, and rAF cadence during the hydration window.
//
// PURE DIAGNOSTIC. Gated behind `localStorage.wolfpackDebug = "1"` so it
// doesn't expose per-session attach metadata to any JS in the page context
// (XSS, extension, bookmarklet). When disabled, all helpers are no-ops and
// `__wfTrace` / `__wf_dumpTrace` / `__wf_clearTrace` are NOT installed on
// `window`.
//
// Read with `window.__wf_dumpTrace()` or `window.__wf_dumpTrace("sess")`
// after enabling: `localStorage.wolfpackDebug = "1"; location.reload()`.

declare global {
interface Window {
__wfTrace?: Record<string, TraceState>;
__wf_dumpTrace?: (sessionFilter?: string) => Record<string, TraceState> | undefined;
__wf_clearTrace?: () => void;
__wf_lastCrash?: CrashCapture;
}
}

export interface TraceMeta {
readonly session: string;
readonly machine: string;
readonly startWall: number;
readonly startPerf: number;
readonly [extra: string]: unknown;
}

export interface TraceEvent {
readonly t: number;
readonly kind: string;
readonly [field: string]: unknown;
}

export interface TraceState {
readonly _meta: TraceMeta;
readonly events: TraceEvent[];
_rafCount: number;
_rafActive: boolean;
}

export interface CrashCapture {
readonly session: string;
readonly cols: number | null;
readonly rows: number | null;
readonly len: number;
readonly head: readonly number[];
readonly tail: readonly number[];
readonly err: string;
readonly stack: string | undefined;
readonly ts: number;
}

const __wfTraceEnabled = (() => {
try { return localStorage.getItem("wolfpackDebug") === "1"; }
catch { return false; }
})();

const __wfTraceMaxEvents = 5000;

export const wfTraceEnabled: boolean = __wfTraceEnabled;

if (__wfTraceEnabled) window.__wfTrace = window.__wfTrace || {};

function __wfTraceKey(session: string | null | undefined, machine: string | null | undefined): string {
return (session || "?") + "@" + (machine || "");
}

export function __wfTraceStart(
session: string,
machine: string | null | undefined,
extra?: Record<string, unknown>,
): TraceState | null {
if (!__wfTraceEnabled) return null;
const key = __wfTraceKey(session, machine);
const trace: TraceState = {
_meta: {
session,
machine: machine || "",
startWall: Date.now(),
startPerf: performance.now(),
...(extra || {}),
},
events: [],
_rafCount: 0,
_rafActive: false,
};
window.__wfTrace![key] = trace;
return trace;
}

export function __wfTraceGet(
session: string | null | undefined,
machine: string | null | undefined,
): TraceState | null {
if (!__wfTraceEnabled) return null;
const key = __wfTraceKey(session, machine);
return window.__wfTrace ? window.__wfTrace[key] || null : null;
}

export function __wfTraceEvent(
trace: TraceState | null,
kind: string,
fields?: Record<string, unknown>,
): void {
if (!trace) return;
if (trace.events.length >= __wfTraceMaxEvents) return;
trace.events.push({
t: +(performance.now() - trace._meta.startPerf).toFixed(3),
kind,
...(fields || {}),
});
}

export function __wfTraceRafStart(trace: TraceState | null): void {
if (!trace || trace._rafActive) return;
trace._rafActive = true;
function tick() {
if (!trace || !trace._rafActive) return;
trace._rafCount++;
__wfTraceEvent(trace, "raf", { n: trace._rafCount });
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}

export function __wfTraceRafStop(trace: TraceState | null): void {
if (!trace) return;
trace._rafActive = false;
}

if (__wfTraceEnabled) {
window.__wf_dumpTrace = function (sessionFilter?: string) {
const all = window.__wfTrace || {};
const keys = Object.keys(all).filter(k => !sessionFilter || k.indexOf(sessionFilter) >= 0);
for (const key of keys) {
const trace = all[key];
const ev = trace.events;
const meta = trace._meta;
const sumByKind: Record<string, number> = {};
let prefillBytes = 0, replayBytes = 0, prefillFrames = 0, replayFrames = 0;
let firstPrefillT = -1, prefillDoneT = -1, firstReplayT = -1, hydratedT = -1;
for (const e of ev) {
sumByKind[e.kind] = (sumByKind[e.kind] || 0) + 1;
if (e.kind === "ws.binary") {
const size = typeof e.size === "number" ? e.size : 0;
if (e.bucket === "prefill") { prefillBytes += size; prefillFrames++; if (firstPrefillT < 0) firstPrefillT = e.t; }
else { replayBytes += size; replayFrames++; if (firstReplayT < 0) firstReplayT = e.t; }
}
if (e.kind === "prefill_done") prefillDoneT = e.t;
if (e.kind === "hydration.finish") hydratedT = e.t;
}
console.group("[wf-trace] " + key);
console.log("meta:", meta);
console.log("counts:", sumByKind);
console.log("prefill: " + prefillFrames + " frames, " + prefillBytes + " bytes, first @ " + firstPrefillT + "ms, prefill_done @ " + prefillDoneT + "ms");
console.log("replay (post-prefill_done): " + replayFrames + " frames, " + replayBytes + " bytes, first @ " + firstReplayT + "ms");
console.log("hydrated @ " + hydratedT + "ms; rAFs during attach: " + trace._rafCount);
console.log("events:", ev);
console.groupEnd();
}
return all;
};

window.__wf_clearTrace = function () {
window.__wfTrace = {};
};
}

/** Capture first WASM _term.write crash. Must NEVER throw — caller re-throws the original error. */
export function captureLastCrash(snapshot: {
session: string;
cols: number | null;
rows: number | null;
data: Uint8Array;
err: unknown;
}): void {
try {
if (window.__wf_lastCrash) return;
const { data, err } = snapshot;
window.__wf_lastCrash = {
session: snapshot.session,
cols: snapshot.cols,
rows: snapshot.rows,
len: data.length,
head: Array.from(data.slice(0, 64)),
tail: Array.from(data.slice(Math.max(0, data.length - 64))),
err: String(err),
stack: err instanceof Error ? err.stack : undefined,
ts: Date.now(),
};
console.error("[wf-crash]", snapshot.session, err, "— captured to window.__wf_lastCrash");
} catch {
// Best-effort crash capture must never mask the original terminal error.
}
}
47 changes: 37 additions & 10 deletions public/app-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,43 @@ import {

// ── Dependency injection ──

interface GridTerminalController {
readonly isConnected: boolean;
readonly hydration?: { finish(): void };
readonly term?: { options: { disableStdin: boolean; cursorBlink: boolean } };
mount(cell: HTMLElement, opts: { readonly cached?: string | null }): Promise<void>;
connect(opts?: { readonly takeControl?: boolean }): void;
reconnect(opts?: { readonly takeControl?: boolean }): void;
scheduleReconnect(): void;
sendTakeControl(): void;
forceRepaint(): void;
focus(): void;
resize(): void;
resizeWithTransition(): void;
dispose(): void;
}

interface GridSession {
readonly session: string;
readonly machine: string;
controller?: GridTerminalController | null;
_cellElement?: HTMLElement | null;
_displaced?: boolean;
_autoTakeControl?: boolean;
[field: string]: unknown;
}

interface GridDeps {
showView: (name: string, skipAnimation?: boolean) => void;
openSession: (name: string, machineUrl?: string) => void;
destroyTerminal: () => void;
initTerminal: (cached?: any) => void;
initTerminal: (cached?: string | null) => void;
backToSessions: () => void;
renderSidebar: () => void;
createPtyTerminalController: (opts: any) => any;
createConflictOverlay: (message: string, buttonLabel: string, onClick: (e: any) => void) => HTMLElement;
createPtyTerminalController: (opts: { session: string; machine?: string; [k: string]: unknown }) => GridTerminalController;
createConflictOverlay: (message: string, buttonLabel: string, onClick: (e: Event) => void) => HTMLElement;
canUseWasmTerminal?: () => boolean;
saveGridCellSnapshot?: (gs: any) => void;
saveGridCellSnapshot?: (gs: GridSession) => void;
flushGridSnapshots?: () => void;
loadSnapshot?: (machine: string, session: string) => string | null;
}
Expand Down Expand Up @@ -111,7 +137,8 @@ function createGridCell(gs, idx) {
cell.dataset.gridIndex = idx;
cell.innerHTML = '<div class="grid-cell-header"><div class="grid-cell-label">' + esc(gs.session) + '</div><div class="grid-cell-close" title="Remove from grid">&times;</div></div><div class="grid-cell-loading">Loading terminal</div>';
cell.addEventListener("click", (e) => {
if (e.target.classList.contains("grid-cell-close")) return;
const tgt = e.target as HTMLElement | null;
if (tgt && tgt.classList.contains("grid-cell-close")) return;
const sel = window.getSelection ? window.getSelection() : null;
if (sel && !sel.isCollapsed) return;
const i = parseInt(cell.dataset.gridIndex, 10);
Expand Down Expand Up @@ -232,7 +259,7 @@ export function renderGridCells() {
const existingCells = container.querySelectorAll(".grid-cell");
const existingMap = new Map();
existingCells.forEach(cell => {
const idx = parseInt(cell.dataset.gridIndex, 10);
const idx = parseInt((cell as HTMLElement).dataset.gridIndex || "0", 10);
existingMap.set(idx, cell);
});
// Track which sessions need new cells vs reuse
Expand Down Expand Up @@ -470,7 +497,7 @@ export function backFromSettings() {
deps.showView(state.viewBeforeSettings || "sessions");
}

export function addToGrid(session, machine) {
export function addToGrid(session: string, machine?: string): void {
if (!(deps.canUseWasmTerminal ? deps.canUseWasmTerminal() : isDesktop())) {
console.warn("[grid] WebAssembly unavailable — cannot open grid terminal");
return;
Expand All @@ -479,10 +506,10 @@ export function addToGrid(session, machine) {
// WebAssembly.Memory. Concurrent fit()/write() across cells produce
// out-of-bounds memory accesses that crash every terminal in the tab.
// Refuse to enter grid mode in that state and surface a visible warning.
if (typeof (window as any).createIsolatedGhostty !== "function") {
if (typeof window.createIsolatedGhostty !== "function") {
console.error("[grid] createIsolatedGhostty unavailable — grid mode disabled to prevent WASM OOB crash. Reload to pick up a newer ghostty-web bundle.");
if (typeof window !== "undefined" && typeof (window as any).alert === "function") {
(window as any).alert(
if (typeof window !== "undefined" && typeof window.alert === "function") {
window.alert(
"Grid mode is disabled in this tab.\n\n" +
"The terminal WASM bundle does not support per-cell isolation, which is required " +
"to safely show multiple terminals at once. (Older versions of ghostty-web are " +
Expand Down
Loading
Loading