Skip to content
Closed
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
4 changes: 2 additions & 2 deletions FEATURE_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ The README and release notes are the release contract. They must only claim beha
| State | What it means |
|---|---|
| Exists today | T4 owns `packages/host-wire` and `packages/host-service`; the client protocol consumes the T4-owned wire package. |
| Compatibility transition | The verified OMP integration binary still embeds the legacy host copy, and T4 retains a bounded read-only JSONL projector for that exact bridge. |
| Compatibility transition | The standalone host uses the T4 bridge when available. With an official OMP release that lacks the bridge, it discovers saved sessions through the bounded JSONL projector and exposes them as explicitly labeled, view-only compatibility sessions. |
| Planned next | Replace the embedded copy with a thin OMP launcher and authority adapter, then test supported upstream OMP versions by bridge capability instead of requiring every T4 host feature inside the fork. |

## 1. Hosts, connections, and environments

| Capability | OMP authority | T3 reference | Desktop surface and required states | Priority |
|---|---|---|---|---|
| Local host discovery | `packages/coding-agent/src/modes/rpc/rpc-mode.ts`, planned appserver health/capabilities | `apps/web/src/routes/__root.tsx`, `packages/client-runtime/src/connection/*` | Host switcher; starting, ready, version-skew, unavailable, reconnecting, read-only, upgrade-required | Launch |
| Local host discovery | OMP bridge when available; standard session files as a read-only fallback | `apps/web/src/routes/__root.tsx`, `packages/client-runtime/src/connection/*` | Host switcher; starting, ready, version-skew, unavailable, reconnecting, read-only, upgrade-required. Official OMP sessions remain readable and are labeled `OMP · view only`; runtime controls and activity status require the T4 bridge. | Launch |
| Remote tailnet host | Existing collab transport plus planned appserver; current Mac/bunker topology | `packages/tailscale/src/tailscale.ts`, `packages/ssh/src/tunnel.ts`, connection supervisor | Add by MagicDNS/IP, pair, trust, connect, revoke, latency/status; never expose tokens | Launch |
| Host capability negotiation | Planned versioned app protocol over OMP runtime | T3 contract schemas and server handshake | Show feature compatibility; disable unsupported actions with reason | Launch |
| Multi-host operation | Planned appserver registry | T3 environments/projects | Sessions grouped by host and project; host status remains visible across switches | Launch |
Expand Down
226 changes: 163 additions & 63 deletions apps/desktop/src/lifecycle.ts

Large diffs are not rendered by default.

82 changes: 68 additions & 14 deletions apps/desktop/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export class OmpAppserverCompatibilityError extends Error {
"Installed OMP is incompatible with this T4 Code build. T4 Code requires `omp appserver status --json`. Update OMP, then choose Check again.",
);
this.name = "OmpAppserverCompatibilityError";
Object.defineProperty(this, "stack", { value: undefined, enumerable: false, configurable: true });
Object.defineProperty(this, "stack", {
value: undefined,
enumerable: false,
configurable: true,
});
}
}

Expand All @@ -63,8 +67,16 @@ export interface OmpExecutableDiscoveryOptions {
readonly maxOutputBytes?: number;
}

export interface OmpAppserverProbeOptions
extends Omit<OmpExecutableDiscoveryOptions, "homeDirectory"> {
export interface T4HostExecutableDiscoveryOptions {
readonly environment?: NodeJS.ProcessEnv;
readonly homeDirectory?: string;
readonly packagedExecutable?: string;
}

export interface OmpAppserverProbeOptions extends Omit<
OmpExecutableDiscoveryOptions,
"homeDirectory"
> {
readonly profileId?: string;
}

Expand All @@ -90,7 +102,9 @@ function isAppserverStatus(value: unknown): boolean {
value.health.epoch.length > 0
);
}
return value.reason === "unreachable" || value.reason === "malformed" || value.reason === "failed";
return (
value.reason === "unreachable" || value.reason === "malformed" || value.reason === "failed"
);
}

type AppserverProbeState = "running" | "stopped" | "incompatible" | false;
Expand Down Expand Up @@ -129,10 +143,7 @@ async function probesAppserverStatus(
/flag provided but not defined\s*:\s*-json\b/iu.test(diagnosticOutput)
)
return "incompatible";
if (
(result.exitCode !== 0 && result.exitCode !== 1) ||
result.stderr.trim().length > 0
)
if ((result.exitCode !== 0 && result.exitCode !== 1) || result.stderr.trim().length > 0)
return false;
let parsed: unknown;
try {
Expand All @@ -156,7 +167,8 @@ export async function discoverOmpExecutable(
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 undefined;
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024) return undefined;
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024)
return undefined;
const candidates: string[] = [];
const explicit = environment.OMP_EXECUTABLE;
if (explicit !== undefined && explicit.length > 0) candidates.push(explicit);
Expand Down Expand Up @@ -189,14 +201,57 @@ export async function discoverOmpExecutable(
} catch {
continue;
}
const state = await probesAppserverStatus(candidate, environment, runner, timeoutMs, maxOutputBytes);
const state = await probesAppserverStatus(
candidate,
environment,
runner,
timeoutMs,
maxOutputBytes,
);
if (state === "running" || state === "stopped") return candidate;
if (state === "incompatible") incompatible = true;
}
if (incompatible) throw new OmpAppserverCompatibilityError();
return undefined;
}

export async function discoverT4HostExecutable(
options: T4HostExecutableDiscoveryOptions = {},
): Promise<string | undefined> {
const environment = options.environment ?? process.env;
const home = options.homeDirectory ?? homedir();
const candidates = [
options.packagedExecutable,
environment.T4_HOST_EXECUTABLE,
...(environment.PATH ?? "")
.split(":")
.filter(Boolean)
.slice(0, 64)
.map((entry) => join(entry, "t4-host")),
join(home, ".local", "bin", "t4-host"),
join(home, "bin", "t4-host"),
"/usr/local/bin/t4-host",
"/usr/bin/t4-host",
];
const seen = new Set<string>();
for (const candidate of candidates) {
if (
!candidate ||
seen.has(candidate) ||
!candidate.startsWith("/") ||
candidate.includes("\0") ||
!candidate.endsWith("/t4-host")
)
continue;
seen.add(candidate);
try {
await access(candidate, fsConstants.X_OK);
return candidate;
} catch {}
}
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,
Expand Down Expand Up @@ -257,22 +312,21 @@ export async function probeOmpAppserver(
return false;
}
return (
await probesAppserverStatus(
(await probesAppserverStatus(
executable,
environment,
runner,
timeoutMs,
maxOutputBytes,
options.profileId,
)
) === "running";
)) === "running"
);
}
export interface NodeServiceRunnerOptions {
readonly environment?: NodeJS.ProcessEnv;
readonly runner?: ProcessRunner;
}


export class NodeServiceRunner implements ServiceRunner {
private readonly runner: ProcessRunner;
private readonly environment: NodeJS.ProcessEnv;
Expand Down
Loading
Loading