diff --git a/src/bridge/agent-registry.js b/src/bridge/agent-registry.js
index b6b314d..9659a64 100644
--- a/src/bridge/agent-registry.js
+++ b/src/bridge/agent-registry.js
@@ -273,6 +273,7 @@ export function createAgentRegistry({
let workspaceSequence = 0;
let manifestIssue;
let canonicalHomeWorktree;
+ let lastSuccessfulModelCatalog;
let stopping = false;
function publishWorkspace(type, runtime, data = {}) {
@@ -307,13 +308,13 @@ export function createAgentRegistry({
runtimes: [...runtimesByRuntimeId.values()]
.filter((runtime) => !runtime.ephemeral)
.map((runtime) => ({
- runtimeId: runtime.runtimeId,
- worktreePath: runtime.worktreePath,
- ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
- ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
- ...(runtime.legacyDefault ? { legacyDefault: true } : {}),
- openedAt: runtime.openedAt,
- })),
+ runtimeId: runtime.runtimeId,
+ worktreePath: runtime.worktreePath,
+ ...(runtime.sessionPath ? { sessionPath: runtime.sessionPath } : {}),
+ ...(runtime.sessionId ? { sessionId: runtime.sessionId } : {}),
+ ...(runtime.legacyDefault ? { legacyDefault: true } : {}),
+ openedAt: runtime.openedAt,
+ })),
};
}
@@ -986,7 +987,9 @@ export function createAgentRegistry({
}
if (stopConfirmed) releaseClosedSessionLease(runtime);
if (runtime.ephemeral && stopConfirmed)
- await rm(runtime.sessionDir, { recursive: true, force: true }).catch(() => {});
+ await rm(runtime.sessionDir, { recursive: true, force: true }).catch(
+ () => {},
+ );
runtime.state = "stopped";
publishWorkspace("runtime_closed", runtime, {
runtimeId: runtime.runtimeId,
@@ -1160,6 +1163,15 @@ export function createAgentRegistry({
});
await restoreRecords(records);
}
+ if (
+ !loaded.issue &&
+ ![...runtimesByAgentId.values()].some((runtime) => runtime.adapter)
+ ) {
+ await openRuntime({
+ worktreePath: canonicalHomeWorktree,
+ legacyDefault: true,
+ });
+ }
return this.listAgents()[0];
},
async selectWorktree(worktreePath) {
@@ -1206,14 +1218,31 @@ export function createAgentRegistry({
return closeRuntime(runtimeId);
},
async getModelCatalog() {
- const runtime = [...runtimesByAgentId.values()].find(
- (candidate) => candidate.adapter,
+ const candidates = [...runtimesByAgentId.values()].filter(
+ (runtime) =>
+ runtime.adapter &&
+ !runtime.stopped &&
+ !["closing", "stopped", "failed"].includes(runtime.state),
);
- if (!runtime?.adapter) return [];
- const response = await enqueue(runtime, () =>
- runtime.adapter.send({ type: "get_available_models" }),
- );
- return response?.data?.models ?? [];
+ let lastError;
+ for (const runtime of candidates) {
+ const adapter = runtime.adapter;
+ try {
+ const response = await enqueue(runtime, () =>
+ adapter.send({ type: "get_available_models" }),
+ );
+ if (response?.success !== true || !Array.isArray(response?.data?.models)) {
+ continue;
+ }
+ lastSuccessfulModelCatalog = response.data.models;
+ return response.data.models;
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ if (lastSuccessfulModelCatalog) return lastSuccessfulModelCatalog;
+ if (lastError) throw lastError;
+ return [];
},
listAgents() {
return [...runtimesByAgentId.values()]
diff --git a/test/agent-registry.test.js b/test/agent-registry.test.js
index f9c5245..9279598 100644
--- a/test/agent-registry.test.js
+++ b/test/agent-registry.test.js
@@ -78,6 +78,60 @@ test("starts only the home agent and creates other worktree agents on explicit s
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
});
+test("model catalog retries unsuccessful responses and falls back to last success", async () => {
+ const worktrees = await createWorktrees();
+ const fixture = createAdapterFactory();
+ const registry = createAgentRegistry({
+ homeWorktree: worktrees.home,
+ sessionRoot: worktrees.sessionRoot,
+ startAdapter: fixture.startAdapter,
+ });
+ await registry.start();
+ await registry.selectWorktree(worktrees.feature);
+ const expected = [{ provider: "provider", id: "family/model" }];
+ const rejected = [{ provider: "wrong", id: "must-not-be-cached" }];
+ const sends = fixture.calls.map(({ adapter }) => adapter.send.bind(adapter));
+ let homeCatalogCalls = 0;
+ let featureCatalogCalls = 0;
+ fixture.calls[0].adapter.send = async (command, options) => {
+ if (command.type === "get_available_models") {
+ homeCatalogCalls += 1;
+ return {
+ type: "response",
+ success: false,
+ data: { models: rejected },
+ };
+ }
+ return sends[0](command, options);
+ };
+ fixture.calls[1].adapter.send = async (command, options) => {
+ if (command.type === "get_available_models") {
+ featureCatalogCalls += 1;
+ return { type: "response", success: true, data: { models: expected } };
+ }
+ return sends[1](command, options);
+ };
+
+ assert.deepEqual(await registry.getModelCatalog(), expected);
+ assert.equal(homeCatalogCalls, 1);
+ assert.equal(featureCatalogCalls, 1);
+ fixture.calls[1].adapter.send = async (command, options) => {
+ if (command.type === "get_available_models") {
+ featureCatalogCalls += 1;
+ return {
+ type: "response",
+ success: true,
+ data: { models: "malformed" },
+ };
+ }
+ return sends[1](command, options);
+ };
+ assert.deepEqual(await registry.getModelCatalog(), expected);
+ assert.equal(homeCatalogCalls, 2);
+ assert.equal(featureCatalogCalls, 2);
+ await registry.stop();
+});
+
test("sends abort without waiting behind a blocked prompt", async () => {
const worktrees = await createWorktrees();
const fixture = createAdapterFactory();
diff --git a/test/multi-session-registry.test.js b/test/multi-session-registry.test.js
index 33d542f..3de2843 100644
--- a/test/multi-session-registry.test.js
+++ b/test/multi-session-registry.test.js
@@ -987,7 +987,7 @@ test("closing retains a saved-session lease until the child stops", async () =>
await registry.stop();
});
-test("first migration retains a missing legacy session as failed dormant intent", async () => {
+test("first migration retains missing intent and starts an idle home runtime", async () => {
const paths = await fixture();
const sessionDir = sessionDirectoryPath(paths.sessionRoot, paths.home);
const missing = join(sessionDir, "missing.jsonl");
@@ -1003,14 +1003,18 @@ test("first migration retains a missing legacy session as failed dormant intent"
startAdapter: adapters.startAdapter,
});
await registry.start();
- const runtime = registry.getWorkspace().directories[0].runtimes[0];
- assert.equal(runtime.state, "failed");
- assert.equal(runtime.sessionPath, missing);
- assert.equal(adapters.calls.length, 0);
+ const runtimes = registry.getWorkspace().directories[0].runtimes;
+ const failed = runtimes.find((runtime) => runtime.state === "failed");
+ const live = runtimes.find((runtime) => runtime.state === "idle");
+ assert.equal(failed.sessionPath, missing);
+ assert.equal(live.worktreePath, paths.home);
+ assert.equal(adapters.calls.length, 1);
const manifest = await readJson(
join(paths.sessionRoot, "bridge-workspace-v2.json"),
);
- assert.equal(manifest.runtimes[0].sessionPath, missing);
+ assert.ok(
+ manifest.runtimes.some((runtime) => runtime.sessionPath === missing),
+ );
await registry.stop();
});
@@ -1198,7 +1202,7 @@ test("snapshot rejects results from an adapter replaced during recovery", async
await registry.stop();
});
-test("closing the last runtime persists a zero-runtime restart", async () => {
+test("restoration with no live adapter starts one idle home runtime", async () => {
const paths = await fixture();
const firstAdapters = adapterFactory();
const first = createAgentRegistry({
@@ -1216,8 +1220,10 @@ test("closing the last runtime persists a zero-runtime restart", async () => {
startAdapter: restoredAdapters.startAdapter,
});
await restored.start();
- assert.equal(restored.getWorkspaceSummary().openCount, 0);
- assert.equal(restoredAdapters.calls.length, 0);
+ assert.equal(restored.getWorkspaceSummary().openCount, 1);
+ assert.equal(restoredAdapters.calls.length, 1);
+ assert.equal(restored.listAgents()[0].worktreePath, paths.home);
+ assert.equal(restored.listAgents()[0].state, "idle");
await restored.stop();
});
diff --git a/test/quick-pane-ui.test.js b/test/quick-pane-ui.test.js
index d4c6ae3..8c50884 100644
--- a/test/quick-pane-ui.test.js
+++ b/test/quick-pane-ui.test.js
@@ -9,32 +9,34 @@ test("Quick Pane polls snapshots, renders answers, and gates handoff", async ()
);
assert.match(source, /get_session_runtime_snapshot/);
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
- assert.match(
- source,
- /
\{answer\}<\/pre>/,
- );
- assert.match(
- source,
- /\{\(escalation \|\| error\) && \(\s*
+ {canContinue && (
+
+ )}
+
);
}
diff --git a/ui/src/quick-pane/quick-pane.css b/ui/src/quick-pane/quick-pane.css
index a509532..d58eb8e 100644
--- a/ui/src/quick-pane/quick-pane.css
+++ b/ui/src/quick-pane/quick-pane.css
@@ -2,6 +2,13 @@
color-scheme: dark;
}
+html,
+body,
+#root {
+ width: 100%;
+ height: 100%;
+}
+
body {
min-width: 0;
min-height: 0;
@@ -11,11 +18,14 @@ body {
.quick-pane {
box-sizing: border-box;
- min-height: 100vh;
- overflow-y: auto;
- padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ height: 100vh;
+ overflow: hidden;
+ padding: 10px;
color: #e6e3df;
- font: 14px/1.4 Inter, ui-sans-serif, system-ui, sans-serif;
+ font: 14px/1.35 Inter, ui-sans-serif, system-ui, sans-serif;
background: rgba(34, 34, 34, 0.98);
border: 1px solid #4b4b4b;
border-radius: 8px;
@@ -24,9 +34,9 @@ body {
.quick-pane header {
display: flex;
+ flex: 0 0 auto;
align-items: center;
justify-content: space-between;
- margin-bottom: 12px;
color: #f0a347;
font-size: 11px;
font-weight: 900;
@@ -36,14 +46,15 @@ body {
.quick-pane button,
.quick-pane select {
+ min-width: 0;
border: 0;
border-radius: 5px;
- padding: 6px 9px;
+ padding: 5px 8px;
font: inherit;
}
.quick-pane button {
- margin-right: 6px;
+ flex: 0 0 auto;
background: #f0a347;
color: #25180b;
font-weight: 750;
@@ -59,52 +70,108 @@ body {
cursor: wait;
}
+.quick-pane button.quiet {
+ color: #e6e3df;
+ background: #45413c;
+}
+
.quick-pane select {
color: #e6e3df;
background: #353535;
}
-.quick-pane textarea {
+.quick-pane-request {
box-sizing: border-box;
width: 100%;
- min-height: 92px;
+ min-height: 180px;
+ flex: 1 1 auto;
padding: 12px;
+ resize: none;
border: 1px solid #484848;
border-radius: 6px;
color: #e6e3df;
background: #292929;
font: inherit;
font-size: 16px;
- resize: vertical;
}
-.quick-pane textarea:focus,
+.quick-pane-request:focus,
.quick-pane button:focus-visible,
.quick-pane select:focus-visible {
outline: 3px solid #f0a347;
outline-offset: 2px;
}
-.quick-pane > label {
- display: inline-flex;
- align-items: center;
- gap: 7px;
- margin: 10px 8px 10px 0;
- color: #bbb7b0;
-}
-
-.quick-pane > pre,
-.quick-pane > p {
- margin: 10px 0 0;
- padding: 10px 12px;
+.quick-pane-output {
+ max-height: 108px;
+ min-height: 0;
+ flex: 0 1 auto;
+ overflow: auto;
border: 1px solid #484848;
border-radius: 6px;
- white-space: pre-wrap;
background: #292929;
}
-.quick-pane [role="alert"] {
- border-color: #a65b67;
+.quick-pane-output:empty {
+ display: none;
+}
+
+.quick-pane-output > pre,
+.quick-pane-output > p {
+ margin: 0;
+ padding: 6px 8px;
+ white-space: pre-wrap;
+}
+
+.quick-pane-output > :not(:last-child) {
+ border-bottom: 1px solid #484848;
+}
+
+.quick-pane-output [role="alert"] {
color: #ffd7db;
background: #3d252b;
}
+
+.quick-pane-controls {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: end;
+ gap: 6px;
+ min-width: 0;
+}
+
+.quick-pane-controls label {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+ color: #bbb7b0;
+ font-size: 10px;
+ line-height: 1;
+}
+
+.quick-pane-controls label:first-child {
+ flex: 1 1 auto;
+}
+
+.quick-pane-controls label:nth-child(2) {
+ flex: 0 1 100px;
+}
+
+.quick-pane-controls select {
+ width: 100%;
+ font-size: 12px;
+}
+
+.quick-pane-output .extension-backdrop,
+.quick-pane-output .extension {
+ position: static;
+ display: block;
+ padding: 6px 8px;
+ background: transparent;
+}
+
+.quick-pane-output .extension h2,
+.quick-pane-output .extension p {
+ margin: 0 0 4px;
+ font-size: 12px;
+}