From c703f3878bb6ffc8d9d08b5c8d8059917b311b69 Mon Sep 17 00:00:00 2001
From: iamnbutler
Date: Thu, 22 Oct 2026 17:24:56 -0400
Subject: [PATCH 1/3] fix: offer a teammate's directory after leaving one
instead of Create directory
Why: apps/host/src/peers.ts#function discover | a host with no directory keeps greeting peers so it learns which directory they name; adopt still never joins after a choice
---
apps/app/src/app.tsx | 8 +++-
apps/app/src/onboarding.tsx | 87 ++++++++++++++++++++++++++++++------
apps/host/src/diagnostics.ts | 1 +
apps/host/src/directory.ts | 15 +++++++
apps/host/src/peers.ts | 10 +++--
apps/host/src/protocol.ts | 2 +
6 files changed, 104 insertions(+), 19 deletions(-)
diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx
index be54a558..38a27735 100644
--- a/apps/app/src/app.tsx
+++ b/apps/app/src/app.tsx
@@ -1010,7 +1010,13 @@ export function App() {
onClone={cloneUrl}
/>
)}
- {card && setDismissed(true)} />}
+ {card && (
+ setDismissed(true)}
+ onChange={() => setSettings("team")}
+ />
+ )}
)}
diff --git a/apps/app/src/onboarding.tsx b/apps/app/src/onboarding.tsx
index 2cfd092d..5fea3ff4 100644
--- a/apps/app/src/onboarding.tsx
+++ b/apps/app/src/onboarding.tsx
@@ -245,8 +245,33 @@ export function Welcome({ github, onClose }: { github?: string; onClose: () => v
);
}
-/** Sidebar footer upsell while the team has no directory. */
-export function DirectoryCard({ onDismiss }: { onDismiss: () => void }) {
+/**
+ * Sidebar footer while this host has no directory: join the one a teammate's host names, or
+ * create one when nobody has.
+ */
+export function DirectoryCard(
+ { offered, onDismiss, onChange }: {
+ offered?: { url: string; host: string };
+ onDismiss: () => void;
+ onChange: () => void;
+ },
+) {
+ const [joining, setJoining] = useState(false);
+ const [error, setError] = useState("");
+
+ async function join(url: string) {
+ setJoining(true);
+ setError("");
+ try {
+ await host.request({ op: "directory-set", url });
+ window.dispatchEvent(new Event(DIRECTORY_CHANGED));
+ } catch (error) {
+ setError((error as Error).message);
+ } finally {
+ setJoining(false);
+ }
+ }
+
return (
@@ -261,17 +286,51 @@ export function DirectoryCard({ onDismiss }: { onDismiss: () => void }) {
-
- Keep channels listed while Macs are offline, and share projects.
-
-
window.dispatchEvent(new Event(CREATE_DIRECTORY))}
- >
- Create directory
-
+ {offered
+ ? (
+ <>
+
+ {offered.host}{" "}
+ serves your team's directory. This Mac isn't in it, so its channels aren't listed
+ while it's offline.
+
+ {error &&
{error}
}
+
+ void join(offered.url)}
+ >
+ {joining ? "Joining…" : "Join"}
+
+
+ Change…
+
+
+ >
+ )
+ : (
+ <>
+
+ Keep channels listed while Macs are offline, and share projects.
+
+
window.dispatchEvent(new Event(CREATE_DIRECTORY))}
+ >
+ Create directory
+
+ >
+ )}
);
}
@@ -291,7 +350,7 @@ export function OfflineHost({ name, host: machine }: { name: string; host: strin
{name} runs on that Mac. Its chat opens here when the Mac is back.
- {directory && !directory.url && (
+ {directory && !directory.url && !directory.offered && (
diff --git a/apps/host/src/diagnostics.ts b/apps/host/src/diagnostics.ts
index 1cab67ff..ac997826 100644
--- a/apps/host/src/diagnostics.ts
+++ b/apps/host/src/diagnostics.ts
@@ -63,6 +63,7 @@ export async function diagnostics(sync?: boolean): Promise {
serving: served.isServing(),
shares: served.shares(),
hosts: hosts.map(({ address: _, ...host }) => host),
+ offered: directory.offered(),
},
shell: { ...environment },
web: web.address,
diff --git a/apps/host/src/directory.ts b/apps/host/src/directory.ts
index 100e6f2f..425d50c3 100644
--- a/apps/host/src/directory.ts
+++ b/apps/host/src/directory.ts
@@ -119,6 +119,21 @@ export function isChosen(): boolean {
return existsSync(config) || !!process.env.ACE_DIRECTORY;
}
+/** The directory each peer's host names, so a host without one can offer to join it. */
+const offers = new Map();
+
+export function offer(peer: string, value: string | undefined): void {
+ if (value) offers.set(peer, value);
+ else offers.delete(peer);
+}
+
+/** A directory a peer's host names while this host has none, as after leaving one. */
+export function offered(): { url: string; host: string } | undefined {
+ if (url()) return;
+ const [host, value] = offers.entries().next().value ?? [];
+ return host && value ? { url: value, host } : undefined;
+}
+
/**
* When each URL a peer named last failed without a secret, so discovery doesn't ask every 15
* seconds; its host may only be offline, so it's asked again later.
diff --git a/apps/host/src/peers.ts b/apps/host/src/peers.ts
index 5a80fe4d..3b7096ef 100644
--- a/apps/host/src/peers.ts
+++ b/apps/host/src/peers.ts
@@ -41,6 +41,7 @@ async function discover(signal: AbortSignal) {
if (online.get(name)?.address === host.machine.address) continue;
host.client.close();
hosts.delete(name);
+ directory.offer(name, undefined);
log("info", "peer.gone", { peer: name });
emit();
}
@@ -65,16 +66,17 @@ async function discover(signal: AbortSignal) {
});
hosts.set(name, host);
}
- // A teammate can start a directory after this host first greeted them, and a peer that answered
- // before `gh` resolved has a login to give.
- const chosen = directory.isChosen();
+ // A teammate can start a directory after this host first greeted them, a host that left one
+ // offers a teammate's instead, and a peer that answered before `gh` resolved has a login to give.
+ const listening = !directory.isChosen() || !directory.url();
for (const [name, { client, machine }] of hosts) {
- if (client.status === "open" && (!chosen || unnamed.has(name))) greet(client, machine);
+ if (client.status === "open" && (listening || unnamed.has(name))) greet(client, machine);
}
}
function greet(client: GatewayClient, machine: Machine) {
client.request({ op: "hello" }).then((hello) => {
+ directory.offer(machine.name, hello.directory);
if (hello.directory) void directory.adopt(hello.directory);
// A freshly started peer answers before `gh` has told it its owner's login, which may have changed.
if (!hello.github) return void unnamed.add(machine.name);
diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts
index cf6ad01b..5d72279b 100644
--- a/apps/host/src/protocol.ts
+++ b/apps/host/src/protocol.ts
@@ -70,6 +70,8 @@ export type Diagnostics = {
shares: Share[];
/** Hosts in the directory's last answer, and when each last published to it; older hosts omit it. */
hosts?: DirectoryHost[];
+ /** A directory a teammate's host names while this host has none, such as after leaving it. */
+ offered?: { url: string; host: string };
};
/** Whether the helper loaded the login shell's environment; `pending` while it retries. */
shell: { state: "adopted" | "pending" | "failed"; error?: string };
From 2443b24aac5d505d3527b44e6a4cacb19b289c50 Mon Sep 17 00:00:00 2001
From: iamnbutler
Date: Thu, 22 Oct 2026 17:25:00 -0400
Subject: [PATCH 2/3] chore: confirm notes on changed files
---
...YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md | 6 +++---
...JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md | 6 +++---
...JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md | 5 +++--
...JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md | 5 +++--
...JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md | 8 ++++++++
5 files changed, 20 insertions(+), 10 deletions(-)
create mode 100644 .lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md
diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md
index f3e95a44..89df60b2 100644
--- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md
+++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md
@@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
-body_hash: de60f6b6b28752c6
-raw_hash: a19d4ffff16a8faf
-vouched: 2026-10-22T21:17:34Z
+body_hash: cbba38a628c7d858
+raw_hash: a8b8547e68eff2dc
+vouched: 2026-10-22T21:25:00Z
lines: 235-824
---
diff --git a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md
index 44f83b6f..4d860aff 100644
--- a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md
+++ b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md
@@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
-body_hash: de60f6b6b28752c6
-raw_hash: a19d4ffff16a8faf
-vouched: 2026-10-22T21:17:34Z
+body_hash: cbba38a628c7d858
+raw_hash: a8b8547e68eff2dc
+vouched: 2026-10-22T21:25:00Z
lines: 330-1237
---
diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md b/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md
index 1ae1c2bc..07968feb 100644
--- a/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md
+++ b/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md
@@ -4,8 +4,9 @@ anchor: function greet
created: 2026-10-22T17:44:22Z
norm: '1'
sig: 583ffcbd3b791ed2
-body_hash: f003a7d2404a7483
-raw_hash: 18edf8ce741d1310
+body_hash: b38e7bb2f4ff3a8c
+raw_hash: d2d3c4587a57e408
+vouched: 2026-10-22T21:25:00Z
lines: 76-86
---
diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md b/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md
index cc008feb..e37e86d5 100644
--- a/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md
+++ b/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md
@@ -4,8 +4,9 @@ anchor: function discover
created: 2026-10-22T17:44:31Z
norm: '1'
sig: ab69f97386d0db50
-body_hash: 687af47f626b9aa1
-raw_hash: ce225aafa3f68775
+body_hash: 9b0a75f289475140
+raw_hash: 4713ceb74c23d1f3
+vouched: 2026-10-22T21:25:00Z
lines: 37-74
supersedes: 01M4YX4FKJR9W30M9Y5HVTKBW4
---
diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md b/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md
new file mode 100644
index 00000000..b917f10e
--- /dev/null
+++ b/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md
@@ -0,0 +1,8 @@
+---
+id: 01M5JR1DMRY4D6Q3Z6KTX7JCG5
+anchor: function discover
+created: 2026-10-22T21:24:56Z
+norm: '1'
+---
+
+a host with no directory keeps greeting peers so it learns which directory they name; adopt still never joins after a choice
From 62988cc874d123aaa229d601d9bf3f43278e1306 Mon Sep 17 00:00:00 2001
From: iamnbutler
Date: Thu, 22 Oct 2026 17:25:02 -0400
Subject: [PATCH 3/3] lane: sync fix-left-dir memory
---
...01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md b/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md
index b917f10e..9f177b65 100644
--- a/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md
+++ b/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md
@@ -3,6 +3,10 @@ id: 01M5JR1DMRY4D6Q3Z6KTX7JCG5
anchor: function discover
created: 2026-10-22T21:24:56Z
norm: '1'
+sig: ab69f97386d0db50
+body_hash: 9b0a75f289475140
+raw_hash: 4713ceb74c23d1f3
+lines: 37-75
---
a host with no directory keeps greeting peers so it learns which directory they name; adopt still never joins after a choice