Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 43 additions & 0 deletions apps/desktop/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { discoverNativeOmpProfiles } from "./local-profiles.ts";
import {
createSafeServiceEnvironment,
discoverOmpExecutable,
inspectPathOmpCompatibility,
OmpAppserverCompatibilityError,
type PathOmpCompatibility,
probeOmpAppserver,
} from "./service.ts";

Expand Down Expand Up @@ -46,6 +48,7 @@ export interface DoctorRuntime {
readonly sourceContract: () => Promise<SourceContract>;
readonly pnpmVersion: () => Promise<string | null>;
readonly discoverOmp: () => Promise<string | undefined>;
readonly inspectPathOmp: () => Promise<PathOmpCompatibility>;
readonly probeOmp: (executable: string) => Promise<boolean>;
readonly profileCount: () => Promise<number>;
readonly inspectTailnet: () => Promise<TailnetInspection>;
Expand Down Expand Up @@ -193,6 +196,7 @@ export function createDoctorRuntime(): DoctorRuntime {
sourceContract: () => readSourceContract(),
pnpmVersion: installedPnpmVersion,
discoverOmp: () => discoverOmpExecutable(),
inspectPathOmp: () => inspectPathOmpCompatibility(),
probeOmp: (executable) => probeOmpAppserver(executable),
profileCount: async () => (await discoverNativeOmpProfiles()).length,
inspectTailnet,
Expand Down Expand Up @@ -324,6 +328,45 @@ export async function collectDoctorReport(
);
}

const pathOmp = await runtime.inspectPathOmp().catch(() => "unavailable" as const);
const pathOmpChecks: Record<PathOmpCompatibility, DoctorCheck> = {
compatible: check(
"terminal-omp",
"OMP commands",
"pass",
"Every omp command found on PATH provides the appserver contract T4 requires.",
),
incompatible: check(
"terminal-omp",
"OMP commands",
"warning",
"The omp commands found on PATH are too old for T4 live ownership signals. T4 can still open saved history, but a running task may look idle or update in chunks.",
`Install the verified OMP integration (${contract.ompTag}) in every shell or app launch path: ${contract.ompUrl}`,
),
missing: check(
"terminal-omp",
"OMP commands",
"warning",
"No omp command was found on PATH.",
`Install the verified OMP integration (${contract.ompTag}) before starting tasks from another shell or app: ${contract.ompUrl}`,
),
mixed: check(
"terminal-omp",
"OMP commands",
"warning",
"Different omp commands are installed, and they do not all pass T4's appserver check. A task can therefore look live in one app but idle or delayed in another.",
`Update or remove stale OMP copies so every shell and app uses the verified integration (${contract.ompTag}): ${contract.ompUrl}`,
),
unavailable: check(
"terminal-omp",
"OMP commands",
"warning",
"The omp commands found on PATH could not be verified safely.",
`Check that every shell and app uses the verified OMP integration (${contract.ompTag}): ${contract.ompUrl}`,
),
};
checks.push(pathOmpChecks[pathOmp]);

if (executable !== undefined) {
const running = await runtime.probeOmp(executable).catch(() => false);
checks.push(
Expand Down
56 changes: 55 additions & 1 deletion apps/desktop/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ export function createSafeServiceEnvironment(
return safeEnvironment;
}

const APP_SERVER_PROBE_TIMEOUT_MS = 1_500;
// Cold OMP startup can exceed 1.5 seconds on macOS. A shorter deadline can
// reject the verified runtime before it returns a healthy status response.
const APP_SERVER_PROBE_TIMEOUT_MS = 3_000;
const APP_SERVER_PROBE_MAX_OUTPUT_BYTES = 16 * 1024;

export class OmpAppserverCompatibilityError extends Error {
Expand Down Expand Up @@ -66,6 +68,13 @@ export interface OmpAppserverProbeOptions
readonly profileId?: string;
}

export type PathOmpCompatibility =
| "compatible"
| "incompatible"
| "missing"
| "mixed"
| "unavailable";

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
Expand Down Expand Up @@ -188,6 +197,51 @@ export async function discoverOmpExecutable(
return undefined;
}

/**
* Check every `omp` command on PATH. T4 may have its own compatible bundled
* runtime while another shell or app launch path selects an older build,
* which makes cross-app activity look idle or arrive in chunks.
*/
export async function inspectPathOmpCompatibility(
options: Omit<OmpExecutableDiscoveryOptions, "homeDirectory"> = {},
): Promise<PathOmpCompatibility> {
const environment = options.environment ?? process.env;
const runner = options.runner ?? new NodeProcessRunner();
const timeoutMs = options.timeoutMs ?? APP_SERVER_PROBE_TIMEOUT_MS;
const maxOutputBytes = options.maxOutputBytes ?? APP_SERVER_PROBE_MAX_OUTPUT_BYTES;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10_000) return "unavailable";
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024)
return "unavailable";

const entries = (environment.PATH ?? "")
.split(":")
.filter((entry) => entry.startsWith("/") && !entry.includes("\0"))
.slice(0, 64);
const seen = new Set<string>();
let compatible = 0;
let incompatible = 0;
let unavailable = 0;
for (const entry of entries) {
const candidate = join(entry, "omp");
if (seen.has(candidate)) continue;
seen.add(candidate);
try {
await access(candidate, fsConstants.X_OK);
} catch {
continue;
}
const state = await probesAppserverStatus(candidate, environment, runner, timeoutMs, maxOutputBytes);
if (state === "running" || state === "stopped") compatible += 1;
else if (state === "incompatible") incompatible += 1;
else unavailable += 1;
}
if (compatible > 0 && (incompatible > 0 || unavailable > 0)) return "mixed";
if (compatible > 0) return "compatible";
if (incompatible > 0) return "incompatible";
if (unavailable > 0) return "unavailable";
return "missing";
}

export async function probeOmpAppserver(
executable: string,
options: OmpAppserverProbeOptions = {},
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/test/desktop-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ProcessRunner, ProcessSpec } from "@t4-code/remote";
import {
createSafeServiceEnvironment,
discoverOmpExecutable,
inspectPathOmpCompatibility,
NodeServiceRunner,
OmpAppserverCompatibilityError,
probeOmpAppserver,
Expand Down Expand Up @@ -91,6 +92,52 @@ describe("desktop lifecycle boundaries", () => {
expect(error.message.includes("requires `omp appserver status --json`")).toBe(true);
expect(calls).toBe(1);
});
it("detects mixed PATH candidates instead of blessing only the first compatible one", async () => {
const root = await mkdtemp(join(tmpdir(), "t4-desktop-"));
const oldDir = join(root, "old");
const newDir = join(root, "new");
await mkdir(oldDir);
await mkdir(newDir);
await writeFile(join(oldDir, "omp"), "");
await writeFile(join(newDir, "omp"), "");
await chmod(join(oldDir, "omp"), 0o755);
await chmod(join(newDir, "omp"), 0o755);
let calls = 0;
const runner: ProcessRunner = {
spawn: async (spec) => {
calls += 1;
if (spec.command === join(newDir, "omp")) {
return {
kill: () => {},
result: Promise.resolve({
exitCode: 0,
signal: null,
stdout: JSON.stringify({ state: "stopped", reason: "unreachable" }),
stderr: "",
stdoutTruncated: false,
stderrTruncated: false,
}),
};
}
return {
kill: () => {},
result: Promise.resolve({
exitCode: 2,
signal: null,
stdout: "",
stderr: "Error: unknown flag: --json",
stdoutTruncated: false,
stderrTruncated: false,
}),
};
},
};

expect(
await inspectPathOmpCompatibility({ environment: { PATH: `${oldDir}:${newDir}` }, runner }),
).toBe("mixed");
expect(calls).toBe(2);
});
it("scopes named appserver probes without inheriting provider credentials", async () => {
const root = await mkdtemp(join(tmpdir(), "t4-desktop-"));
const executable = join(root, "omp");
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/test/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function runtime(overrides: Partial<DoctorRuntime> = {}): DoctorRuntime {
sourceContract: async () => contract,
pnpmVersion: async () => "11.10.0",
discoverOmp: async () => "/opt/omp/bin/omp",
inspectPathOmp: async () => "compatible",
probeOmp: async () => true,
profileCount: async () => 3,
inspectTailnet: async () => "ready",
Expand Down Expand Up @@ -56,13 +57,26 @@ describe("T4 setup doctor", () => {
["node", "pass"],
["pnpm", "pass"],
["omp", "pass"],
["terminal-omp", "pass"],
["appserver", "pass"],
["profiles", "pass"],
["tailscale", "pass"],
]);
expect(formatDoctorReport(report)).toContain("Required setup checks passed.");
});

it("warns when shells and apps can resolve different OMP builds", async () => {
const report = await collectDoctorReport(
runtime({ inspectPathOmp: async () => "mixed" }),
);
const terminalOmp = report.checks.find((item) => item.id === "terminal-omp");

expect(report.ok).toBe(true);
expect(terminalOmp).toMatchObject({ status: "warning", label: "OMP commands" });
expect(terminalOmp?.detail).toContain("look live in one app but idle or delayed in another");
expect(terminalOmp?.action).toContain(contract.ompTag);
});

it("explains incompatible tools without exposing executable paths or raw errors", async () => {
const report = await collectDoctorReport(
runtime({
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/Rail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,16 @@ import {
import { useWorkspace, workspaceStore } from "../state/store-instance.ts";
import { SessionListTabs } from "./SessionListTabs.tsx";

function describeSessionState(session: WorkspaceSession): string {
export function describeSessionState(session: WorkspaceSession): string {
if (session.freshness === "offline") return "Offline";
if (session.freshness === "cached") return "Cached";
// Owner kind is never proven across the wire, so labels stay generic —
// and only a confirmed live lock reads "Active elsewhere".
if (session.control !== undefined) return presentSessionControlKind(session.control).railLabel;
return session.status === null ? "Idle" : "";
if (session.status !== null) return "";
if (session.lifecycle === "idle") return "Idle";
if (session.lifecycle === "closed") return "Stopped";
return "Status unknown";
}

type SessionDialog = "rename" | "terminate" | "delete" | null;
Expand Down Expand Up @@ -138,7 +141,7 @@ function SessionRowItem({
const { session } = row;
const pinned = useWorkspace((state) => state.pinnedSessionIds[session.id] === true);
const stateLabel = describeSessionState(session);
const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "idle");
const ariaState = stateLabel !== "" ? stateLabel : (session.status ?? "Status unknown");
const [menuOpen, setMenuOpen] = useState(false);
const [dialog, setDialog] = useState<SessionDialog>(null);
const [renameValue, setRenameValue] = useState(session.title);
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/SessionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ import {
type ExportContent,
type ExportMeta,
} from "../features/transcript/export.ts";
import { FreshnessBadge, SessionMain, SessionOwnershipBadge } from "../features/transcript/SessionMain.tsx";
import {
FreshnessBadge,
SessionLifecycleBadge,
SessionMain,
SessionOwnershipBadge,
} from "../features/transcript/SessionMain.tsx";
import { RIGHT_PANE_DOCK_QUERY, useMediaQuery } from "../hooks/useMediaQuery.ts";
import { rendererPlatform, useWorkspace, workspaceStore } from "../state/store-instance.ts";
import { useDesktopRuntimeSnapshot } from "../platform/desktop-runtime.ts";
Expand Down Expand Up @@ -406,6 +411,7 @@ export function SessionScreen({
)}
<span className="shrink-0">
<FreshnessBadge session={session} />
<SessionLifecycleBadge session={session} />
<SessionOwnershipBadge session={session} />
</span>
{previewCount > 0 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe("presentSessionControl", () => {
}
const live = presentSessionControl({ mode: "observer", lockStatus: "live", transcript: "live" });
expect(live.bannerDetail).toBe(
"Finished lines land here as the other app saves them. To continue here, run /continue-in-t4 in the other app — or just exit it.",
"Following saved output, not a token-by-token stream. Finished steps appear here as the other app saves them. To continue here, run /continue-in-t4 in the other app — or just exit it.",
);
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/session-runtime/session-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function presentSessionControl(state: SessionControlState): SessionContro
return {
railLabel: "Active elsewhere",
bannerTitle: "Active in another app",
bannerDetail: `Finished lines land here as the other app saves them. ${TRANSFER_HINT}`,
bannerDetail: `Following saved output, not a token-by-token stream. Finished steps appear here as the other app saves them. ${TRANSFER_HINT}`,
bannerBusy: false,
composerReason: OBSERVER_COMPOSER_REASON,
cancelReason: "Only the app running this session can stop it.",
Expand Down
Loading
Loading