Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
2 changes: 1 addition & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Older `ade runtime ...` and `ade sync pin ...` command aliases remain available

## Project registry

The ADE brain owns a per-machine project catalog at `$ADE_HOME/projects.json` (`ProjectRegistry` in `src/services/projects/projectRegistry.ts`). A project record carries a stable `projectId` (`project_<sha256(rootPath)[..24]>`), root path, display name, `addedAt`, `lastOpenedAt`, and the resolved git origin URL.
The ADE brain owns a per-machine project catalog at `$ADE_HOME/projects.json` (`ProjectRegistry` in `src/services/projects/projectRegistry.ts`). A project record carries a stable `projectId` (`project_<sha256(rootPath)[..24]>`), root path, display name, `addedAt`, `lastOpenedAt`, and the resolved git origin URL. Each record also has a `catalogVisibility` (`recent` vs `system`) and a `registrationSource`: explicit commands (`ade projects add`, `ade init`) register as `recent`/`cli-explicit`, while runtime auto-registration (an agent attaching to run a project-scoped command) registers as `system`/`runtime-auto` so it does not surface as a recent project. `ade projects list --text` shows a `visibility` column so all rows, including `system` ones, are visible to the operator.

Manage the registry through typed CLI commands:

Expand Down
17 changes: 14 additions & 3 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ export type AdeRuntimeSyncOptions = {
foreignChatProvider?: Parameters<typeof createSyncService>[0]["foreignChatProvider"];
personalChatScope?: Parameters<typeof createSyncService>[0]["personalChatScope"];
remoteCommandExecutor?: Parameters<typeof createSyncService>[0]["remoteCommandExecutor"];
getAccountDirectoryHealth?: Parameters<typeof createSyncService>[0]["getAccountDirectoryHealth"];
/**
* Brain-level websocket listener shared by every project scope's sync host
* so connected phones survive hosted-project switches. Owned (created and
Expand Down Expand Up @@ -1516,8 +1517,8 @@ export async function createAdeRuntime(args: {
// ONE tunnel client per machine (keyed by the config file): per-scope
// instances would re-register the same machineKey with the relay on every
// project open and churn the connection paired phones dial through.
const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () =>
createSyncTunnelClientService({
const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () => {
const service = createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
Expand All @@ -1543,7 +1544,16 @@ export async function createAdeRuntime(args: {
return null;
}
},
}));
});
resolvedArgs.syncRuntime?.sharedSyncListener?.onLoopbackValidated(() => {
void service.validateCurrentBridge().catch((error) => {
logger.warn("sync.tunnel_bridge_validation_failed", {
error: error instanceof Error ? error.message : String(error),
});
});
});
return service;
});
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
// host socket per machineKey (last wins), so a headless one-shot CLI
Expand All @@ -1567,6 +1577,7 @@ export async function createAdeRuntime(args: {
usageTrackingService,
productAnalyticsService,
logger,
getAccountDirectoryHealth: resolvedArgs.syncRuntime.getAccountDirectoryHealth,
accountAuthService,
projectId: resolvedArgs.syncRuntime.registryProjectId ?? projectId,
runtimeProjectId: projectId,
Expand Down
92 changes: 91 additions & 1 deletion apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applySyncWebPairingFlags,
automaticProjectRegistrationParams,
buildAdeCodeArgs,
buildCliPlan,
checkLinearReadiness,
Expand All @@ -15,6 +16,7 @@ import {
formatOutput,
graphWaitState,
inferFormatter,
includeHostProjectInCatalog,
isEphemeralRuntimeSocketPath,
isFailedServiceManagerResult,
machineRuntimeMismatchReason,
Expand Down Expand Up @@ -130,6 +132,32 @@ function writeSyncHostSingletonLock(args: {
}

describe("ADE CLI", () => {
it("includes the system host in the mobile catalog without exposing other system projects", () => {
const projects = [
{ projectId: "project_recent", catalogVisibility: "recent" as const },
{ projectId: "project_system_host", catalogVisibility: "system" as const },
{ projectId: "project_system_other", catalogVisibility: "system" as const },
];
const recentProjects = projects.filter(
(project) => project.catalogVisibility === "recent",
);
const hostProject = projects.find(
(project) => project.projectId === "project_system_host",
)!;

expect(
includeHostProjectInCatalog(recentProjects, hostProject).map(
(project) => project.projectId,
),
).toEqual(["project_recent", "project_system_host"]);
expect(
includeHostProjectInCatalog(
[...recentProjects, hostProject],
hostProject,
).map((project) => project.projectId),
).toEqual(["project_recent", "project_system_host"]);
});

it("builds projectless account commands and reports the signed-out local-first message", () => {
const statusPlan = expectExecutePlan(buildCliPlan(["auth", "status"]));
expect(statusPlan).toMatchObject({
Expand Down Expand Up @@ -773,7 +801,11 @@ describe("ADE CLI", () => {
{
key: "result",
method: "projects.add",
params: { rootPath: "/tmp/project" },
params: {
rootPath: "/tmp/project",
catalogVisibility: "recent",
registrationSource: "cli-explicit",
},
},
],
});
Expand Down Expand Up @@ -825,6 +857,11 @@ describe("ADE CLI", () => {
expect(lanes.kind).toBe("execute");
if (lanes.kind !== "execute") return;
expect(shouldAutoRegisterProjectForPlan(lanes)).toBe(true);
expect(automaticProjectRegistrationParams("/tmp/project")).toEqual({
rootPath: "/tmp/project",
catalogVisibility: "system",
registrationSource: "runtime-auto",
});
});

it("builds sync status and pairing PIN commands", () => {
Expand All @@ -835,6 +872,7 @@ describe("ADE CLI", () => {
]);
expect(status.kind).toBe("execute");
if (status.kind !== "execute") return;
expect(status.formatter).toBe("sync-status");
expect(status.steps).toEqual([
{
key: "result",
Expand Down Expand Up @@ -967,6 +1005,58 @@ describe("ADE CLI", () => {
}
});

it("formats account-directory publisher health in sync status text", () => {
const plan = expectExecutePlan(buildCliPlan(["sync", "status"]));
const output = formatOutput({
mode: "brain",
role: "brain",
runtimeRole: "host",
runtimeName: "Studio",
connectedPeers: [],
routeHealth: {
listener: {
listenerBound: true,
loopbackAdeValidated: true,
port: 8787,
reason: null,
},
tailscale: { enabled: false, tailscaleReachable: false, reason: null },
relay: { enabled: true, relayControlConnected: true, relayBridgeValidated: false, reason: "Relay bridge is not validated." },
accountDirectory: {
state: "http_error",
skipReason: "The account directory returned HTTP 401.",
directoryOrigin: "https://directory.example",
lastAttemptAt: Date.parse("2026-07-16T12:00:00.000Z"),
lastSuccessAt: null,
lastHttpStatus: 401,
reachableEndpointCount: 2,
},
},
transferReadiness: {
ready: false,
blockers: [{
kind: "terminal_session",
id: "terminal-1",
label: "Shell",
detail: "Stop the active shell before transferring the host.",
}],
survivableState: ["Paused chats"],
},
survivableStateText: "Paused chats remain available.",
blockingStateText: "Live processes must stop first.",
}, { text: true } as any, inferFormatter(plan));

expect(output).toContain("ADE sync status");
expect(output).toContain("account directory");
expect(output).toContain("http_error · 2 reachable endpoints · HTTP 401");
expect(output).toContain("The account directory returned HTTP 401.");
expect(output).toContain("https://directory.example");
expect(output).toContain("blocked by 1 active item");
expect(output).toContain("Shell: Stop the active shell before transferring the host.");
expect(output).toContain("Paused chats remain available.");
expect(output).toContain("Live processes must stop first.");
});

it("formats sync web pairing info from sync status", () => {
const plan = expectExecutePlan(buildCliPlan(["sync", "web"]));
const connection = {
Expand Down
Loading