feat(bridge): establish local pi status bridge
Deliver the initial local bridge, Noctalia v4/v5 adapters, desktop client, service unit, tests, and implementation documentation for persistent Pi status and control.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
|
||||
|
||||
test("recovers an exited agent with its last Pi session reference", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-"));
|
||||
const home = join(root, "home");
|
||||
const sessionRoot = join(root, "sessions");
|
||||
await mkdir(home);
|
||||
const sessionFile = join(sessionRoot, "pi-session.jsonl");
|
||||
const calls = [];
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: home,
|
||||
sessionRoot,
|
||||
recoveryDelayForAttempt: () => 0,
|
||||
sleep: async () => {},
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
send: async () => ({
|
||||
type: "response",
|
||||
success: true,
|
||||
data: { sessionFile },
|
||||
}),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
|
||||
const agent = await registry.start();
|
||||
calls[0].options.onError({ code: "child_exited", message: "crashed" });
|
||||
await registry.waitForRecovery(agent.id);
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[1].options.sessionPath, sessionFile);
|
||||
assert.deepEqual(
|
||||
registry.eventsAfter(agent.id, 0).map((event) => event.type),
|
||||
["agent_state", "recovery", "recovery", "agent_state"],
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
|
||||
|
||||
async function createWorktrees() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-worktrees-"));
|
||||
const home = join(root, "home");
|
||||
const feature = join(root, "feature");
|
||||
await Promise.all([mkdir(home), mkdir(feature)]);
|
||||
return { root, home, feature, sessionRoot: join(root, "sessions") };
|
||||
}
|
||||
|
||||
function createAdapterFactory() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
sent: [],
|
||||
extensionResponses: [],
|
||||
stopped: false,
|
||||
send(command) {
|
||||
this.sent.push(command);
|
||||
return Promise.resolve({
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
respondToExtension(requestId, response) {
|
||||
this.extensionResponses.push({ requestId, response });
|
||||
},
|
||||
async stop() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("starts only the home agent and creates other worktree agents on explicit selection", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
|
||||
const home = await registry.start();
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
assert.equal(home.worktreePath, worktrees.home);
|
||||
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const selectedAgain = await registry.selectWorktree(
|
||||
`${worktrees.feature}/../feature`,
|
||||
);
|
||||
assert.equal(feature.id, selectedAgain.id);
|
||||
assert.notEqual(home.id, feature.id);
|
||||
assert.deepEqual(
|
||||
fixture.calls.map(({ options }) => options.cwd),
|
||||
[worktrees.home, worktrees.feature],
|
||||
);
|
||||
assert.notEqual(home.sessionDir, feature.sessionDir);
|
||||
|
||||
await registry.stop();
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("catalogues managed directories and exposes only their sessions", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const olderSession = join(feature.sessionDir, "older.jsonl");
|
||||
const newestSession = join(feature.sessionDir, "newest.jsonl");
|
||||
await writeFile(
|
||||
olderSession,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "older",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "user", content: "Older task" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
newestSession,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "newest",
|
||||
timestamp: "2025-01-02T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
}),
|
||||
JSON.stringify({ type: "session_info", name: "Newest work" }),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: { role: "user", content: "Newest task" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(
|
||||
join(feature.sessionDir, "bridge-agent.json"),
|
||||
`${JSON.stringify({ sessionPath: newestSession, worktreePath: worktrees.feature })}\n`,
|
||||
);
|
||||
|
||||
const sessions = await registry.listSessions(feature.id);
|
||||
assert.deepEqual(
|
||||
sessions.map(({ id, name, isCurrent }) => ({ id, name, isCurrent })),
|
||||
[
|
||||
{ id: "newest", name: "Newest work", isCurrent: false },
|
||||
{ id: "older", name: undefined, isCurrent: false },
|
||||
],
|
||||
);
|
||||
await registry.route(feature.id, "switch_session", {
|
||||
sessionPath: newestSession,
|
||||
});
|
||||
await registry.route(feature.id, "new_session");
|
||||
assert.deepEqual(
|
||||
fixture.calls[1].adapter.sent.map((command) => command.type),
|
||||
["get_state", "switch_session", "get_state", "new_session", "get_state"],
|
||||
);
|
||||
await assert.rejects(
|
||||
registry.route(feature.id, "switch_session", {
|
||||
sessionPath: olderSession.replace("older", "../outside"),
|
||||
}),
|
||||
/ENOENT|belong/,
|
||||
);
|
||||
await registry.stop();
|
||||
|
||||
const restored = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: createAdapterFactory().startAdapter,
|
||||
});
|
||||
await restored.start();
|
||||
assert.deepEqual(await restored.listDirectories(), [
|
||||
{ worktreePath: worktrees.feature, state: "inactive" },
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
agentId: restored.listAgents()[0].id,
|
||||
},
|
||||
]);
|
||||
await restored.stop();
|
||||
});
|
||||
|
||||
test("routes commands by explicit agent ID and replays only events after the cursor", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const agent = await registry.start();
|
||||
const [{ options, adapter }] = fixture.calls;
|
||||
|
||||
const response = await registry.route(agent.id, "prompt", {
|
||||
message: "Stay in this worktree",
|
||||
});
|
||||
assert.deepEqual(response, {
|
||||
type: "response",
|
||||
command: "prompt",
|
||||
success: true,
|
||||
});
|
||||
const stats = await registry.route(agent.id, "get_session_stats");
|
||||
assert.deepEqual(stats, {
|
||||
type: "response",
|
||||
command: "get_session_stats",
|
||||
success: true,
|
||||
});
|
||||
const commands = await registry.route(agent.id, "get_commands");
|
||||
assert.deepEqual(commands, {
|
||||
type: "response",
|
||||
command: "get_commands",
|
||||
success: true,
|
||||
});
|
||||
assert.deepEqual(adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Stay in this worktree" },
|
||||
{ type: "get_session_stats" },
|
||||
{ type: "get_commands" },
|
||||
]);
|
||||
|
||||
options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
await registry.route(agent.id, "submit_prompt", {
|
||||
message: "After this turn",
|
||||
});
|
||||
assert.deepEqual(adapter.sent.at(-1), {
|
||||
type: "follow_up",
|
||||
message: "After this turn",
|
||||
});
|
||||
|
||||
options.onEvent({
|
||||
type: "stream",
|
||||
data: { event: { type: "message_update" } },
|
||||
});
|
||||
options.onEvent({ type: "queue", data: { event: { type: "queue_update" } } });
|
||||
assert.deepEqual(
|
||||
registry.eventsAfter(agent.id, 0).map(({ seq, type }) => ({ seq, type })),
|
||||
[
|
||||
{ seq: 1, type: "agent_state" },
|
||||
{ seq: 2, type: "stream" },
|
||||
{ seq: 3, type: "queue" },
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
registry.eventsAfter(agent.id, 1).map(({ seq, type }) => ({ seq, type })),
|
||||
[
|
||||
{ seq: 2, type: "stream" },
|
||||
{ seq: 3, type: "queue" },
|
||||
],
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
registry.route("missing-agent", "abort"),
|
||||
/unknown agent/,
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { startBridgeService } from "../src/bridge/service.js";
|
||||
|
||||
function connect(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
socket.once("connect", () => resolve(socket));
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function request(socket, frame) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk;
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
socket.off("data", onData);
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
socket.write(`${JSON.stringify(frame)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
function createAdapterFactory() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
startAdapter: (options) => {
|
||||
const adapter = new EventEmitter();
|
||||
adapter.sent = [];
|
||||
adapter.send = async (command) => {
|
||||
adapter.sent.push(command);
|
||||
return {
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: { command },
|
||||
};
|
||||
};
|
||||
adapter.respondToExtension = () => {};
|
||||
adapter.stop = async () => {};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("starts the home agent and dispatches local protocol requests to the selected worktree", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-service-"));
|
||||
const home = join(root, "home");
|
||||
const feature = join(root, "feature");
|
||||
await Promise.all([mkdir(home), mkdir(feature)]);
|
||||
const fixture = createAdapterFactory();
|
||||
const service = await startBridgeService({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const socket = await connect(service.socketPath);
|
||||
|
||||
try {
|
||||
const agents = await request(socket, {
|
||||
version: "v1",
|
||||
id: "list-1",
|
||||
op: "list_agents",
|
||||
});
|
||||
assert.equal(agents.result.agents.length, 1);
|
||||
assert.equal(agents.result.agents[0].worktreePath, home);
|
||||
|
||||
const selected = await request(socket, {
|
||||
version: "v1",
|
||||
id: "select-1",
|
||||
op: "select_agent",
|
||||
payload: { worktreePath: feature },
|
||||
});
|
||||
const featureAgent = selected.result.agent;
|
||||
const prompt = await request(socket, {
|
||||
version: "v1",
|
||||
id: "prompt-1",
|
||||
op: "prompt",
|
||||
agentId: featureAgent.id,
|
||||
payload: { message: "Use this worktree" },
|
||||
});
|
||||
|
||||
assert.equal(prompt.ok, true);
|
||||
assert.deepEqual(
|
||||
fixture.calls.map(({ options }) => options.cwd),
|
||||
[home, feature],
|
||||
);
|
||||
assert.deepEqual(fixture.calls[1].adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Use this worktree" },
|
||||
]);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { startBridgeService } from "../src/bridge/service.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
test("executes an approved bridge request through the local client CLI", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-cli-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const service = await startBridgeService({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: () => ({
|
||||
send: async () => ({ type: "response", success: true }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"src/client/cli.js",
|
||||
"--socket",
|
||||
service.socketPath,
|
||||
"request",
|
||||
'{"op":"list_agents"}',
|
||||
],
|
||||
{ cwd: process.cwd() },
|
||||
);
|
||||
const result = JSON.parse(stdout);
|
||||
assert.equal(result.agents.length, 1);
|
||||
assert.equal(result.agents[0].worktreePath, home);
|
||||
} finally {
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { startBridgeDaemon } from "../src/bridge/daemon.js";
|
||||
import { createNoctaliaStateRelay } from "../src/client/noctalia-relay.js";
|
||||
|
||||
test("starts a bridge daemon around the local bridge service", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const daemon = await startBridgeDaemon({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: () => ({
|
||||
send: async () => ({ type: "response", success: true }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.match(daemon.socketPath, /bridge\.sock$/);
|
||||
assert.equal(daemon.listAgents()[0].worktreePath, home);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("relays only the selected agent's bridge state into a presentation update", async () => {
|
||||
let listener;
|
||||
const updates = [];
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client: {
|
||||
request: async () => ({ data: { isStreaming: false } }),
|
||||
subscribe: async (_agentId, _cursor, callback) => {
|
||||
listener = callback;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
agent: { id: "agent-1", worktreePath: "/worktrees/feature" },
|
||||
onState: (state) => updates.push(state),
|
||||
});
|
||||
|
||||
await relay.start();
|
||||
listener({ type: "agent_state", data: { state: "streaming" } });
|
||||
listener({
|
||||
type: "queue",
|
||||
data: { event: { steering: ["focus"], followUp: [] } },
|
||||
});
|
||||
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
state: "idle",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 0,
|
||||
detail: "Idle",
|
||||
},
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 0,
|
||||
detail: "Streaming",
|
||||
},
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 1,
|
||||
detail: "Streaming",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
BridgeAlreadyRunningError,
|
||||
BridgeLockError,
|
||||
acquireBridgeLock,
|
||||
lockMode,
|
||||
} from "../src/bridge/instance-lock.js";
|
||||
|
||||
async function paths() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-lock-"));
|
||||
return {
|
||||
lockPath: join(directory, "bridge.lock"),
|
||||
socketPath: join(directory, "bridge.sock"),
|
||||
};
|
||||
}
|
||||
|
||||
test("acquires an owner-only lock and releases only its own token", async () => {
|
||||
const { lockPath, socketPath } = await paths();
|
||||
const lock = await acquireBridgeLock({
|
||||
lockPath,
|
||||
socketPath,
|
||||
pid: 101,
|
||||
now: () => new Date("2026-01-02T03:04:05.000Z"),
|
||||
});
|
||||
const lockContents = await readFile(lockPath, "utf8");
|
||||
let metadata;
|
||||
try {
|
||||
metadata = JSON.parse(lockContents);
|
||||
} catch (error) {
|
||||
assert.fail(
|
||||
error instanceof Error ? error.message : "lock file was not JSON",
|
||||
);
|
||||
}
|
||||
|
||||
const lockStats = await stat(lockPath);
|
||||
assert.equal(metadata.pid, 101);
|
||||
assert.equal(metadata.socketPath, socketPath);
|
||||
assert.equal(lockStats.mode & 0o777, 0o600);
|
||||
assert.equal(lockMode, 0o600);
|
||||
assert.equal(await lock.release(), true);
|
||||
assert.equal(await lock.release(), false);
|
||||
});
|
||||
|
||||
test("refuses to replace a live owner", async () => {
|
||||
const { lockPath, socketPath } = await paths();
|
||||
await writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
pid: 202,
|
||||
socketPath,
|
||||
startedAt: "2026-01-02T03:04:05.000Z",
|
||||
token: "live-owner",
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
acquireBridgeLock({
|
||||
lockPath,
|
||||
socketPath,
|
||||
pid: 303,
|
||||
processAlive: (pid) => pid === 202,
|
||||
}),
|
||||
BridgeAlreadyRunningError,
|
||||
);
|
||||
});
|
||||
|
||||
test("recovers only a proven-stale lock", async () => {
|
||||
const { lockPath, socketPath } = await paths();
|
||||
await writeFile(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
pid: 404,
|
||||
socketPath,
|
||||
startedAt: "2026-01-02T03:04:05.000Z",
|
||||
token: "stale-owner",
|
||||
}),
|
||||
);
|
||||
|
||||
const lock = await acquireBridgeLock({
|
||||
lockPath,
|
||||
socketPath,
|
||||
pid: 505,
|
||||
processAlive: () => false,
|
||||
});
|
||||
assert.equal(lock.metadata.pid, 505);
|
||||
await lock.release();
|
||||
});
|
||||
|
||||
test("does not reclaim a malformed lock", async () => {
|
||||
const { lockPath, socketPath } = await paths();
|
||||
await writeFile(lockPath, "broken");
|
||||
|
||||
await assert.rejects(
|
||||
acquireBridgeLock({
|
||||
lockPath,
|
||||
socketPath,
|
||||
pid: 606,
|
||||
processAlive: () => false,
|
||||
}),
|
||||
BridgeLockError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { connectLocalClient } from "../src/client/local-client.js";
|
||||
import { startBridgeService } from "../src/bridge/service.js";
|
||||
|
||||
test("uses only the approved local protocol for requests and live event subscriptions", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-client-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const starts = [];
|
||||
const service = await startBridgeService({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
send: async () => ({ type: "response", success: true }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
};
|
||||
starts.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
const client = await connectLocalClient({ socketPath: service.socketPath });
|
||||
|
||||
try {
|
||||
const agents = await client.request("list_agents");
|
||||
assert.equal(agents.agents.length, 1);
|
||||
const events = [];
|
||||
const unsubscribe = await client.subscribe(
|
||||
agents.agents[0].id,
|
||||
0,
|
||||
(event) => events.push(event),
|
||||
);
|
||||
starts[0].options.onEvent({
|
||||
type: "queue",
|
||||
data: { event: { type: "queue_update" } },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
version: "v1",
|
||||
seq: 1,
|
||||
type: "queue",
|
||||
agentId: agents.agents[0].id,
|
||||
data: { event: { type: "queue_update" } },
|
||||
},
|
||||
]);
|
||||
unsubscribe();
|
||||
} finally {
|
||||
await client.close();
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { publishNoctaliaState } from "../src/client/noctalia-ipc.js";
|
||||
|
||||
test("publishes presentation state through Noctalia's documented IPC command", async () => {
|
||||
const calls = [];
|
||||
await publishNoctaliaState(
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 2,
|
||||
detail: "Streaming",
|
||||
},
|
||||
{ execute: async (command, args) => calls.push({ command, args }) },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
command: "qs",
|
||||
args: [
|
||||
"-c",
|
||||
"noctalia-shell",
|
||||
"ipc",
|
||||
"call",
|
||||
"plugin:pi-status-bridge",
|
||||
"setBridgeState",
|
||||
"streaming",
|
||||
"feature",
|
||||
"2",
|
||||
"Streaming",
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => {
|
||||
const manifestText = await readFile(
|
||||
"noctalia-plugin/pi-status-bridge/manifest.json",
|
||||
"utf8",
|
||||
);
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(manifestText);
|
||||
} catch (error) {
|
||||
assert.fail(
|
||||
error instanceof Error ? error.message : "manifest was not valid JSON",
|
||||
);
|
||||
}
|
||||
const [bar, panel, main] = await Promise.all([
|
||||
readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"),
|
||||
readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"),
|
||||
readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"),
|
||||
]);
|
||||
|
||||
assert.equal(manifest.id, "pi-status-bridge");
|
||||
assert.deepEqual(manifest.entryPoints, {
|
||||
main: "Main.qml",
|
||||
barWidget: "BarWidget.qml",
|
||||
panel: "Panel.qml",
|
||||
});
|
||||
assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/);
|
||||
assert.match(panel, /allowAttach: true/);
|
||||
assert.match(main, /IpcHandler/);
|
||||
assert.match(main, /setBridgeState/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => {
|
||||
const [manifest, bridge] = await Promise.all([
|
||||
readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"),
|
||||
readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"),
|
||||
]);
|
||||
|
||||
assert.doesNotMatch(manifest, /\[\[panel\]\]/);
|
||||
assert.match(manifest, /entry = "bridge\.luau"/);
|
||||
assert.match(bridge, /PI_STATUS_UI_BINARY/);
|
||||
assert.match(bridge, /pi-status-ui/);
|
||||
assert.match(bridge, /noctalia\.runAsync/);
|
||||
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
|
||||
assert.match(bridge, /pi-status-ui\.log/);
|
||||
assert.doesNotMatch(bridge, /togglePanel/);
|
||||
assert.match(bridge, /Pi reconnecting/);
|
||||
assert.match(bridge, /discover_home_agent\(socket\)/);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import test from "node:test";
|
||||
import { startPiRpcAdapter } from "../src/bridge/pi-rpc-adapter.js";
|
||||
|
||||
class FakePiChild extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.stdin = new PassThrough();
|
||||
this.stdout = new PassThrough();
|
||||
this.stderr = new PassThrough();
|
||||
this.killed = false;
|
||||
}
|
||||
|
||||
kill(signal) {
|
||||
this.killed = true;
|
||||
this.emit("exit", null, signal);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSent(stdin) {
|
||||
try {
|
||||
return stdin
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`fake child received invalid JSON: ${error instanceof Error ? error.message : "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createFixture() {
|
||||
const child = new FakePiChild();
|
||||
const calls = [];
|
||||
let stdin = "";
|
||||
child.stdin.on("data", (chunk) => {
|
||||
stdin += chunk;
|
||||
});
|
||||
return {
|
||||
child,
|
||||
calls,
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return child;
|
||||
},
|
||||
sent: () => parseSent(stdin),
|
||||
};
|
||||
}
|
||||
|
||||
test("starts Pi in RPC mode and correlates a command response", async () => {
|
||||
const fixture = createFixture();
|
||||
const events = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
const response = adapter.send({
|
||||
type: "prompt",
|
||||
message: "Implement the bridge",
|
||||
});
|
||||
const [command] = fixture.sent();
|
||||
assert.deepEqual(fixture.calls[0], {
|
||||
command: "pi",
|
||||
args: ["--mode", "rpc", "--session-dir", "/workspace/sessions"],
|
||||
options: { cwd: "/workspace/home", stdio: ["pipe", "pipe", "pipe"] },
|
||||
});
|
||||
assert.equal(command.type, "prompt");
|
||||
assert.equal(command.message, "Implement the bridge");
|
||||
assert.match(command.id, /^bridge-/);
|
||||
|
||||
fixture.child.stdout.write('{"type":"agent_start"}\n');
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: command.id, command: "prompt", success: true })}\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(await response, {
|
||||
type: "response",
|
||||
id: command.id,
|
||||
command: "prompt",
|
||||
success: true,
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
seq: 1,
|
||||
type: "agent_state",
|
||||
data: { state: "streaming", event: { type: "agent_start" } },
|
||||
},
|
||||
]);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("requests Pi session statistics for context and token status", async () => {
|
||||
const fixture = createFixture();
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
});
|
||||
|
||||
const pending = adapter.send({ type: "get_session_stats" });
|
||||
const [command] = fixture.sent();
|
||||
assert.equal(command.type, "get_session_stats");
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: command.id, command: "get_session_stats", success: true, data: { contextUsage: { tokens: 32000, contextWindow: 200000 } } })}\n`,
|
||||
);
|
||||
const stats = await pending;
|
||||
assert.equal(stats.data.contextUsage.tokens, 32000);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("forwards extension responses without replacing Pi's request ID", async () => {
|
||||
const fixture = createFixture();
|
||||
const events = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
const extensionRequest = {
|
||||
type: "extension_ui_request",
|
||||
id: "extension-request-1",
|
||||
method: "confirm",
|
||||
title: "Keep U+2028 here",
|
||||
message: "Approve?",
|
||||
};
|
||||
fixture.child.stdout.write(`${JSON.stringify(extensionRequest)}\n`);
|
||||
adapter.respondToExtension("extension-request-1", { confirmed: false });
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{ seq: 1, type: "extension_ui_request", data: { event: extensionRequest } },
|
||||
]);
|
||||
assert.deepEqual(fixture.sent(), [
|
||||
{
|
||||
type: "extension_ui_response",
|
||||
id: "extension-request-1",
|
||||
confirmed: false,
|
||||
},
|
||||
]);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("reports invalid child output and rejects pending commands on child exit", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
|
||||
const pending = adapter.send({ type: "get_state" });
|
||||
fixture.child.stdout.write("not-json\n");
|
||||
fixture.child.emit("exit", 1, null);
|
||||
|
||||
await assert.rejects(pending, /exited before responding/);
|
||||
assert.equal(errors[0].code, "invalid_json");
|
||||
await adapter.stop();
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import test from "node:test";
|
||||
import { startPiRpcAdapter } from "../src/bridge/pi-rpc-adapter.js";
|
||||
|
||||
function createChild() {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = (signal) => {
|
||||
child.emit("exit", null, signal);
|
||||
return true;
|
||||
};
|
||||
return child;
|
||||
}
|
||||
|
||||
test("normalizes streaming, tool, and queue events in child output order", async () => {
|
||||
const child = createChild();
|
||||
const events = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: () => child,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
const piEvents = [
|
||||
{
|
||||
type: "message_update",
|
||||
message: { role: "assistant" },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "Hello" },
|
||||
},
|
||||
{
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "pwd" },
|
||||
},
|
||||
{
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "todo-1",
|
||||
toolName: "todo",
|
||||
result: {
|
||||
details: {
|
||||
tasks: [{ id: 1, subject: "Ship pane", status: "in_progress" }],
|
||||
},
|
||||
},
|
||||
isError: false,
|
||||
},
|
||||
{ type: "queue_update", steering: ["Refocus"], followUp: [] },
|
||||
];
|
||||
child.stdout.write(JSON.stringify(piEvents[0]).slice(0, 30));
|
||||
child.stdout.write(
|
||||
`${JSON.stringify(piEvents[0]).slice(30)}\n${JSON.stringify(piEvents[1])}\n${JSON.stringify(piEvents[2])}\n${JSON.stringify(piEvents[3])}\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{ seq: 1, type: "stream", data: { event: piEvents[0] } },
|
||||
{ seq: 2, type: "tool", data: { event: piEvents[1] } },
|
||||
{ seq: 3, type: "tool", data: { event: piEvents[2] } },
|
||||
{ seq: 4, type: "queue", data: { event: piEvents[3] } },
|
||||
]);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("marks Pi idle only after it settles and preserves extension errors as diagnostics", async () => {
|
||||
const child = createChild();
|
||||
const events = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: () => child,
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
const piEvents = [
|
||||
{ type: "agent_start" },
|
||||
{ type: "agent_end", willRetry: false },
|
||||
{ type: "agent_settled" },
|
||||
{ type: "extension_error", error: "non-fatal extension failure" },
|
||||
];
|
||||
child.stdout.write(
|
||||
`${piEvents.map((event) => JSON.stringify(event)).join("\n")}\n`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
events.map((event) => ({ type: event.type, state: event.data.state })),
|
||||
[
|
||||
{ type: "agent_state", state: "streaming" },
|
||||
{ type: "transcript", state: undefined },
|
||||
{ type: "agent_state", state: "idle" },
|
||||
{ type: "transcript", state: undefined },
|
||||
],
|
||||
);
|
||||
await adapter.stop();
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
MAX_FRAME_BYTES,
|
||||
ProtocolError,
|
||||
parseRequestFrame,
|
||||
validateEvent,
|
||||
} from "../src/protocol/index.js";
|
||||
|
||||
const request = (value) =>
|
||||
JSON.stringify({ version: "v1", id: "request-1", ...value });
|
||||
|
||||
test("accepts a command scoped to its selected agent", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "prompt",
|
||||
agentId: "agent-main",
|
||||
payload: { message: "implement the protocol" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "prompt",
|
||||
agentId: "agent-main",
|
||||
payload: { message: "implement the protocol" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts an agent transcript request without a synthetic cursor", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "get_transcript", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "get_transcript",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts bridge-managed directory and session operations", () => {
|
||||
assert.deepEqual(parseRequestFrame(request({ op: "list_directories" })), {
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "list_directories",
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "list_sessions",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "new_session", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "new_session",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "switch_session",
|
||||
agentId: "agent-main",
|
||||
payload: { sessionPath: "/sessions/session.jsonl" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "switch_session",
|
||||
agentId: "agent-main",
|
||||
payload: { sessionPath: "/sessions/session.jsonl" },
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "switch_session",
|
||||
agentId: "agent-main",
|
||||
payload: { sessionPath: "relative.jsonl" },
|
||||
}),
|
||||
),
|
||||
/must be absolute/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts an agent session-statistics request", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({ op: "get_session_stats", agentId: "agent-main" }),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "get_session_stats",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a default composer submit request", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "submit_prompt",
|
||||
agentId: "agent-main",
|
||||
payload: { message: "Continue" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "submit_prompt",
|
||||
agentId: "agent-main",
|
||||
payload: { message: "Continue" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a selected agent get_commands request", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "get_commands", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "get_commands",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects malformed, unknown, and oversized frames", () => {
|
||||
assert.throws(() => parseRequestFrame("not json"), ProtocolError);
|
||||
assert.throws(
|
||||
() => parseRequestFrame(request({ op: "teleport" })),
|
||||
/not supported/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseRequestFrame("x".repeat(MAX_FRAME_BYTES + 1)),
|
||||
/frame exceeds/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects cross-worktree routing fields on agent commands", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "prompt",
|
||||
agentId: "agent-main",
|
||||
payload: { message: "wrong worktree", worktreePath: "/other" },
|
||||
}),
|
||||
),
|
||||
/not supported/,
|
||||
);
|
||||
});
|
||||
|
||||
test("requires explicit absolute worktree selection", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "select_agent",
|
||||
payload: { worktreePath: "relative/project" },
|
||||
}),
|
||||
),
|
||||
/must be absolute/,
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes sequenced bridge events", () => {
|
||||
assert.deepEqual(
|
||||
validateEvent({
|
||||
version: "v1",
|
||||
seq: 2,
|
||||
type: "extension_ui_request",
|
||||
agentId: "agent-main",
|
||||
data: { id: "extension-1", method: "confirm" },
|
||||
}),
|
||||
{
|
||||
version: "v1",
|
||||
seq: 2,
|
||||
type: "extension_ui_request",
|
||||
agentId: "agent-main",
|
||||
data: { id: "extension-1", method: "confirm" },
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRecoverySupervisor } from "../src/bridge/recovery-supervisor.js";
|
||||
|
||||
test("restarts after bounded exponential delays and stops after exhaustion", async () => {
|
||||
const delays = [];
|
||||
const states = [];
|
||||
let starts = 0;
|
||||
const supervisor = createRecoverySupervisor({
|
||||
start: async () => ({ attempt: ++starts }),
|
||||
maxAttempts: 2,
|
||||
delayForAttempt: (attempt) => attempt * 100,
|
||||
sleep: async (delay) => delays.push(delay),
|
||||
onState: (state) => states.push(state),
|
||||
});
|
||||
|
||||
assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 1 });
|
||||
assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 2 });
|
||||
assert.equal(await supervisor.handleUnexpectedExit(), undefined);
|
||||
assert.deepEqual(delays, [100, 200]);
|
||||
assert.deepEqual(
|
||||
states.map(({ state, attempt }) => ({ state, attempt })),
|
||||
[
|
||||
{ state: "recovering", attempt: 1 },
|
||||
{ state: "healthy", attempt: 1 },
|
||||
{ state: "recovering", attempt: 2 },
|
||||
{ state: "healthy", attempt: 2 },
|
||||
{ state: "failed", attempt: 2 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("restarts failed launches within the same bounded recovery budget", async () => {
|
||||
let starts = 0;
|
||||
const supervisor = createRecoverySupervisor({
|
||||
start: async () => {
|
||||
starts += 1;
|
||||
if (starts === 1) throw new Error("temporary spawn failure");
|
||||
return { attempt: starts };
|
||||
},
|
||||
maxAttempts: 2,
|
||||
delayForAttempt: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(await supervisor.handleUnexpectedExit(), { attempt: 2 });
|
||||
assert.equal(starts, 2);
|
||||
});
|
||||
|
||||
test("cancels scheduled recovery when stopped", async () => {
|
||||
let resolveSleep;
|
||||
const supervisor = createRecoverySupervisor({
|
||||
start: async () => assert.fail("must not restart after stop"),
|
||||
delayForAttempt: () => 1,
|
||||
sleep: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveSleep = resolve;
|
||||
}),
|
||||
});
|
||||
|
||||
const recovering = supervisor.handleUnexpectedExit();
|
||||
supervisor.stop();
|
||||
resolveSleep();
|
||||
assert.equal(await recovering, undefined);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import test from "node:test";
|
||||
import { BridgeAlreadyRunningError } from "../src/bridge/instance-lock.js";
|
||||
import {
|
||||
createRuntimePaths,
|
||||
startBridgeServer,
|
||||
} from "../src/bridge/runtime.js";
|
||||
|
||||
test("creates a private runtime directory and enforces one live bridge", async () => {
|
||||
const runtimeDir = await mkdtemp(`${tmpdir()}/pi-status-bridge-runtime-`);
|
||||
const paths = await createRuntimePaths(runtimeDir);
|
||||
const runtimeStats = await stat(paths.directory);
|
||||
assert.equal(runtimeStats.mode & 0o777, 0o700);
|
||||
|
||||
const bridge = await startBridgeServer({
|
||||
runtimeDir,
|
||||
handleRequest: async () => ({}),
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
startBridgeServer({ runtimeDir, handleRequest: async () => ({}) }),
|
||||
BridgeAlreadyRunningError,
|
||||
);
|
||||
} finally {
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { startBridgeService } from "../src/bridge/service.js";
|
||||
|
||||
function connect(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
socket.once("connect", () => resolve(socket));
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function collectFrames(socket, count) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const frames = [];
|
||||
let buffer = "";
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk;
|
||||
while (buffer.includes("\n")) {
|
||||
const index = buffer.indexOf("\n");
|
||||
const line = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 1);
|
||||
try {
|
||||
frames.push(JSON.parse(line));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (frames.length === count) {
|
||||
socket.off("data", onData);
|
||||
resolve(frames);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("replays then streams subscribed agent events on the same local socket", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const calls = [];
|
||||
const service = await startBridgeService({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
send: async () => ({ type: "response", success: true }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
calls[0].options.onEvent({
|
||||
type: "stream",
|
||||
data: { event: { type: "message_update", delta: "replayed" } },
|
||||
});
|
||||
const socket = await connect(service.socketPath);
|
||||
|
||||
try {
|
||||
const frames = collectFrames(socket, 2);
|
||||
socket.write(
|
||||
`${JSON.stringify({ version: "v1", id: "subscribe-1", op: "subscribe", agentId: service.listAgents()[0].id, payload: {} })}\n`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
calls[0].options.onEvent({
|
||||
type: "stream",
|
||||
data: { event: { type: "message_update", delta: "live" } },
|
||||
});
|
||||
}, 10);
|
||||
|
||||
assert.deepEqual(await frames, [
|
||||
{
|
||||
version: "v1",
|
||||
id: "subscribe-1",
|
||||
ok: true,
|
||||
result: {
|
||||
events: [
|
||||
{
|
||||
version: "v1",
|
||||
seq: 1,
|
||||
type: "stream",
|
||||
agentId: service.listAgents()[0].id,
|
||||
data: { event: { type: "message_update", delta: "replayed" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "v1",
|
||||
type: "event",
|
||||
event: {
|
||||
version: "v1",
|
||||
seq: 2,
|
||||
type: "stream",
|
||||
agentId: service.listAgents()[0].id,
|
||||
data: { event: { type: "message_update", delta: "live" } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
|
||||
const source = await readFile("ui/src/App.tsx", "utf8");
|
||||
for (const method of [
|
||||
"select",
|
||||
"confirm",
|
||||
"input",
|
||||
"editor",
|
||||
"notify",
|
||||
"setStatus",
|
||||
"setWidget",
|
||||
"setTitle",
|
||||
"set_editor_text",
|
||||
]) {
|
||||
assert.match(source, new RegExp(`case "${method}"`));
|
||||
}
|
||||
assert.match(source, /setPendingExtension\(extension\)/);
|
||||
assert.doesNotMatch(source, /This extension UI is unsupported/);
|
||||
assert.match(source, /function stripAnsi/);
|
||||
assert.match(source, /stripAnsi\(extension\.statusText\)/);
|
||||
assert.match(source, /className="pi-controls"/);
|
||||
assert.match(source, /aria-label="Model and thinking controls"/);
|
||||
assert.doesNotMatch(source, /<details className="pi-controls">/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
|
||||
);
|
||||
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
|
||||
assert.match(
|
||||
source,
|
||||
/window\.addEventListener\("keydown", handleKeydown, true\)/,
|
||||
);
|
||||
assert.match(source, /useLayoutEffect/);
|
||||
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
||||
);
|
||||
assert.match(source, /generation !== loadGeneration\.current/);
|
||||
assert.match(source, /current\.phase !== "working"/);
|
||||
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
|
||||
assert.match(source, /\.toFixed\(1\)/);
|
||||
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
|
||||
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
|
||||
assert.match(source, /formatTokens\(cacheTokens\)/);
|
||||
assert.match(source, /function updateWorkProgress/);
|
||||
assert.match(
|
||||
source,
|
||||
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
assert.match(source, /event\.type === "tool"/);
|
||||
assert.match(source, /event\.type === "queue"/);
|
||||
assert.match(source, /composer-progress/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("makes the window draggable from the non-interactive header", async () => {
|
||||
const [component, styles, capability] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
|
||||
]);
|
||||
assert.match(component, /startDragging\(\)/);
|
||||
assert.match(
|
||||
component,
|
||||
/closest\(\s*"button, input, select, textarea",?\s*\)/,
|
||||
);
|
||||
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
|
||||
assert.match(capability, /core:window:allow-start-dragging/);
|
||||
});
|
||||
|
||||
test("opens local follow-up choices for /model and /resume", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /const controlCommands: Command\[\]/);
|
||||
assert.match(component, /followUp: "model"/);
|
||||
assert.match(component, /followUp: "resume"/);
|
||||
assert.match(component, /followUp: "thinking"/);
|
||||
assert.match(component, /const nativeCommand = controlCommands\.find/);
|
||||
assert.match(component, /function chooseThinking/);
|
||||
assert.match(component, /function openCommandFollowUp/);
|
||||
assert.match(component, /function chooseModel/);
|
||||
assert.match(component, /className="command-followup"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(styles, /\.command-followup \{/);
|
||||
assert.match(styles, /\.command-choice-list \{/);
|
||||
});
|
||||
|
||||
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(
|
||||
component,
|
||||
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
||||
);
|
||||
assert.match(component, /function chooseDirectory/);
|
||||
assert.match(component, /function startNewSession/);
|
||||
assert.match(component, /function switchSession/);
|
||||
assert.match(component, /className="session-panel"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(component, /state\.isStreaming/);
|
||||
assert.match(styles, /\.session-panel \{/);
|
||||
assert.match(styles, /\.session-list \{/);
|
||||
});
|
||||
|
||||
test("renders todo-plugin state and compact context/token status", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /function currentTodos/);
|
||||
assert.match(component, /message\.toolName === "todo"/);
|
||||
assert.match(component, /className="todos-pane"/);
|
||||
assert.match(component, /className="workflow-main"/);
|
||||
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
|
||||
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
|
||||
assert.match(styles, /\.workflow-main \{/);
|
||||
assert.match(styles, /\.todos-pane \{/);
|
||||
assert.match(styles, /\.status-metric \{/);
|
||||
});
|
||||
|
||||
test("shows an optimistic prompt until the transcript receives it", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /type PendingSubmission/);
|
||||
assert.match(
|
||||
component,
|
||||
/const \[pendingSubmissions, setPendingSubmissions\]/,
|
||||
);
|
||||
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
|
||||
assert.match(component, /phase: "sending"/);
|
||||
assert.match(component, /phase: "sent"/);
|
||||
assert.match(component, /Sent · waiting for Pi/);
|
||||
assert.match(component, /receivedMessages\.some/);
|
||||
assert.match(component, /pending-message/);
|
||||
assert.match(styles, /\.pending-message \{/);
|
||||
assert.match(styles, /\.pending-message\.sending/);
|
||||
});
|
||||
|
||||
test("distinguishes user and assistant messages", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.message\.user \{/);
|
||||
assert.match(styles, /background: #192536/);
|
||||
assert.match(styles, /\.message\.assistant \{/);
|
||||
assert.match(styles, /background: #1a241a/);
|
||||
});
|
||||
|
||||
test("uses a subtle focus treatment for the prompt editor", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.composer textarea:focus \{/);
|
||||
assert.match(styles, /outline: none/);
|
||||
assert.match(styles, /border-color: #6f8b56/);
|
||||
});
|
||||
|
||||
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
||||
const source = await readFile("ui/src/App.tsx", "utf8");
|
||||
assert.match(source, /onKeyDown=\{\(event\) => \{/);
|
||||
assert.match(source, /event\.key !== "Enter"/);
|
||||
assert.match(source, /event\.shiftKey/);
|
||||
assert.match(source, /event\.nativeEvent\.isComposing/);
|
||||
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
|
||||
assert.match(source, /Enter to send · Shift\+Enter for newline/);
|
||||
});
|
||||
|
||||
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /className="workflow-header"/);
|
||||
assert.match(component, /className="workflow"/);
|
||||
assert.match(component, /className="workflow-footer"/);
|
||||
assert.match(component, /className="composer-stack"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
|
||||
assert.match(
|
||||
component,
|
||||
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
|
||||
);
|
||||
assert.match(styles, /\.transcript\.working \{/);
|
||||
assert.match(styles, /conic-gradient\(/);
|
||||
assert.match(styles, /transcript-border-orbit/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/,
|
||||
);
|
||||
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createConnection } from "node:net";
|
||||
import test from "node:test";
|
||||
import { startUnixSocketServer } from "../src/bridge/unix-server.js";
|
||||
|
||||
function connect(socketPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(socketPath);
|
||||
socket.once("connect", () => resolve(socket));
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readFrame(socket) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk;
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
socket.off("data", onData);
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("serves validated JSONL requests over an owner-only Unix socket", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-"));
|
||||
const socketPath = join(directory, "bridge.sock");
|
||||
const bridge = await startUnixSocketServer({
|
||||
socketPath,
|
||||
handleRequest: async (request) => ({ echoedOperation: request.op }),
|
||||
});
|
||||
const socket = await connect(socketPath);
|
||||
|
||||
try {
|
||||
const socketStats = await stat(socketPath);
|
||||
const mode = socketStats.mode & 0o777;
|
||||
assert.equal(mode, 0o600);
|
||||
|
||||
const response = readFrame(socket);
|
||||
socket.write(
|
||||
`${JSON.stringify({ version: "v1", id: "request-2", op: "list_agents" })}\r\n`,
|
||||
);
|
||||
assert.deepEqual(await response, {
|
||||
version: "v1",
|
||||
id: "request-2",
|
||||
ok: true,
|
||||
result: { echoedOperation: "list_agents" },
|
||||
});
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns protocol errors without routing invalid input", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "pi-status-bridge-"));
|
||||
const socketPath = join(directory, "bridge.sock");
|
||||
const bridge = await startUnixSocketServer({
|
||||
socketPath,
|
||||
handleRequest: async () => ({}),
|
||||
});
|
||||
const socket = await connect(socketPath);
|
||||
|
||||
try {
|
||||
const response = readFrame(socket);
|
||||
socket.write('{"version":"v1","id":"request-3","op":"unknown"}\n');
|
||||
assert.deepEqual(await response, {
|
||||
version: "v1",
|
||||
id: "request-3",
|
||||
ok: false,
|
||||
error: {
|
||||
code: "unsupported_operation",
|
||||
message: "operation unknown is not supported",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await bridge.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user