Skip to content

Commit dd19202

Browse files
authored
Ade Code Remote (#581)
* commit from ade code * fix(ade-code): trust remote lane worktree availability * follow up fix * fix(ade-code): complete remote launch and bundled skills * test(ios): cover context compaction lifecycle * fix(ade-code): harden remote session bridge * fix(tui): close cross-turn compaction blocks * fix: address review follow-ups * fix(ade-code): preserve ambiguous remote project errors * fix(opencode): dedupe compaction parts without ids
1 parent 9b79c48 commit dd19202

55 files changed

Lines changed: 4151 additions & 365 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/ade-cli/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Default routing for typed commands: prefer the machine brain endpoint if reachab
2525
| `$ADE_HOME/projects.json` | Project catalog. |
2626
| `~/.ade/secrets/` | Machine credential store (`credentials.safe.enc` for desktop safeStorage, `credentials.json.enc` plus `.machine-key` for headless fallback storage, and per-store `*.lock` files). |
2727
| `~/.ade/bin/ade` | Bundled static runtime binary (release installs / remote uploads). |
28+
| `~/.ade/agent-skills/` | Bundled, version-locked ADE agent skills. Desktop remote bootstrap uploads this beside the remote runtime; CLI launch then re-seeds ADE-managed skills into runtime-native home skill directories. |
2829
| `~/.ade/runtime/<platform-arch>/` | Native node modules for that runtime binary. |
2930
| `~/.ade/runtime/launchd.{out,err}.log` | Runtime stdout/stderr when running as a login service on macOS. |
3031

@@ -211,10 +212,20 @@ The `sync.connectToBrain`, `sync.disconnectFromBrain`, and `sync.transferBrainTo
211212
ade code # attach to the machine brain, auto-spawn it if missing
212213
ade code --embedded # force the in-process embedded runtime
213214
ade code --print-state # smoke-test the connection and exit
215+
ade code remote --target mac --project ADE
216+
# attach to a saved desktop remote machine
217+
ade code remote session --target mac --project ADE --session chat-1
218+
# open a remote chat or Claude terminal session
214219
ade --socket /path/to/ade.sock code # attach to a specific local endpoint
215220
ade --project-root /repo code # bind to a specific project root
216221
```
217222

223+
`ade code remote` reads the same saved remote-machine registry as desktop ADE,
224+
starts `ade rpc --stdio` over SSH, and bridges it back into the normal TUI with
225+
`--remote`, `--remote-label`, `--require-socket`, remote project roots, and an
226+
optional `--session` hint. Use `--list-targets`, `--list-projects`, and
227+
`--list-sessions` for non-interactive discovery.
228+
218229
**Browser mirror (dev):** from the repo root, `npm run dev:code:web` runs **one** `ade code` in a **single PTY** and mirrors that TTY to the browser (xterm). Use Cursor’s browser tools against that page like any other local URL. This is not the same as running `ade code` in a terminal app **and** in the browser at once—that would be two separate processes.
219230

220231
See `docs/features/ade-code/README.md` for the full attach/embedded handshake, slash command catalog, and right-pane drawers.

apps/ade-cli/src/cli.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ import type { AdeRuntime } from "./bootstrap";
7979
import { reseedBundledAdeSkillsForCli } from "./bootstrap";
8080
import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
8181
import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol";
82+
import {
83+
runAdeCodeRemote,
84+
takeAdeCodeRemoteArgs,
85+
} from "./tuiClient/remoteLauncher";
8286

8387
type JsonObject = Record<string, unknown>;
8488

@@ -1138,6 +1142,15 @@ const HELP_BY_COMMAND: Record<string, string> = {
11381142
$ ade code --require-socket Fail instead of starting an embedded runtime when no runtime endpoint exists
11391143
$ ade code --socket /tmp/ade.sock Attach to a specific local endpoint
11401144
$ ade code --lane <id|name|branch> Launch focused on a specific lane
1145+
$ ade code remote --target <machine> --project <project>
1146+
Launch against a saved desktop remote machine
1147+
$ ade code remote session --target <machine> --project <project> --session <session>
1148+
Open a specific remote chat or Claude terminal session
1149+
$ ade code remote --list-targets List saved remote machines
1150+
$ ade code remote --target <machine> --list-projects
1151+
List ADE projects available on the remote machine
1152+
$ ade code remote session --target <machine> --project <project> --list-sessions
1153+
List remote chat and Claude terminal sessions
11411154
$ ade --project-root <path> code Launch against a specific ADE project
11421155

11431156
Keys:
@@ -10591,6 +10604,11 @@ async function runAdeCode(
1059110604
): Promise<{ output: string; exitCode: number }> {
1059210605
const modulePath = resolveAdeCodeModulePath();
1059310606
const { runAdeCodeCli } = await import(pathToFileURL(modulePath).href);
10607+
const remoteArgs = takeAdeCodeRemoteArgs(rest);
10608+
if (remoteArgs) {
10609+
const exitCode = await runAdeCodeRemote(remoteArgs, runAdeCodeCli);
10610+
return { output: "", exitCode };
10611+
}
1059410612
const exitCode = await runAdeCodeCli(buildAdeCodeArgs(rest, options));
1059510613
return { output: "", exitCode };
1059610614
}

apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx

Lines changed: 153 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@ import {
66
computeChatScrollMaxOffset,
77
renderChatSelectableRowTexts,
88
renderChatTranscriptPlainText,
9+
renderChatVisibleSelectionRows,
910
selectedTextFromChatRows,
11+
workGroupExpandKey,
1012
} from "../components/ChatView";
13+
import { aggregateChatBlocks } from "../aggregate";
14+
import { chatEventLineId } from "../format";
1115
import { buildSubagentTranscriptEvents } from "../subagentPane";
1216
import {
1317
parseAssistantMarkdown,
@@ -34,16 +38,34 @@ function stripAnsi(value: string): string {
3438
return value.replace(/\[[0-9;]*m/g, "");
3539
}
3640

41+
// Tool-call / file-change groups collapse to a single header row by default.
42+
// Tests that assert per-entry rendering (every call/file, glyphs, durations,
43+
// badges) pass `expanded: true` to open every work group.
44+
function expandAllWorkGroups(
45+
events: AgentChatEventEnvelope[],
46+
activeSession: AgentChatSessionSummary | null,
47+
): Set<string> {
48+
const blocks = aggregateChatBlocks({ events, notices: [], activeSession });
49+
const ids = new Set<string>();
50+
for (const block of blocks) {
51+
if (block.kind === "tool-calls-group" || block.kind === "files-changed-group") {
52+
ids.add(workGroupExpandKey(block.id));
53+
}
54+
}
55+
return ids;
56+
}
57+
3758
function renderEvents(
3859
events: AgentChatEventEnvelope[],
39-
options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean; provider?: AdeCodeProvider; olderHistory?: "loading" | "available" | "exhausted" | null } = {},
60+
options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean; provider?: AdeCodeProvider; olderHistory?: "loading" | "available" | "exhausted" | null; expanded?: boolean } = {},
4061
): string {
4162
const provider = options.provider ?? "codex";
63+
const activeSession = { ...session, provider };
4264
const result = render(
4365
<ChatView
4466
events={events}
4567
notices={[]}
46-
activeSession={{ ...session, provider }}
68+
activeSession={activeSession}
4769
projectName="ADE"
4870
laneName="Primary"
4971
provider={provider}
@@ -53,6 +75,7 @@ function renderEvents(
5375
scrollOffsetRows={options.scrollOffsetRows}
5476
olderHistory={options.olderHistory}
5577
width={options.width}
78+
expandedLineIds={options.expanded ? expandAllWorkGroups(events, activeSession) : undefined}
5679
/>,
5780
);
5881
return stripAnsi(result.lastFrame() ?? "");
@@ -283,6 +306,20 @@ describe("ChatView", () => {
283306
expect(frame).not.toContain("waiting for runtime events");
284307
});
285308

309+
it("renders a generic context_compact begin (Claude/OpenCode) as an active state", () => {
310+
const frame = renderEvents([
311+
{
312+
sessionId: "s1",
313+
timestamp: "2026-01-01T12:00:00.000Z",
314+
sequence: 1,
315+
event: { type: "context_compact", state: "started", trigger: "auto", turnId: "turn-active" },
316+
},
317+
], { width: 80 });
318+
319+
expect(frame).toContain("compacting context");
320+
expect(frame).not.toContain("model working");
321+
});
322+
286323
it("renders queued steer messages as staged instead of normal sent bubbles", () => {
287324
const frame = renderEvents([
288325
{
@@ -416,10 +453,13 @@ describe("ChatView", () => {
416453
});
417454

418455
expect(frame).not.toContain("Runtime");
419-
// Headerless: the per-call lines stack directly, no "Tool calls (N)" rows.
420-
expect(frame).not.toContain("Tool calls");
421-
expect(frame).toContain("grep");
456+
expect(frame).not.toContain("Processing tool input");
457+
// The two real tool calls collapse to a single header row; the latest call
458+
// (read) previews, the earlier one (grep) hides behind the collapsed group.
459+
expect(frame).toContain("Tool calls");
460+
expect(frame).toContain("(2)");
422461
expect(frame).toContain("read");
462+
expect(frame).not.toContain("grep");
423463
expect(frame).toContain("Let me look at the sendMessage flow more carefully and what events are emitted when a session is resumed.");
424464
});
425465

@@ -635,16 +675,17 @@ describe("ChatView", () => {
635675
expect(transcriptLines(frame).at(-1)).toContain("↓ newer messages");
636676
});
637677

638-
it("renders a command as a headerless stacked tool line with shell label and command", () => {
678+
it("renders an expanded command as a shell tool line with label, command, and duration", () => {
639679
const frame = renderEvents([
640680
{
641681
sessionId: "s1",
642682
timestamp: "2026-01-01T12:00:00.000Z",
643683
sequence: 1,
644684
event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 },
645685
},
646-
], { width: 100 });
647-
expect(frame).not.toContain("Tool calls");
686+
], { width: 100, expanded: true });
687+
expect(frame).toContain("Tool calls");
688+
expect(frame).toContain("(1)");
648689
expect(frame).toMatch(/ shell\s+git branch\s+12ms/);
649690
});
650691

@@ -671,14 +712,15 @@ describe("ChatView", () => {
671712
},
672713
];
673714
const frame = renderEvents(events, { width: 100 });
674-
// Headerless groups: the tool lines and the badge/stats file row stack
675-
// directly, in event order, without "Tool calls"/"files changed" rows.
676-
expect(frame).not.toContain("Tool calls");
677-
expect(frame).not.toContain("file changed");
715+
// Typed split: the command group and the file-change group each get their
716+
// own collapsible header (in event order). Each single-entry group previews
717+
// its call/file inline, so the collapsed headers still carry the signal.
718+
expect(frame).toContain("Tool calls");
719+
expect(frame).toContain("Files changed");
678720
expect(frame).toContain("npm test");
679721
expect(frame).toContain("npm run typecheck");
680722
expect(frame).toContain("auth.ts");
681-
// File rows keep their badge + diff stats format.
723+
// The collapsed file header keeps the badge + diff stats format.
682724
expect(frame).toContain("TS");
683725
expect(frame).toContain("+2 −1");
684726
});
@@ -763,7 +805,9 @@ describe("ChatView", () => {
763805
},
764806
], { width: 100 });
765807

766-
expect(frame).not.toContain("Tool calls");
808+
// The top-level spawn collapses into a single "Tool calls" header that
809+
// previews it; the subagent's own child tool chatter stays suppressed.
810+
expect(frame).toContain("Tool calls");
767811
expect(frame).toContain("spawn_agent");
768812
expect(frame).toContain("Explore renderer");
769813
expect(frame).not.toContain("child launch spam");
@@ -834,7 +878,7 @@ describe("ChatView", () => {
834878
expect(transcriptBody).not.toContain("unrelated agent result");
835879
});
836880

837-
it("collapses tool-calls-group on done with failed and ok summary", () => {
881+
it("renders per-call ok/failed glyphs for an expanded finished tool group", () => {
838882
const turnId = "turn-done";
839883
const events: AgentChatEventEnvelope[] = [
840884
{
@@ -868,12 +912,13 @@ describe("ChatView", () => {
868912
event: { type: "done", turnId, status: "completed", usage: { inputTokens: 4000, outputTokens: 2200 }, costUsd: 0.31 },
869913
},
870914
];
871-
const frame = renderEvents(events, { width: 100 });
872-
// Headerless: ok/failed status lives on each line's glyph, not a summary row.
873-
expect(frame).not.toContain("Tool calls");
915+
const frame = renderEvents(events, { width: 100, expanded: true });
916+
// Expanded group: ok/failed status lives on each call's glyph.
917+
expect(frame).toContain("Tool calls");
918+
expect(frame).toContain("(4)");
874919
expect(frame.match(//g)).toHaveLength(3);
875920
expect(frame.match(//g)).toHaveLength(1);
876-
// Most recent shell commands visible.
921+
// Every shell command is visible when expanded.
877922
expect(frame).toContain("npm test");
878923
expect(frame).toContain("echo two");
879924
expect(frame).not.toContain("8.3s");
@@ -902,7 +947,7 @@ describe("ChatView", () => {
902947
},
903948
];
904949

905-
const frame = renderEvents(events, { width: 100 });
950+
const frame = renderEvents(events, { width: 100, expanded: true });
906951
expect(frame).toContain("instant");
907952
expect(frame).toContain("measured");
908953
expect(frame).toContain("12ms");
@@ -988,7 +1033,7 @@ describe("ChatView", () => {
9881033
expect(text).not.toContain("gpt");
9891034
});
9901035

991-
it("stacks every tool call as its own line like the desktop work log", () => {
1036+
it("collapses many tool calls to one header row and expands to stack every call", () => {
9921037
const turnId = "turn-many";
9931038
const events: AgentChatEventEnvelope[] = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({
9941039
sessionId: "s1",
@@ -1006,13 +1051,22 @@ describe("ChatView", () => {
10061051
turnId,
10071052
},
10081053
}));
1009-
const frame = renderEvents(events, { width: 120, maxRows: 40 });
1010-
expect(frame).not.toContain("Tool calls");
1011-
expect(frame).not.toContain("more");
1012-
// Every consecutive call stacks as its own single line (desktop parity).
1013-
expect(frame).toContain("cmd-1");
1014-
expect(frame).toContain("cmd-5");
1015-
expect(frame).toContain("cmd-12");
1054+
1055+
// Collapsed (default): one header row with the count + the latest call's
1056+
// preview; the earlier calls are hidden behind the collapsed group.
1057+
const collapsed = renderEvents(events, { width: 120, maxRows: 40 });
1058+
expect(collapsed).toContain("Tool calls");
1059+
expect(collapsed).toContain("(12)");
1060+
expect(collapsed).toContain("cmd-12");
1061+
expect(collapsed).not.toContain("cmd-1 ");
1062+
expect(collapsed).not.toContain("cmd-5");
1063+
1064+
// Expanded: the header stays, and every consecutive call stacks one per line.
1065+
const expanded = renderEvents(events, { width: 120, maxRows: 40, expanded: true });
1066+
expect(expanded).toContain("Tool calls");
1067+
expect(expanded).toContain("cmd-1");
1068+
expect(expanded).toContain("cmd-5");
1069+
expect(expanded).toContain("cmd-12");
10161070
});
10171071

10181072
it("strips the /bin/zsh -lc launcher wrapper from shell commands", () => {
@@ -1048,7 +1102,7 @@ describe("ChatView", () => {
10481102
},
10491103
},
10501104
];
1051-
const frame = renderEvents(events, { width: 120 });
1105+
const frame = renderEvents(events, { width: 120, expanded: true });
10521106
expect(frame).toContain("git status --short");
10531107
expect(frame).toContain("npm test");
10541108
// Launcher prefix is gone.
@@ -1077,15 +1131,83 @@ describe("ChatView", () => {
10771131
event: { type: "file_change", path: "docs/notes.md", diff: "+line1", kind: "create", itemId: "f3", status: "completed", turnId: "t1" },
10781132
},
10791133
];
1080-
const frame = renderEvents(events, { width: 120 });
1081-
expect(frame).not.toContain("files changed");
1134+
const frame = renderEvents(events, { width: 120, expanded: true });
1135+
expect(frame).toContain("Files changed");
1136+
expect(frame).toContain("(3)");
10821137
expect(frame).toContain("TSX");
10831138
expect(frame).toContain("JS");
10841139
expect(frame).toContain("MD");
10851140
expect(frame).toContain("Component.tsx");
10861141
expect(frame).toContain("Deleted");
10871142
});
10881143

1144+
it("collapses file changes to one header row by default, previewing the latest edit", () => {
1145+
const events: AgentChatEventEnvelope[] = [
1146+
{
1147+
sessionId: "s1",
1148+
timestamp: "2026-01-01T12:00:00.000Z",
1149+
sequence: 1,
1150+
event: { type: "file_change", path: "src/early.ts", diff: "+a\n+b\n-c", kind: "modify", itemId: "f1", status: "completed", turnId: "t1" },
1151+
},
1152+
{
1153+
sessionId: "s1",
1154+
timestamp: "2026-01-01T12:00:01.000Z",
1155+
sequence: 2,
1156+
event: { type: "file_change", path: "src/recent.ts", diff: "+x", kind: "modify", itemId: "f2", status: "completed", turnId: "t1" },
1157+
},
1158+
];
1159+
const collapsed = renderEvents(events, { width: 120 });
1160+
expect(collapsed).toContain("Files changed");
1161+
expect(collapsed).toContain("(2)");
1162+
// Latest edit previews; the earlier file hides behind the collapsed group.
1163+
expect(collapsed).toContain("recent.ts");
1164+
expect(collapsed).not.toContain("early.ts");
1165+
1166+
const expanded = renderEvents(events, { width: 120, expanded: true });
1167+
expect(expanded).toContain("early.ts");
1168+
expect(expanded).toContain("recent.ts");
1169+
});
1170+
1171+
it("tags a collapsed work-group header with an expandable click-target id", () => {
1172+
const events: AgentChatEventEnvelope[] = [
1173+
{
1174+
sessionId: "s1",
1175+
timestamp: "2026-01-01T12:00:00.000Z",
1176+
sequence: 1,
1177+
event: { type: "command", command: "alpha", cwd: "/repo", output: "", itemId: "c1", status: "completed", exitCode: 0, durationMs: 10, turnId: "t1" },
1178+
},
1179+
{
1180+
sessionId: "s1",
1181+
timestamp: "2026-01-01T12:00:01.000Z",
1182+
sequence: 2,
1183+
event: { type: "command", command: "beta", cwd: "/repo", output: "", itemId: "c2", status: "completed", exitCode: 0, durationMs: 10, turnId: "t1" },
1184+
},
1185+
];
1186+
const expectedKey = workGroupExpandKey(chatEventLineId(events[0]!, 0));
1187+
const rows = renderChatVisibleSelectionRows({
1188+
events,
1189+
notices: [],
1190+
activeSession: session,
1191+
width: 120,
1192+
});
1193+
const headerRow = rows.find((row) => row.expandableId != null);
1194+
// The collapsed group renders exactly one clickable header carrying the
1195+
// expand key the transcript click handler toggles in expandedLineIds.
1196+
expect(headerRow?.expandableId).toBe(expectedKey);
1197+
expect(rows.filter((row) => row.expandableId != null)).toHaveLength(1);
1198+
1199+
const expandedRows = renderChatVisibleSelectionRows({
1200+
events,
1201+
notices: [],
1202+
activeSession: session,
1203+
expandedLineIds: new Set([expectedKey]),
1204+
width: 120,
1205+
});
1206+
// Still exactly one clickable header (now ▾) plus the stacked call rows.
1207+
expect(expandedRows.filter((row) => row.expandableId != null)).toHaveLength(1);
1208+
expect(expandedRows.length).toBeGreaterThan(rows.length);
1209+
});
1210+
10891211
it("renders fenced code with highlight.js-derived per-line tokens", () => {
10901212
const events: AgentChatEventEnvelope[] = [
10911213
{

0 commit comments

Comments
 (0)