Compare commits

...
35 changed files with 457 additions and 436 deletions

No files matched your search

+88 -114
View File
@@ -1,31 +1,15 @@
name: Tests
# Builds, type-checks and runs every test suite on a clean GitHub runner, and records the file
# list each published package would contain. Each suite is its own job so one failure does not
# hide the others. No provider credentials or operator configuration are supplied.
# Package suites deny external networking; scenario runtime has no external route.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
on: workflow_dispatch
permissions:
contents: read
concurrency:
group: tests-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-package:
baseline-command-names:
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
with:
ref: edd0a967e67af10b5513439cd14f87eeeabd179f
persist-credentials: false
- uses: actions/setup-node@v4
with:
@@ -33,110 +17,100 @@ jobs:
cache: npm
- run: npm ci
- run: npm run build
- name: Record the published file list of each package
- name: Measure the old command and absent new command at the selected base
run: |
set -euo pipefail
mkdir -p pack-lists
for p in cli daemon tui; do
(cd packages/$p && npm pack --dry-run --json --ignore-scripts) \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{for(const f of JSON.parse(s)[0].files)console.log(f.path)})' \
| sort > pack-lists/$p.txt
echo "$p: $(wc -l < pack-lists/$p.txt) files"
done
- uses: actions/upload-artifact@v4
with:
name: pack-lists
path: pack-lists/
typecheck:
node packages/cli/dist/bin-wrapper.js --help > /tmp/baseline-help.txt
node packages/cli/dist/bin-wrapper.js agent-app list --help > /tmp/baseline-list-help.txt
grep -F 'agent-app' /tmp/baseline-help.txt
! grep -F 'community-harness' /tmp/baseline-help.txt
set +e
node packages/cli/dist/bin-wrapper.js community-harness list --help > /tmp/new-before.txt 2>&1
result=$?
set -e
test "$result" != 0
cat /tmp/baseline-list-help.txt /tmp/new-before.txt
candidate-behavior-and-package:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint
repo-checks:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:repo
package-tests:
if: ${{ github.event_name != 'push' }}
# macOS, the platform OpenRig runs on: several daemon and cli tests read macOS ps columns,
# file-inode behaviour and release tags.
runs-on: macos-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
package: [daemon, cli, tui, ui]
timeout-minutes: 22
steps:
- uses: actions/checkout@v4
with:
ref: 13fe8d599954cb8542295c353f51074477fb07c0
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: brew install tmux
- run: sudo apt-get update -qq && sudo apt-get install -y tmux
- run: npm ci
- name: Build the packages the tests import
run: npm run build
- name: Run the suite without ambient credentials or external network
- name: Build and assemble only this test candidate
run: npm run build:package
- name: Typecheck all packages
run: npm run lint
- name: Focused renamed consumers, built-ins and migration preservation
run: npx vitest run packages/daemon/test/community-harness-runtime.test.ts packages/daemon/test/restore-orchestrator.test.ts packages/daemon/test/seat-set-cwd.test.ts packages/daemon/test/migration-fixture-parity.test.ts packages/cli/test/lazy-program.test.ts packages/cli/test/ps.test.ts packages/tui/test/hydrate.test.ts
- name: Real private daemon, HTTP, CLI, SQLite, tmux launch and delivery
run: npx vitest run packages/daemon/test/community-harness-runtime.integration.test.ts
- name: Package and source name inventory with enumerated storage exceptions
run: |
suite_root=$(mktemp -d "$RUNNER_TEMP/opr-test.XXXXXX")
suite_root=$(cd "$suite_root" && pwd -P)
mkdir -p "$suite_root/home" "$suite_root/bin"
# Same system bytes, nonprivileged mode; no system file is changed.
/usr/bin/install -m 0755 /bin/ps "$suite_root/bin/ps"
# Only this job's temporary sockets (including tmux) are reachable.
env -i HOME="$suite_root/home" PATH="$PATH" TMPDIR="$suite_root" TMUX_TMPDIR="$suite_root" \
CI=true TERM=xterm-256color \
sandbox-exec -D IPC_ROOT="$suite_root" -p '(version 1)(allow default)(deny network-outbound)(allow network-outbound (remote ip "localhost:*"))(allow network-outbound (subpath (param "IPC_ROOT")))' \
/bin/bash -s "${{ matrix.package }}" <<'SUITE'
set -euo pipefail
node scripts/ci-macos-process-probe.mjs
# The production readers use bare ps. Select real system bytes only after
# self/owned-child observation and network/IPC confinement are proved.
export PATH="$TMPDIR/bin:$PATH"
test "$(command -v ps)" = "$TMPDIR/bin/ps"
exec npm run test -w "packages/$1"
SUITE
installed-scenario:
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Installed package, daemon restart, and seeded durability failure
run: bash scripts/run-pr-scenarios.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: installed-scenario-evidence
path: dist/pr-scenarios/
if-no-files-found: error
npm pack --workspace packages/cli --ignore-scripts --pack-destination packages/cli
python3 - <<'PYCODE'
import pathlib,re,subprocess,json,tarfile
pattern=re.compile(r"agent[-_ ]apps?(?:\b|_)|agentApp\w*|AgentApp\w*|AGENT_APP\w*", re.I)
root=pathlib.Path('.')
source_allow={
'packages/daemon/src/db/all-migrations.ts':['import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "./migrations/114_session_agent_app.js";'],
'packages/daemon/test/helpers/test-app.ts':['import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "../../src/db/migrations/114_session_agent_app.js";'],
'packages/daemon/src/domain/session-registry.ts':[
'this.db.prepare("UPDATE sessions SET agent_app = ? WHERE id = ?").run(app, sessionId);',
'...(row.agent_app ? { communityHarness: row.agent_app } : {}),',
'agent_app?: string | null;',
'has = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name?: string }>).some(row => row.name === "agent_app");',
'return has ? `${alias}.agent_app` : "NULL";'],
'packages/daemon/test/community-harness-runtime.test.ts':[
'CREATE TABLE sessions(id INTEGER, node_id TEXT, session_name TEXT, status TEXT, agent_app TEXT);',
'db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, agent_app) VALUES (\'s1\', \'n1\', \'w@r\', \'running\', \'ready\', ?)\").run(communityHarness);']
}
# SQL fixture strings above are checked as exact source lines below, using the actual accepted line from source.
f='packages/daemon/test/community-harness-runtime.test.ts'
source_allow[f]=[line.strip() for line in (root/f).read_text().splitlines() if pattern.search(line) and ('CREATE TABLE sessions(' in line or 'INSERT INTO sessions (' in line)]
legacy='packages/daemon/src/db/migrations/114_session_agent_app.ts'
assert (root/legacy).read_bytes()==subprocess.check_output(['git','show','edd0a967e67af10b5513439cd14f87eeeabd179f:'+legacy])
violations=[];allowed=[]
for path in subprocess.check_output(['git','ls-files','-z']).decode().split('\0'):
if not path:continue
if pattern.search(path) and path!=legacy:violations.append([path,'path'])
data=(root/path).read_bytes()
try: text=data.decode()
except UnicodeDecodeError:continue
for n,line in enumerate(text.splitlines(),1):
if not pattern.search(line):continue
if path==legacy or line.strip() in source_allow.get(path,[]):allowed.append([path,n,line.strip()])
else:violations.append([path,n,line])
print('SOURCE_NAME_SWEEP='+json.dumps({'allowed':allowed,'violations':violations}))
assert not violations
package_allow={
'package/daemon/dist/db/all-migrations.js':lambda line:'sessionAgentAppSchema as sessionCommunityHarnessSchema' in line and '114_session_agent_app.js' in line,
'package/daemon/dist/domain/session-registry.js':lambda line:any(k in line for k in ['UPDATE sessions SET agent_app = ?', 'row.agent_app ? { communityHarness: row.agent_app }', 'row.name === "agent_app"', '`${alias}.agent_app`'])
}
archive=next(pathlib.Path('packages/cli').glob('*.tgz'))
violations=[];allowed=[];names=[]
with tarfile.open(archive) as tar:
for member in tar.getmembers():
names.append(member.name)
immutable=member.name.startswith('package/daemon/dist/db/migrations/114_session_agent_app.')
if pattern.search(member.name) and not immutable:violations.append([member.name,'path'])
if not member.isfile():continue
try:text=tar.extractfile(member).read().decode()
except UnicodeDecodeError:continue
for n,line in enumerate(text.splitlines(),1):
if not pattern.search(line):continue
if immutable or package_allow.get(member.name,lambda line:False)(line):allowed.append([member.name,n,line.strip()])
else:violations.append([member.name,n,line])
assert 'package/dist/commands/community-harness.js' in names
assert 'package/daemon/dist/domain/community-harness-presets.js' in names
assert 'package/daemon/dist/routes/community-harnesses.js' in names
print('PACKAGE_NAME_SWEEP='+json.dumps({'members':len(names),'allowed':allowed,'violations':violations}))
assert not violations
PYCODE
+1 -1
View File
@@ -29,7 +29,7 @@ What a seat runs has its own words:
| --- | --- | --- |
| Runtime | What a seat runs: a harness, a terminal, or the stub (a scripted stand-in used in tests). It's the rig spec's `runtime` field and the TUI's runtime column | `claude-code`, `codex`, `terminal` |
| Harness | The agent software around a model | Claude Code, Codex, Pi |
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/agent-apps/`, listed by `rig agent-app list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
| Community harness | A harness an outside contributor maintains. It's installed as a preset under `$OPENRIG_HOME/community-harnesses/`, listed by `rig community-harness list`, and OpenRig doesn't test it | A preset's name in a member's `runtime` |
| Terminal | A seat with no agent: a shell for infrastructure processes | `runtime: terminal` |
## How the software teams grow
@@ -1,12 +1,12 @@
// `rig agent-app list`: the community agent apps installed as presets under $OPENRIG_HOME/agent-apps, each with its
// maintainer and declared limits. OpenRig hasn't tested these apps.
// `rig community-harness list`: the community harnesses installed as presets under $OPENRIG_HOME/community-harnesses, each with its
// maintainer and declared limits. OpenRig hasn't tested these harnesses.
import { Command } from "commander";
import { DaemonClient } from "../client.js";
import { getDaemonStatus, getDaemonUrl, statusGuardMessage } from "../daemon-lifecycle.js";
import { realDeps } from "./daemon.js";
import type { StatusDeps } from "./status.js";
interface AgentAppWire {
interface CommunityHarnessWire {
name: string;
usable: boolean;
maintainer?: string;
@@ -15,8 +15,8 @@ interface AgentAppWire {
problem?: string;
}
export function agentAppCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("agent-app").description("Community harnesses installed as presets (not tested by OpenRig)");
export function communityHarnessCommand(depsOverride?: StatusDeps): Command {
const cmd = new Command("community-harness").description("Community harnesses installed as presets (not tested by OpenRig)");
const getDeps = (): StatusDeps => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
cmd.command("list")
@@ -29,14 +29,14 @@ export function agentAppCommand(depsOverride?: StatusDeps): Command {
if (status.state !== "running" || status.healthy === false) {
const gm = statusGuardMessage(status); throw new Error(`${gm.fact} ${gm.action}`);
}
const res = await deps.clientFactory(getDaemonUrl(status)).get<{ dir: string; apps: AgentAppWire[] }>("/api/agent-apps");
const res = await deps.clientFactory(getDaemonUrl(status)).get<{ dir: string; harnesses: CommunityHarnessWire[] }>("/api/community-harnesses");
if (res.status !== 200 || !res.data) throw new Error(`Daemon returned HTTP ${res.status}`);
if (opts.json) { console.log(JSON.stringify(res.data, null, 2)); return; }
if (res.data.apps.length === 0) {
if (res.data.harnesses.length === 0) {
console.log(`No community harness presets installed. Add one as ${res.data.dir}/<name>.yaml.`);
return;
}
for (const app of res.data.apps) {
for (const app of res.data.harnesses) {
if (!app.usable) { console.log(`${app.name}: can't be used. ${app.problem}`); continue; }
const limits = app.limits?.length ? ` Declared limits: ${app.limits.join("; ")}.` : "";
console.log(`${app.name}: community harness, maintained by ${app.maintainer}, not tested by OpenRig.${limits}`);
+13 -13
View File
@@ -79,8 +79,8 @@ interface NodeEntry {
decidedBy: string | null;
} | null;
identityVerdict?: { reason?: string | null } | null;
/** A community agent app's seat: its label, maintainer and declared limits. */
agentApp?: { app: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
/** A community harness's seat: its label, maintainer and declared limits. */
communityHarness?: { harness: string; maintainer: string | null; limits: string[]; identity: string; needsInput: string; presetError?: string };
/** Slice 15 — `has-work-to-do` primitive. Derived from queue_items;
* NEVER derived from terminalActive. */
hasAssignedWork?: boolean;
@@ -207,7 +207,7 @@ const ALLOWED_NODE_FIELDS = new Set([
"canonicalSessionName",
"nodeKind",
"runtime",
"agentApp",
"communityHarness",
"model",
"modelObservation",
"sessionStatus",
@@ -623,11 +623,11 @@ function hasCurrentUsageLimit(node: NodeEntry): boolean {
&& activity.stale === false && !activity.fallback;
}
/** One line per community agent app seat: what OpenRig knows about it, and what it doesn't. */
export function formatAgentAppLine(seat: string, app: NonNullable<NodeEntry["agentApp"]>): string {
if (app.presetError) return `Community harness (${seat}): ${app.app}: ${app.presetError}.`;
/** One line per community harness seat: what OpenRig knows about it, and what it doesn't. */
export function formatCommunityHarnessLine(seat: string, app: NonNullable<NodeEntry["communityHarness"]>): string {
if (app.presetError) return `Community harness (${seat}): ${app.harness}: ${app.presetError}.`;
const limits = app.limits.length ? `; declared limits: ${app.limits.join("; ")}` : "";
return `Community harness (${seat}): ${app.app}, maintained by ${app.maintainer}, not tested by OpenRig. Identity ${app.identity}; needs-input ${app.needsInput}${limits}.`;
return `Community harness (${seat}): ${app.harness}, maintained by ${app.maintainer}, not tested by OpenRig. Identity ${app.identity}; needs-input ${app.needsInput}${limits}.`;
}
function missingSessionGuidance(node: NodeEntry): string | null {
@@ -675,7 +675,7 @@ export function compactNodeProjection(nodes: NodeEntry[]): Array<Record<string,
// runtime is complete fleet-wide; model is a DECLARATION, absent on 13 of
// 15 claude-code seats — see formatDeclaredModel for the render rule.
runtime: n.runtime ?? null,
...(n.agentApp ? { agentApp: n.agentApp } : {}),
...(n.communityHarness ? { communityHarness: n.communityHarness } : {}),
model: n.model ?? null,
// Compact tuple: [literal model, evidence source, native record time].
// Full JSON / --fields retain the complete labelled object and occupant generation.
@@ -862,7 +862,7 @@ contextUsage.percent, contextUsage.state (with --nodes). Other keys are rejected
activeCount, hasWorkCount, attentionCount, status, lifecycleState, uptime,
latestSnapshot.
--fields accepts (node-level, with --nodes): rigId, rigName, logicalId, podId,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, agentApp, model, sessionStatus,
podNamespace, hostSelfId, canonicalSessionName, nodeKind, runtime, communityHarness, model, sessionStatus,
intentionallyStopped, startupStatus, restoreOutcome, lifecycleState, tmuxAttachCommand,
resumeCommand, latestError, terminalActive, hasAssignedWork,
assignedWorkCount, pendingWorkCount, inProgressWorkCount, blockedWorkCount,
@@ -1312,7 +1312,7 @@ async function handleNodes(
}
}
for (const n of humanList as NodeEntry[]) {
if (n.agentApp) console.log(formatAgentAppLine(n.canonicalSessionName ?? n.logicalId, n.agentApp));
if (n.communityHarness) console.log(formatCommunityHarnessLine(n.canonicalSessionName ?? n.logicalId, n.communityHarness));
}
if (humanTruncated) {
const remaining = filtered.length - HUMAN_NODE_BUDGET;
@@ -1324,15 +1324,15 @@ async function handleNodes(
}
}
function formatActivity(n: Pick<NodeEntry, "agentActivity" | "activityState" | "agentApp">): string {
function formatActivity(n: Pick<NodeEntry, "agentActivity" | "activityState" | "communityHarness">): string {
// S19 — render from the served taxonomy state when present: needs-input as
// count(+reason), otherwise the bridge's display value. No local arbitration.
const tax = n.activityState;
if (tax) {
if (tax.needsInput.count > 0) return `needs-input x${tax.needsInput.count}`;
// A community agent app has no needs-input source in that state, so its zero isn't a clearance: a prompt the
// A community harness has no needs-input source in that state, so its zero isn't a clearance: a prompt the
// screen shows is kept.
if (n.agentApp && n.agentActivity?.state === "needs_input") return "needs-input (screen)";
if (n.communityHarness && n.agentActivity?.state === "needs_input") return "needs-input (screen)";
return tax.display;
}
// Legacy fallback (a daemon without the S19 enrichment) — retires with the
+3 -3
View File
@@ -70,7 +70,7 @@ import { contextCommand } from "./commands/context.js";
import { pluginCommand } from "./commands/plugin.js";
import { skillCommand } from "./commands/skill.js";
import { agentImageCommand } from "./commands/agent-image.js";
import { agentAppCommand } from "./commands/agent-app.js";
import { communityHarnessCommand } from "./commands/community-harness.js";
import { forkCommand } from "./commands/fork.js";
import { workspaceCommand, type WorkspaceDeps } from "./commands/workspace.js";
import { whoamiCommand } from "./commands/whoami.js";
@@ -139,7 +139,7 @@ export interface ProgramDeps {
pluginDeps?: StatusDeps;
skillDeps?: StatusDeps;
agentImageDeps?: StatusDeps;
agentAppDeps?: StatusDeps;
communityHarnessDeps?: StatusDeps;
forkDeps?: StatusDeps;
workspaceDeps?: WorkspaceDeps;
whoamiDeps?: StatusDeps;
@@ -221,7 +221,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
program.addCommand(skillCommand(depsOverride?.skillDeps));
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
program.addCommand(agentAppCommand(depsOverride?.agentAppDeps));
program.addCommand(communityHarnessCommand(depsOverride?.communityHarnessDeps));
program.addCommand(forkCommand(depsOverride?.forkDeps));
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
program.addCommand(rigModeCommand(depsOverride?.rigModeDeps));
+1 -1
View File
@@ -63,7 +63,7 @@ export const COMMAND_LOADERS: ReadonlyMap<string, Load> = new Map<string, Load>(
["plugin", async () => (await import("./commands/plugin.js")).pluginCommand()],
["skill", async () => (await import("./commands/skill.js")).skillCommand()],
["agent-image", async () => (await import("./commands/agent-image.js")).agentImageCommand()],
["agent-app", async () => (await import("./commands/agent-app.js")).agentAppCommand()],
["community-harness", async () => (await import("./commands/community-harness.js")).communityHarnessCommand()],
["fork", async () => (await import("./commands/fork.js")).forkCommand()],
["workspace", async () => (await import("./commands/workspace.js")).workspaceCommand()],
["mode", async () => (await import("./commands/rig-mode.js")).rigModeCommand()],
+12
View File
@@ -5,6 +5,18 @@ import { COMMAND_LOADERS, createLazyProgram } from "../src/lazy-program.js";
describe("lazy-program", () => {
const full = createProgram();
it("exposes the community-harness command in eager and lazy help without an old-name alias", async () => {
const oldName = ["agent", "app"].join("-");
expect(COMMAND_LOADERS.has("community-harness")).toBe(true);
expect(COMMAND_LOADERS.has(oldName)).toBe(false);
expect(full.helpInformation()).toContain("community-harness");
expect(full.helpInformation()).not.toContain(oldName);
const lazy = await createLazyProgram("community-harness");
expect(lazy?.commands[0]?.commands.map(command => command.name())).toEqual(["list"]);
expect(lazy?.commands[0]?.helpInformation()).toContain("Community harnesses");
expect(full.commands.map(command => command.name()).filter(name => name === "community-harness")).toHaveLength(1);
});
it("names every top-level command of createProgram, in its order", () => {
expect([...COMMAND_LOADERS.keys()]).toEqual(full.commands.map((c) => c.name()));
});
@@ -13,23 +13,23 @@ import type { ProjectionPlan } from "../domain/projection-planner.js";
import type { TmuxAdapter } from "./tmux.js";
import type { SeatLaunchEnvironment } from "../domain/seat-launch-environment.js";
import { isShellForeground } from "../domain/shell-classifier.js";
import { isAgentAppRuntime, loadAgentAppPreset, agentAppsDir } from "../domain/agent-app-presets.js";
import { agentAppOf } from "../domain/built-in-runtimes.js";
import { isCommunityHarnessRuntime, loadCommunityHarnessPreset, communityHarnessesDir } from "../domain/community-harness-presets.js";
import { communityHarnessOf } from "../domain/built-in-runtimes.js";
interface AgentAppAdapterDeps {
interface CommunityHarnessAdapterDeps {
tmux: Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
seatLaunchEnvironment?: Pick<SeatLaunchEnvironment, "command">;
presetsDir?: () => string;
}
/**
* A community agent app, launched from its installed preset (`domain/agent-app-presets.ts`). It types the preset's
* A community harness, launched from its installed preset (`domain/community-harness-presets.ts`). It types the preset's
* command into the seat's pane and is ready while the pane runs something other than a shell. That's readiness, not
* identity: a process alone doesn't prove which session it is. It projects nothing, and refuses resume and fork,
* which a launch-only preset doesn't support.
*/
export class AgentAppRuntimeAdapter implements RuntimeAdapter {
constructor(readonly runtime: string, private readonly deps: AgentAppAdapterDeps) {}
export class CommunityHarnessRuntimeAdapter implements RuntimeAdapter {
constructor(readonly runtime: string, private readonly deps: CommunityHarnessAdapterDeps) {}
async listInstalled(_binding: NodeBinding): Promise<InstalledResource[]> {
return [];
@@ -57,7 +57,7 @@ export class AgentAppRuntimeAdapter implements RuntimeAdapter {
if (opts.resumeToken) {
return { ok: false, error: `Resume is unsupported: community harness "${this.runtime}" is launch-only. No session was started.` };
}
const found = loadAgentAppPreset(this.runtime, this.deps.presetsDir?.() ?? agentAppsDir());
const found = loadCommunityHarnessPreset(this.runtime, this.deps.presetsDir?.() ?? communityHarnessesDir());
if (!found.ok) return { ok: false, error: found.message };
const session = binding.tmuxSession ?? opts.name;
const command = this.deps.seatLaunchEnvironment
@@ -75,50 +75,50 @@ export class AgentAppRuntimeAdapter implements RuntimeAdapter {
}
const command = await this.deps.tmux.getPaneCommand(session);
if (!command || isShellForeground(command)) {
return { ready: false, reason: `Community harness "${this.runtime}" isn't running: the pane is at a shell.`, code: "app_not_running" };
return { ready: false, reason: `Community harness "${this.runtime}" isn't running: the pane is at a shell.`, code: "harness_not_running" };
}
return { ready: true };
}
}
/**
* The runtime adapter map, extended with community agent apps: a runtime name that isn't built in resolves to an
* AgentAppRuntimeAdapter while its preset is installed. The built-in entries are returned as they are, and the map's
* The runtime adapter map, extended with community harnesses: a runtime name that isn't built in resolves to an
* CommunityHarnessRuntimeAdapter while its preset is installed. The built-in entries are returned as they are, and the map's
* own keys stay the built-ins.
*/
export function withAgentApps<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: AgentAppAdapterDeps): T {
export function withCommunityHarnesses<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: CommunityHarnessAdapterDeps): T {
const installed = (key: string | symbol): key is string =>
typeof key === "string" && !Object.hasOwn(builtIns, key) && isAgentAppRuntime(key)
&& loadAgentAppPreset(key, deps.presetsDir?.() ?? agentAppsDir()).ok;
typeof key === "string" && !Object.hasOwn(builtIns, key) && isCommunityHarnessRuntime(key)
&& loadCommunityHarnessPreset(key, deps.presetsDir?.() ?? communityHarnessesDir()).ok;
return new Proxy(builtIns, {
get: (target, key, receiver) => installed(key) ? new AgentAppRuntimeAdapter(key, deps) : Reflect.get(target, key, receiver),
get: (target, key, receiver) => installed(key) ? new CommunityHarnessRuntimeAdapter(key, deps) : Reflect.get(target, key, receiver),
has: (target, key) => Reflect.has(target, key) || installed(key),
});
}
/** The hidden key of a relaunch map's agent-app resolver; a symbol, so the map's own keys stay the built-ins. */
const AGENT_APP_RESOLVER = Symbol("agent-app-resolver");
/** The hidden key of a relaunch map's community-harness resolver; a symbol, so the map's own keys stay the built-ins. */
const COMMUNITY_HARNESS_RESOLVER = Symbol("community-harness-resolver");
/**
* The runtime adapter map for relaunches (restore, seat lifecycle): exactly the built-ins, plus a hidden resolver for a
* community agent app. Only `seatAdapter`, given a seat's recorded selection, reaches that resolver, so a seat whose
* community harness. Only `seatAdapter`, given a seat's recorded selection, reaches that resolver, so a seat whose
* runtime merely matches an installed preset's name (a free-text self-attach) gets no adapter, as before.
*/
export function withAgentAppResolver<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: AgentAppAdapterDeps): T {
Object.defineProperty(builtIns, AGENT_APP_RESOLVER, {
export function withCommunityHarnessResolver<T extends Record<string, RuntimeAdapter>>(builtIns: T, deps: CommunityHarnessAdapterDeps): T {
Object.defineProperty(builtIns, COMMUNITY_HARNESS_RESOLVER, {
enumerable: false,
value: (runtime: string) => isAgentAppRuntime(runtime) && loadAgentAppPreset(runtime, deps.presetsDir?.() ?? agentAppsDir()).ok
? new AgentAppRuntimeAdapter(runtime, deps) : undefined,
value: (runtime: string) => isCommunityHarnessRuntime(runtime) && loadCommunityHarnessPreset(runtime, deps.presetsDir?.() ?? communityHarnessesDir()).ok
? new CommunityHarnessRuntimeAdapter(runtime, deps) : undefined,
});
return builtIns;
}
/** The adapter to relaunch a seat with: its runtime's built-in adapter, or, for a seat launched for a community agent
* app (`agentAppOf`), that app's adapter while its preset is installed. */
* app (`communityHarnessOf`), that app's adapter while its preset is installed. */
export function seatAdapter(map: Record<string, RuntimeAdapter> | undefined, runtime: string | null | undefined,
agentApp: string | null | undefined): RuntimeAdapter | undefined {
communityHarness: string | null | undefined): RuntimeAdapter | undefined {
if (!map || !runtime) return undefined;
if (Object.hasOwn(map, runtime)) return map[runtime];
const resolve = (map as Record<symbol, ((runtime: string) => RuntimeAdapter | undefined) | undefined>)[AGENT_APP_RESOLVER];
return agentAppOf(agentApp, runtime) && resolve ? resolve(runtime) : undefined;
const resolve = (map as Record<symbol, ((runtime: string) => RuntimeAdapter | undefined) | undefined>)[COMMUNITY_HARNESS_RESOLVER];
return communityHarnessOf(communityHarness, runtime) && resolve ? resolve(runtime) : undefined;
}
+2 -2
View File
@@ -115,7 +115,7 @@ import { modePostureCoexistSchema } from "./migrations/109_mode_posture_coexist.
import { workflowInstanceScopeSchema } from "./migrations/110_workflow_instance_scope.js";
import { queueTransitionsTsQitemSchema } from "./migrations/112_queue_transitions_ts_qitem.js";
import { appliedLaunchDaemonOptOutSchema } from "./migrations/113_applied_launch_daemon_opt_out.js";
import { sessionAgentAppSchema } from "./migrations/114_session_agent_app.js";
import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "./migrations/114_session_agent_app.js";
import { threadSeatMapChannelKeySchema } from "./migrations/115_thread_seat_map_channel_key.js";
/** Ordered migrations; numbers may be reserved by independent changes. */
@@ -231,6 +231,6 @@ export const ALL_MIGRATIONS: Migration[] = [
seatEnvSchema,
queueTransitionsTsQitemSchema,
appliedLaunchDaemonOptOutSchema,
sessionAgentAppSchema,
sessionCommunityHarnessSchema,
threadSeatMapChannelKeySchema,
];
@@ -18,7 +18,7 @@
// display from the count+reason fields via `deriveDisplayActivity` — the one bridge —
// so no store or transition ever holds "needs-input" as a state. That reconciliation
// is documented in the reference doc's addendum-mapping section.
import { agentAppOf } from "./built-in-runtimes.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
/** Activity axis — what a present agent is doing. */
export type ActivityValue = "working" | "idle-at-prompt" | "unknown";
@@ -106,7 +106,7 @@ export interface RungDeclaration {
* never inherits its predecessor's rung authority. */
export interface AdapterRungInventory {
adapterId: string;
runtime: "claude-code" | "codex" | "tmux-generic" | "agent-app";
runtime: "claude-code" | "codex" | "tmux-generic" | "community-harness";
rungs: RungDeclaration[];
}
@@ -195,20 +195,20 @@ export const TMUX_GENERIC_RUNG_INVENTORY: AdapterRungInventory = {
rungs: [{ rung: "window-sampling", lifecycleCoverage: "full", initialTrust: "authoritative" }],
};
/** A community agent app declares no activity signal. A changing screen reads working, decided by window
/** A community harness declares no activity signal. A changing screen reads working, decided by window
* sampling; a quiet screen reads unknown, never idle, since an app can sit quietly at a prompt or an approval. */
export const AGENT_APP_RUNG_INVENTORY: AdapterRungInventory = {
adapterId: "agent-app",
runtime: "agent-app",
export const COMMUNITY_HARNESS_RUNG_INVENTORY: AdapterRungInventory = {
adapterId: "community-harness",
runtime: "community-harness",
rungs: [{ rung: "window-sampling", lifecycleCoverage: "partial", initialTrust: "positive-only" }],
};
/** Resolve a runtime string to its rung inventory (the ingest auto-declaration path).
* Unknown runtimes get the generic floor — partial-coverage honesty by default. A seat launched for a community
* agent app (its session's recorded selection, still the node's runtime) gets that app's inventory. */
export function runtimeRungInventory(runtime: string | null, agentApp?: string | null): AdapterRungInventory {
* community harness (its session's recorded selection, still the node's runtime) gets that app's inventory. */
export function runtimeRungInventory(runtime: string | null, communityHarness?: string | null): AdapterRungInventory {
if (runtime === "claude-code") return CLAUDE_ACTIVITY_RUNG_INVENTORY;
if (runtime === "codex") return CODEX_ACTIVITY_RUNG_INVENTORY;
if (agentAppOf(agentApp, runtime)) return AGENT_APP_RUNG_INVENTORY;
if (communityHarnessOf(communityHarness, runtime)) return COMMUNITY_HARNESS_RUNG_INVENTORY;
return TMUX_GENERIC_RUNG_INVENTORY;
}
@@ -1,15 +1,15 @@
/** The runtimes OpenRig ships. Any other runtime name names a community agent app (`agent-app-presets.ts`), and a
/** The runtimes OpenRig ships. Any other runtime name names a community harness (`community-harness-presets.ts`), and a
* preset can't take one of these names. */
export const BUILT_IN_RUNTIMES: ReadonlySet<string> = new Set(["claude-code", "codex", "pi", "omp", "terminal", "stub"]);
/** A runtime name that isn't built in names a community agent app. */
export function isAgentAppRuntime(runtime: string | null | undefined): runtime is string {
/** A runtime name that isn't built in names a community harness. */
export function isCommunityHarnessRuntime(runtime: string | null | undefined): runtime is string {
return !!runtime && !BUILT_IN_RUNTIMES.has(runtime);
}
/** The community agent app a seat runs, from positive provenance only: the session was launched for that app
* (`sessions.agent_app`), and it's still the node's runtime. A free-text runtime name alone, an older session or a
/** The community harness a seat runs, from positive provenance only: the session was launched for that app
* (`sessions.community_harness`), and it's still the node's runtime. A free-text runtime name alone, an older session or a
* changed runtime gives null, so such a seat is treated as it always was. */
export function agentAppOf(agentApp: string | null | undefined, runtime: string | null | undefined): string | null {
return agentApp && agentApp === runtime ? agentApp : null;
export function communityHarnessOf(communityHarness: string | null | undefined, runtime: string | null | undefined): string | null {
return communityHarness && communityHarness === runtime ? communityHarness : null;
}
@@ -1,21 +1,21 @@
// Community agent apps: another agent app runs in a seat from a small data preset the person installs, one YAML file
// per app under $OPENRIG_HOME/agent-apps/<name>.yaml. A preset names the app, the command that starts it in the seat's
// pane, its maintainer and the limits it declares. OpenRig hasn't tested these apps: it shows them as community apps,
// Community harnesses: another community harness runs in a seat from a small data preset the person installs, one YAML file
// per harness under $OPENRIG_HOME/community-harnesses/<name>.yaml. A preset names the harness, the command that starts it in the seat's
// pane, its maintainer and the limits it declares. OpenRig hasn't tested these harnesses: it shows them as community harnesses,
// never confirms their identity, and reads their activity only from what the screen shows. Built-in runtime names are
// reserved, so a preset can't replace one.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { basename, join } from "node:path";
import { parse as parseYaml } from "yaml";
import { getDefaultOpenRigPath } from "../openrig-compat.js";
import { BUILT_IN_RUNTIMES, isAgentAppRuntime } from "./built-in-runtimes.js";
import { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime } from "./built-in-runtimes.js";
export { BUILT_IN_RUNTIMES, isAgentAppRuntime };
export { BUILT_IN_RUNTIMES, isCommunityHarnessRuntime };
export const AGENT_APP_PRESET_FORMAT = 1;
export const COMMUNITY_HARNESS_PRESET_FORMAT = 1;
const FIELDS = ["format", "name", "command", "maintainer", "limits"] as const;
const NAME = /^[a-z0-9][a-z0-9-]{0,62}$/;
export interface AgentAppPreset {
export interface CommunityHarnessPreset {
name: string;
command: string;
maintainer: string;
@@ -23,26 +23,26 @@ export interface AgentAppPreset {
path: string;
}
export type AgentAppPresetLookup =
| { ok: true; preset: AgentAppPreset }
export type CommunityHarnessPresetLookup =
| { ok: true; preset: CommunityHarnessPreset }
| { ok: false; kind: "missing" | "incompatible"; path: string; message: string };
export function agentAppsDir(): string {
return getDefaultOpenRigPath("agent-apps");
export function communityHarnessesDir(): string {
return getDefaultOpenRigPath("community-harnesses");
}
function parsePreset(name: string, path: string, text: string): AgentAppPresetLookup {
const bad = (reason: string): AgentAppPresetLookup => ({ ok: false, kind: "incompatible", path,
function parsePreset(name: string, path: string, text: string): CommunityHarnessPresetLookup {
const bad = (reason: string): CommunityHarnessPresetLookup => ({ ok: false, kind: "incompatible", path,
message: `community harness "${name}": the preset at ${path} can't be used: ${reason}` });
let doc: unknown;
try { doc = parseYaml(text); } catch { return bad("it isn't valid YAML"); }
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return bad("it isn't a YAML mapping");
const raw = doc as Record<string, unknown>;
if (raw.format !== AGENT_APP_PRESET_FORMAT) {
return bad(`it declares format ${JSON.stringify(raw.format ?? null)}; this OpenRig reads format ${AGENT_APP_PRESET_FORMAT}`);
if (raw.format !== COMMUNITY_HARNESS_PRESET_FORMAT) {
return bad(`it declares format ${JSON.stringify(raw.format ?? null)}; this OpenRig reads format ${COMMUNITY_HARNESS_PRESET_FORMAT}`);
}
const unknown = Object.keys(raw).filter(key => !(FIELDS as readonly string[]).includes(key));
if (unknown.length) return bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (format ${AGENT_APP_PRESET_FORMAT} has ${FIELDS.join(", ")})`);
if (unknown.length) return bad(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (format ${COMMUNITY_HARNESS_PRESET_FORMAT} has ${FIELDS.join(", ")})`);
if (raw.name !== name) return bad(`its name is ${JSON.stringify(raw.name ?? null)}, not "${name}" (the file name)`);
for (const key of ["command", "maintainer"] as const) {
if (typeof raw[key] !== "string" || !(raw[key] as string).trim()) return bad(`${key} must be a non-empty string`);
@@ -89,8 +89,8 @@ function shellSyntaxIn(command: string): string | null {
return quote ? "an unclosed quote" : null;
}
/** The installed preset for a community agent app, or why there isn't a usable one. */
export function loadAgentAppPreset(name: string, dir: string = agentAppsDir()): AgentAppPresetLookup {
/** The installed preset for a community harness, or why there isn't a usable one. */
export function loadCommunityHarnessPreset(name: string, dir: string = communityHarnessesDir()): CommunityHarnessPresetLookup {
const path = join(dir, `${name}.yaml`);
if (BUILT_IN_RUNTIMES.has(name)) {
return { ok: false, kind: "incompatible", path, message: `"${name}" is a built-in runtime; a community harness preset can't use that name` };
@@ -114,16 +114,16 @@ export function loadAgentAppPreset(name: string, dir: string = agentAppsDir()):
}
/** Every preset file in the directory, usable or not, sorted by name. */
export function listAgentAppPresets(dir: string = agentAppsDir()): AgentAppPresetLookup[] {
export function listCommunityHarnessPresets(dir: string = communityHarnessesDir()): CommunityHarnessPresetLookup[] {
if (!existsSync(dir)) return [];
let files: string[];
try { files = readdirSync(dir).filter(file => file.endsWith(".yaml")).sort(); } catch { return []; }
return files.map(file => loadAgentAppPreset(file.slice(0, -".yaml".length), dir));
return files.map(file => loadCommunityHarnessPreset(file.slice(0, -".yaml".length), dir));
}
/** What a seat on a community agent app shows about it. */
export interface AgentAppLabel {
app: string;
/** What a seat on a community harness shows about it. */
export interface CommunityHarnessLabel {
harness: string;
community: true;
testedByOpenRig: false;
maintainer: string | null;
@@ -134,10 +134,10 @@ export interface AgentAppLabel {
presetError?: string;
}
export function agentAppLabel(runtime: string, dir: string = agentAppsDir()): AgentAppLabel {
const found = loadAgentAppPreset(runtime, dir);
export function communityHarnessLabel(runtime: string, dir: string = communityHarnessesDir()): CommunityHarnessLabel {
const found = loadCommunityHarnessPreset(runtime, dir);
return {
app: runtime, community: true, testedByOpenRig: false,
harness: runtime, community: true, testedByOpenRig: false,
maintainer: found.ok ? found.preset.maintainer : null,
limits: found.ok ? found.preset.limits : [],
identity: "not confirmed", needsInput: "from the screen only: no harness prompt pattern",
+7 -7
View File
@@ -21,9 +21,9 @@ import { deriveCanonicalSessionName } from "./session-name.js";
import { buildNativeResumeCommand, buildCodexResumeCore } from "./native-resume-probe.js";
import { AppliedLaunchObservationStore } from "./applied-launch-observation-store.js";
import { tmuxAttachCommand } from "../adapters/tmux-server.js";
import { agentAppLabel } from "./agent-app-presets.js";
import { agentAppOf } from "./built-in-runtimes.js";
import { sessionAgentAppSelect } from "./session-registry.js";
import { communityHarnessLabel } from "./community-harness-presets.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
// -- Row types for SQL results --
@@ -55,7 +55,7 @@ interface InventoryRow {
session_name: string | null;
session_created_at: string | null;
session_status: string | null;
session_agent_app?: string | null;
session_community_harness?: string | null;
intentionally_stopped: number | null;
startup_status: string | null;
resume_type: string | null;
@@ -584,7 +584,7 @@ function runInventoryRowQuery(db: Database.Database, whereClause: string, orderC
s.session_name,
s.created_at as session_created_at,
s.status as session_status,
${sessionAgentAppSelect(db)} as session_agent_app,
${sessionCommunityHarnessSelect(db)} as session_community_harness,
s.intentionally_stopped,
s.startup_status,
s.resume_type,
@@ -697,7 +697,7 @@ function buildInventoryEntry(
attachmentType: (row.binding_attachment_type as NodeInventoryEntry["attachmentType"]) ?? null,
nodeKind: deriveNodeKind(row.runtime),
runtime: row.runtime,
...(agentAppOf(row.session_agent_app, row.runtime) ? { agentApp: agentAppLabel(row.runtime!) } : {}),
...(communityHarnessOf(row.session_community_harness, row.runtime) ? { communityHarness: communityHarnessLabel(row.runtime!) } : {}),
sessionStatus,
storedSessionStatus: row.session_status,
intentionallyStopped: row.intentionally_stopped === 1
@@ -1485,7 +1485,7 @@ export async function attachAgentActivity(
now: sampledAt,
captureObserver: deps.captureObserver,
binding: deps.captureObserver ? deps.observationBinding?.(entry) : undefined,
agentApp: entry.agentApp?.app ?? null,
communityHarness: entry.communityHarness?.harness ?? null,
})),
};
}));
@@ -79,8 +79,8 @@ const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
// existing export surface and the execution call sites below are unchanged.
import { resolveActiveSnapshotSession, activeOccupantAmbiguityError, absentSeatResumeHistory, absentSeatResumeHistoryError } from "./active-occupant.js";
import { effectiveRestorePolicy, type RestoreSeatFacts } from "./restore-policy.js";
import { agentAppOf } from "./built-in-runtimes.js";
import { seatAdapter } from "../adapters/agent-app-runtime-adapter.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { seatAdapter } from "../adapters/community-harness-runtime-adapter.js";
import { pluginPathsOf } from "./claude-core-plugin-env.js";
export { resolveActiveSnapshotSession } from "./active-occupant.js";
export type { ActiveSnapshotSessionResolution } from "./active-occupant.js";
@@ -140,7 +140,7 @@ function snapshotLaunchOutcome(data: SnapshotData, nodeId: string, runtime: stri
}
const policy = effectiveRestorePolicy(seatFacts(data, nodeId, savedRuntime, runtime), session.restorePolicy);
if (policy !== "resume_if_possible") return { kind: "fresh", why: `saves this seat with restore policy '${policy}'` };
if (agentAppOf(session.agentApp, runtime)) return { kind: "decision", why: "is a community harness whose preset is launch-only (resume unsupported)" };
if (communityHarnessOf(session.communityHarness, runtime)) return { kind: "decision", why: "is a community harness whose preset is launch-only (resume unsupported)" };
if (!session.resumeToken) return { kind: "decision", why: "has no resume token for this seat" };
if (!session.resumeType || session.resumeType === "none") return { kind: "fresh", why: "records no resume source for this seat" };
return { kind: "resume" };
@@ -547,7 +547,7 @@ export class RestoreOrchestrator {
const runtime = allNodes.find(current => current.id === node.id)?.runtime ?? node.runtime;
// Launch needs consistent positive evidence: both samples idle with the same fingerprint, and the
// bound pane still in this session (a recycled pane id could name another session's shell).
if (pane && (["claude-code", "codex", "pi", "omp"].includes(runtime ?? "") || agentAppOf(session.agentApp, runtime))
if (pane && (["claude-code", "codex", "pi", "omp"].includes(runtime ?? "") || communityHarnessOf(session.communityHarness, runtime))
&& isShellForeground(await this.tmuxAdapter.getPaneCommand(pane) ?? "")
&& (await this.tmuxAdapter.listPanes(session.sessionName)).some(candidate => candidate.id === pane)
&& (await observeClaudeDelivery({ target: pane, tmux: this.tmuxAdapter, listProcesses: this.listProcesses }, { unknownKeepsIdle: false })).state === "idle_shell"
@@ -920,8 +920,8 @@ export class RestoreOrchestrator {
const snapSession = occupantResolution.kind === "resolved" ? occupantResolution.session : null;
const policy = effectiveRestorePolicy(seatFacts(data, nodeId, node.runtime, liveNode ? liveNode.runtime : node.runtime), snapSession?.restorePolicy);
const resumeSourceRecorded = !!snapSession?.resumeType && snapSession.resumeType !== "none";
// An agent app is launch-only: nothing resumes it, so even a saved token (say, from a former runtime) stops here.
if (policy === "resume_if_possible" && snapSession && !freshRequested && agentAppOf(snapSession.agentApp, node.runtime)) {
// A community harness is launch-only: nothing resumes it, so even a saved token (say, from a former runtime) stops here.
if (policy === "resume_if_possible" && snapSession && !freshRequested && communityHarnessOf(snapSession.communityHarness, node.runtime)) {
return {
nodeId,
logicalId: node.logicalId,
@@ -1147,10 +1147,10 @@ export class RestoreOrchestrator {
}
const session = sessionResolution.kind === "resolved" ? sessionResolution.session : null;
const checkpoint = data.checkpoints[node.id] ?? null;
// Relaunching a seat that was launched for a community agent app keeps that selection on the session registered
// Relaunching a seat that was launched for a community harness keeps that selection on the session registered
// for it, whether or not the app can start now: never a fallback to a built-in reading.
const carriedAgentApp = agentAppOf(session?.agentApp, node.runtime);
if (carriedAgentApp && launchResult?.session?.id) this.sessionRegistry.recordAgentAppSelection(launchResult.session.id, carriedAgentApp);
const carriedCommunityHarness = communityHarnessOf(session?.communityHarness, node.runtime);
if (carriedCommunityHarness && launchResult?.session?.id) this.sessionRegistry.recordCommunityHarnessSelection(launchResult.session.id, carriedCommunityHarness);
// Check restore policy. OPR.0.3.4.2: a --fresh-listed seat (operation B)
// deliberately skips the resume attempt; its launch reports `fresh-primed`.
@@ -1291,7 +1291,7 @@ export class RestoreOrchestrator {
// (e.g. a node-subset launch that did not thread adapters), we CANNOT resume
// and MUST NOT silently fresh-prime — fail closed to awaiting-decision.
// Explicit --fresh and non-resume policies stay the only fresh-prime paths.
const resumeAdapter = seatAdapter(opts?.adapters, node.runtime, session?.agentApp);
const resumeAdapter = seatAdapter(opts?.adapters, node.runtime, session?.communityHarness);
if (!resumeAdapter) {
await this.rollbackToZeroSession(node.id, sessionName, launchResult?.session.id, priorState);
return {
@@ -1332,7 +1332,7 @@ export class RestoreOrchestrator {
// nothing else is replayed.
const replayContained = resumeRequested && !!resumeToken;
const startupRuntime = startupCtx?.runtime ?? node.runtime ?? null;
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.agentApp);
const startupAdapter = seatAdapter(opts?.adapters, startupRuntime, session?.communityHarness);
// A new pod-aware agent needs a startup context to run StartupOrchestrator.
// Do not report a healthy fresh-primed result when that context or adapter
@@ -1354,7 +1354,7 @@ export class RestoreOrchestrator {
// Attempt restore-safe startup replay if context available
if (data.nodeStartupContext && opts?.adapters && launchResult) {
if (startupCtx) {
const adapter = seatAdapter(opts.adapters, startupCtx.runtime, session?.agentApp);
const adapter = seatAdapter(opts.adapters, startupCtx.runtime, session?.communityHarness);
if (adapter) {
// Prefilter: check which files/entries still exist
const existsFn = opts.fsOps?.exists ?? (() => true);
@@ -142,7 +142,7 @@ import { resolveAgentRef, type AgentResolverFsOps } from "./agent-resolver.js";
import { resolveNodeConfig, type ResolutionContext } from "./profile-resolver.js";
import { getOpenRigInstallCwdError, resolveAuthoredCwd, resolveLaunchCwd } from "./cwd-resolution.js";
import nodePath from "node:path";
import { loadAgentAppPreset } from "./agent-app-presets.js";
import { loadCommunityHarnessPreset } from "./community-harness-presets.js";
import { BUILT_IN_RUNTIMES } from "./built-in-runtimes.js";
import { validateClaudeActivityHookDelivery, CLAUDE_ACTIVITY_HOOKS_RESOURCE_TYPE } from "./claude-activity-hooks.js";
import {
@@ -395,9 +395,9 @@ export async function preflightValidatedSpec(rigSpec: PodRigSpec, preflightCtx:
}
}
// Check runtime: a built-in, or a community agent app whose preset is installed and usable.
// Check runtime: a built-in, or a community harness whose preset is installed and usable.
if (!SUPPORTED_RUNTIMES.has(member.runtime)) {
const app = loadAgentAppPreset(member.runtime);
const app = loadCommunityHarnessPreset(member.runtime);
if (!app.ok) {
errors.push(`${pod.id}.${member.id}: unsupported runtime "${member.runtime}": ${app.kind === "missing" ? "it isn't built in, and " : ""}${app.message}`);
}
@@ -11,8 +11,8 @@ import type {
RungHealthEvent,
RungTrust,
} from "./activity-taxonomy.js";
import { AGENT_APP_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
import { sessionAgentAppSelect } from "./session-registry.js";
import { COMMUNITY_HARNESS_RUNG_INVENTORY, EVIDENCE_RUNG_RANK, runtimeRungInventory } from "./activity-taxonomy.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
/** Default polling cadence: 1Hz. The default silence window is 3s, so
* 1Hz polling gives at-most ~1s freshness lag on the cached observation. */
@@ -236,14 +236,14 @@ export class SeatActivityService {
try {
const rows = db.prepare(`
SELECT s.session_name as session_name, n.id as node_id, n.runtime as runtime,
COALESCE(b.attachment_type, 'tmux') as attachment_type, ${sessionAgentAppSelect(db)} as agent_app
COALESCE(b.attachment_type, 'tmux') as attachment_type, ${sessionCommunityHarnessSelect(db)} as community_harness
FROM nodes n
JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
LEFT JOIN bindings b ON b.node_id = n.id
WHERE s.status = 'running'
AND s.session_name IS NOT NULL
`).all() as Array<{ session_name: string; node_id: string; runtime: string | null; attachment_type: string; agent_app: string | null }>;
`).all() as Array<{ session_name: string; node_id: string; runtime: string | null; attachment_type: string; community_harness: string | null }>;
const tmuxRows = rows.filter((r) => r.attachment_type === "tmux");
const liveTmux = new Set(tmuxRows.map((r) => r.session_name));
for (const pane of this.latestByPaneId.keys()) {
@@ -265,10 +265,10 @@ export class SeatActivityService {
// touching the launch machinery.
for (const r of tmuxRows) {
const known = this.ladder.get(r.node_id);
const inventory = runtimeRungInventory(r.runtime, r.agent_app);
const inventory = runtimeRungInventory(r.runtime, r.community_harness);
// A replacement occupant can keep the seat's session name; its own recorded app selection (or none) decides.
const agentAppChanged = !!known && (known.inventory === AGENT_APP_RUNG_INVENTORY) !== (inventory === AGENT_APP_RUNG_INVENTORY);
if (!known || known.inventory === null || known.sessionName !== r.session_name || agentAppChanged) {
const communityHarnessChanged = !!known && (known.inventory === COMMUNITY_HARNESS_RUNG_INVENTORY) !== (inventory === COMMUNITY_HARNESS_RUNG_INVENTORY);
if (!known || known.inventory === null || known.sessionName !== r.session_name || communityHarnessChanged) {
this.declareRungInventory({ seatNodeId: r.node_id, sessionName: r.session_name }, inventory);
}
}
@@ -11,8 +11,8 @@ import { classifyPaneRuntimeMatch } from "./seat-identity-reconciler.js";
import { SeatIdentityStore } from "./seat-identity-store.js";
import { explainNativeRejection, subtreeNamesClaudeExecutable, observeClaudeDelivery, observeClaudePaneProcess, observeClaudePaneRuntime, observeCodexPaneProcess, verifyClaudePaneProcess, verifyClaudePaneRuntime, verifyCodexPaneProcess, verifyPaneIdleShell, type NativeProcessRow, type NativeProcessLister, type ClaudeResumeRotation, findExactNativeResumeProcess, listNativeProcesses } from "./native-process-lineage.js";
import { isShellForeground } from "./shell-classifier.js";
import { agentAppOf } from "./built-in-runtimes.js";
import { sessionAgentAppSelect } from "./session-registry.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
type PaneIdentityTmux = Pick<TmuxAdapter, "listPanes" | "getPanePid" | "getPaneCommand">;
type ProcessRow = NativeProcessRow;
@@ -234,11 +234,11 @@ export async function rebindAndVerifyPaneIdentity(input: {
const runtimeAmbiguous = input.runtime === "codex" || claudeProcessProof ? lineageMatch === null : runtimeMatch === "match" && (strictNativeLineage
? lineageMatch === null
: input.runtime === "claude-code" && !normalizedCommand.includes("claude"));
// A community agent app's live pane is never proof of which session it runs. Only a session launched for that app
// A community harness's live pane is never proof of which session it runs. Only a session launched for that app
// counts: the selection recorded on the session being rebound.
const selected = (input.db.prepare(`SELECT ${sessionAgentAppSelect(input.db, "sessions")} AS agent_app FROM sessions WHERE node_id = ? AND session_name = ? ORDER BY id DESC LIMIT 1`)
.get(input.nodeId, input.sessionName) as { agent_app: string | null } | undefined)?.agent_app;
const unconfirmed = pid !== null && runtimeMatch !== "mismatch" && !runtimeAmbiguous && !!agentAppOf(selected, input.runtime);
const selected = (input.db.prepare(`SELECT ${sessionCommunityHarnessSelect(input.db, "sessions")} AS community_harness FROM sessions WHERE node_id = ? AND session_name = ? ORDER BY id DESC LIMIT 1`)
.get(input.nodeId, input.sessionName) as { community_harness: string | null } | undefined)?.community_harness;
const unconfirmed = pid !== null && runtimeMatch !== "mismatch" && !runtimeAmbiguous && !!communityHarnessOf(selected, input.runtime);
const verdict: SeatIdentityVerdict = {
nodeId: input.nodeId,
verdict: pid === null
@@ -7,8 +7,8 @@ import type { TmuxAdapter } from "../adapters/tmux.js";
import type { SeatIdentityVerdict } from "./types.js";
import { SeatIdentityStore, SelfHostIdentityStore } from "./seat-identity-store.js";
import { RESERVED_HOST_IDS, validateHostRegistry } from "./hosts/hosts-registry-reader.js";
import { agentAppOf } from "./built-in-runtimes.js";
import { sessionAgentAppSelect } from "./session-registry.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
/** Default identity-reconcile cadence: 5s. Process identity changes rarely
* (a seat's pane/process is stable for its whole life), and the check is
@@ -81,7 +81,7 @@ interface RunningSeatRow {
resume_rotated_from?: string | null;
resume_rotated_process?: string | null;
resume_launch_process?: string | null;
agent_app?: string | null;
community_harness?: string | null;
}
type PaneObservation = { pid: number | null; command: string | null };
@@ -130,7 +130,7 @@ export class SeatIdentityReconciler {
s.resume_rotated_from as resume_rotated_from,
s.resume_rotated_process as resume_rotated_process,
s.resume_launch_process as resume_launch_process,
${sessionAgentAppSelect(this.db)} as agent_app
${sessionCommunityHarnessSelect(this.db)} as community_harness
FROM nodes n
JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
@@ -359,8 +359,8 @@ export class SeatIdentityReconciler {
};
}
// A community agent app's process doesn't prove which session it is: the pane is live, its identity unconfirmed.
if (agentAppOf(seat.agent_app, seat.runtime)) {
// A community harness's process doesn't prove which session it is: the pane is live, its identity unconfirmed.
if (communityHarnessOf(seat.community_harness, seat.runtime)) {
return {
...base,
verdict: "unconfirmed",
@@ -42,8 +42,8 @@ import {
CodexInPlaceRestart, type InPlaceCodexLauncher, type InPlaceRestartIo, type InPlaceRestartRefusal,
type InPlaceRestartReport, type InPlaceRestartTarget, type InPlaceVerifyResult,
} from "./seat-restart-in-place.js";
import { AgentAppRuntimeAdapter, seatAdapter } from "../adapters/agent-app-runtime-adapter.js";
import { sessionAgentAppSelect } from "./session-registry.js";
import { CommunityHarnessRuntimeAdapter, seatAdapter } from "../adapters/community-harness-runtime-adapter.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
/**
* S5 (OPR.0.5.4.7) — the seat-lifecycle verb surface: set-model, single-seat stop,
@@ -241,7 +241,7 @@ interface LatestSessionRow {
session_name: string;
status: string;
origin: string;
agent_app?: string | null;
community_harness?: string | null;
}
interface PersistedStartupContextRow {
@@ -371,16 +371,16 @@ export class SeatLifecycleService {
if (!node.runtime || startup.context.runtime !== node.runtime) {
return { ok: false, code: "startup_context_runtime_mismatch", message: "The saved startup context belongs to a different runtime." };
}
// Resolved as launch resolves it, so a recorded community agent app finds its adapter too.
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.agent_app);
// Resolved as launch resolves it, so a recorded community harness finds its adapter too.
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.community_harness);
if (!adapter) return { ok: false, code: "runtime_adapter_missing", message: "The configured runtime adapter is unavailable." };
const warnings: string[] = [];
// The filesystem startup files the next fresh launch would deliver (never send_text or actions), with legacy auto
// hints resolved, for the read-only destination check.
const files: ResolvedStartupFile[] = [];
// A community agent app takes no startup files at launch (its adapter delivers none), so there's nothing to check.
const takesStartupFiles = !(adapter instanceof AgentAppRuntimeAdapter);
// A community harness takes no startup files at launch (its adapter delivers none), so there's nothing to check.
const takesStartupFiles = !(adapter instanceof CommunityHarnessRuntimeAdapter);
for (const file of takesStartupFiles ? startup.context.resolvedStartupFiles : []) {
if (!file.appliesOn.includes("fresh_start") || file.deliveryHint === "send_text") continue;
try {
@@ -873,7 +873,7 @@ export class SeatLifecycleService {
}
const pane = await observeSolePane(this.tmuxAdapter, binding.tmuxSession);
if (!pane.ok || pane.pane !== binding.tmuxPane) return { ok: false as const, code: "binding_changed", message: "The managed terminal binding changed; context was not delivered." };
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, session.agent_app);
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, session.community_harness);
if (!adapter) return { ok: false as const, code: "runtime_adapter_missing", message: "The configured runtime adapter is unavailable." };
const ready = await adapter.checkReady({ ...binding, cwd: node.cwd ?? "." });
if (!ready.ready) return { ok: false as const, code: "attention_required", message: ready.reason ?? "Resolve the native prerequisite first." };
@@ -921,7 +921,7 @@ export class SeatLifecycleService {
message: `Persisted startup context runtime '${startup.context.runtime}' does not match current node runtime '${node.runtime ?? "missing"}'.`,
};
}
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.agent_app);
const adapter = seatAdapter(this.runtimeAdapters, node.runtime, this.latestSession(node.id)?.community_harness);
if (!adapter) {
return {
ok: false as const,
@@ -1807,7 +1807,7 @@ export class SeatLifecycleService {
private latestSession(nodeId: string): LatestSessionRow | null {
const row = this.db.prepare(
`SELECT id, session_name, status, origin, ${sessionAgentAppSelect(this.db, "sessions")} AS agent_app FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1`,
`SELECT id, session_name, status, origin, ${sessionCommunityHarnessSelect(this.db, "sessions")} AS community_harness FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1`,
).get(nodeId) as LatestSessionRow | undefined;
return row ?? null;
}
@@ -1,10 +1,10 @@
import type Database from "better-sqlite3";
import type { PaneCapture, TmuxAdapter } from "../adapters/tmux.js";
import { agentAppPaneActivity, classifyPaneActivity, type PaneActivityClassification } from "./session-transport.js";
import { agentAppOf } from "./built-in-runtimes.js";
import { communityHarnessPaneActivity, classifyPaneActivity, type PaneActivityClassification } from "./session-transport.js";
import { communityHarnessOf } from "./built-in-runtimes.js";
import { detectCodexLimitBanner, type CodexLimitBanner } from "./provider/codex-limit-banner.js";
import { IDLE_FULL_CAPTURE_MS, windowSnapshot } from "./transcript-rotation.js";
import { sessionAgentAppSelect } from "./session-registry.js";
import { sessionCommunityHarnessSelect } from "./session-registry.js";
/** A cached STRUCTURAL pane observation: the classifyPaneActivity verdict plus WHEN the pane was read
* as motion. observedAt is a LIVENESS timestamp (last time we saw the pane), NOT a hook-arrival age —
@@ -91,7 +91,7 @@ export class SeatStructuralActivityService {
* null or failed capture INVALIDATES the prior row (never leaves a stale positive verdict) and
* returns null (MUST-FIX 1). `prefetched` (#308) is the sweep's batched capture: a session it holds (null = gone or
* empty) is used as is; one it lacks is captured here, per seat. */
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, agentApp?: string | null): Promise<StructuralObservation | null> {
async pollSeat(sessionName: string, prefetched?: Map<string, PaneCapture> | null, runtime?: string | null, communityHarness?: string | null): Promise<StructuralObservation | null> {
let content: string | null;
let observedAt: Date | null = null;
if (prefetched?.has(sessionName)) {
@@ -110,7 +110,7 @@ export class SeatStructuralActivityService {
this.latestBySession.delete(sessionName);
return null;
}
const c = agentAppPaneActivity(classifyPaneActivity(content), agentApp);
const c = communityHarnessPaneActivity(classifyPaneActivity(content), communityHarness);
const at = observedAt ?? this.now();
const obs: StructuralObservation = {
state: c.state,
@@ -141,7 +141,7 @@ export class SeatStructuralActivityService {
this.sweeping = true;
try {
const rows = db.prepare(`
SELECT s.session_name as session_name, n.runtime as runtime, ${sessionAgentAppSelect(db)} as agent_app
SELECT s.session_name as session_name, n.runtime as runtime, ${sessionCommunityHarnessSelect(db)} as community_harness
FROM nodes n
JOIN sessions s ON s.node_id = n.id
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = n.id ORDER BY s2.id DESC LIMIT 1)
@@ -149,10 +149,10 @@ export class SeatStructuralActivityService {
WHERE s.status = 'running'
AND s.session_name IS NOT NULL
AND COALESCE(b.attachment_type, 'tmux') = 'tmux'
`).all() as Array<{ session_name: string; runtime: string | null; agent_app: string | null }>;
`).all() as Array<{ session_name: string; runtime: string | null; community_harness: string | null }>;
const live = new Set(rows.map((r) => r.session_name));
const runtimeBySession = new Map(rows.map((r) => [r.session_name, r.runtime] as const));
const agentAppBySession = new Map(rows.map((r) => [r.session_name, agentAppOf(r.agent_app, r.runtime)] as const));
const communityHarnessBySession = new Map(rows.map((r) => [r.session_name, communityHarnessOf(r.community_harness, r.runtime)] as const));
for (const s of Array.from(this.latestBySession.keys())) {
if (!live.has(s)) this.latestBySession.delete(s); // release memory + never serve a stale read
}
@@ -185,7 +185,7 @@ export class SeatStructuralActivityService {
: null;
await Promise.all(due.map(async (r) => {
try {
const obs = await this.pollSeat(r.session_name, prefetched, runtimeBySession.get(r.session_name) ?? null, agentAppBySession.get(r.session_name));
const obs = await this.pollSeat(r.session_name, prefetched, runtimeBySession.get(r.session_name) ?? null, communityHarnessBySession.get(r.session_name));
const seen = activity?.get(r.session_name);
if (obs && seen !== undefined) this.capturedActivity.set(r.session_name, { activity: seen.activity, size: seen.size, startSec, fullAt: sweepAt.getTime() });
else this.capturedActivity.delete(r.session_name);
@@ -370,8 +370,8 @@ export class SessionRegistry {
// empty-token skip or a lower-rank no-op. Callers that only set-and-forget can
// ignore it; the adoption-capture path uses it so its audit event never
// falsely claims a captured write when the provenance guard refused it.
/** Record that this session was launched for a community agent app, selected through its preset. */
recordAgentAppSelection(sessionId: string, app: string): void {
/** Record that this session was launched for a community harness, selected through its preset. */
recordCommunityHarnessSelection(sessionId: string, app: string): void {
this.db.prepare("UPDATE sessions SET agent_app = ? WHERE id = ?").run(app, sessionId);
}
@@ -712,8 +712,8 @@ export class SessionRegistry {
origin: (row.origin === "claimed" ? "claimed" : "launched"),
startupStatus: (row.startup_status as Session["startupStatus"]) ?? "pending",
startupCompletedAt: row.startup_completed_at ?? null,
// Present only on a session launched for a community agent app, so other sessions read as before.
...(row.agent_app ? { agentApp: row.agent_app } : {}),
// Present only on a session launched for a community harness, so other sessions read as before.
...(row.agent_app ? { communityHarness: row.agent_app } : {}),
};
}
@@ -768,16 +768,16 @@ interface BindingRow {
updated_at: string;
}
const sessionsHaveAgentApp = new WeakMap<Database.Database, boolean>();
const sessionsHaveCommunityHarness = new WeakMap<Database.Database, boolean>();
/** The SQL that reads a session's recorded agent app: `<alias>.agent_app`, or NULL on a schema without that column
/** The SQL that reads a session's recorded community harness: the retained storage column, or NULL on a schema without that column
* (as other optional columns are read), so a partial schema reads every seat as before. The schema check is made
* once per database: the column only appears through a migration, which runs before these reads. */
export function sessionAgentAppSelect(db: Database.Database, alias = "s"): string {
let has = sessionsHaveAgentApp.get(db);
export function sessionCommunityHarnessSelect(db: Database.Database, alias = "s"): string {
let has = sessionsHaveCommunityHarness.get(db);
if (has === undefined) {
has = (db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name?: string }>).some(row => row.name === "agent_app");
sessionsHaveAgentApp.set(db, has);
sessionsHaveCommunityHarness.set(db, has);
}
return has ? `${alias}.agent_app` : "NULL";
}
+10 -10
View File
@@ -476,12 +476,12 @@ function draftComposerLine(lines: string[], verdict: PaneActivityClassification)
return lines[last]!.trim();
}
/** A community agent app's screen never proves it's idle: a quiet prompt can be an approval or a pause, so its idle
* reading becomes unknown. Positive work and attention are kept. `agentApp` is the seat's recorded selection
* (`agentAppOf`); without it the classification is returned as it is. */
export function agentAppPaneActivity(classification: PaneActivityClassification, agentApp: string | null | undefined): PaneActivityClassification {
return agentApp && classification.state === "agent_idle"
? { state: "unknown", reason: "agent_app_no_idle_signal", evidence: classification.evidence }
/** A community harness's screen never proves it's idle: a quiet prompt can be an approval or a pause, so its idle
* reading becomes unknown. Positive work and attention are kept. `communityHarness` is the seat's recorded selection
* (`communityHarnessOf`); without it the classification is returned as it is. */
export function communityHarnessPaneActivity(classification: PaneActivityClassification, communityHarness: string | null | undefined): PaneActivityClassification {
return communityHarness && classification.state === "agent_idle"
? { state: "unknown", reason: "community_harness_no_idle_signal", evidence: classification.evidence }
: classification;
}
@@ -643,8 +643,8 @@ export async function probeSessionActivity(input: {
timerlessStatusIsLive?: boolean;
/** Supplied only by an active draft-aware send; ordinary observations stay unchanged. */
composerSnapshot?: () => Promise<ComposerSnapshot | null>;
/** The seat's community agent app (`agentAppOf`): its idle-looking screen reads unknown. */
agentApp?: string | null;
/** The seat's community harness (`communityHarnessOf`): its idle-looking screen reads unknown. */
communityHarness?: string | null;
}): Promise<AgentActivity> {
// Capture routing and observation labels must share the entry context. The
// caller may reuse/mutate its input while hasSession is pending.
@@ -759,8 +759,8 @@ export async function probeSessionActivity(input: {
const paneContent = snapshot?.screen ?? await tmuxAdapter.capturePaneContent(sessionName, 20);
const capturedAt = new Date().toISOString();
const classifyOptions = { timerlessStatusIsLive: input.timerlessStatusIsLive === true };
let classification = agentAppPaneActivity(classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
composerSnapshot: snapshot ?? undefined, runtime }), input.agentApp);
let classification = communityHarnessPaneActivity(classifyPaneActivity(paneContent ?? "", { ...classifyOptions,
composerSnapshot: snapshot ?? undefined, runtime }), input.communityHarness);
// Claude renders its prompt suggestion faint, which the plain capture loses. When a Claude pane reads
// as a draft, one capture with styling is classified on its own. Newer attention or work there is
// kept. A faint suggestion counts only when it's the same composer text this capture read as a draft;
@@ -24,7 +24,7 @@ import { resolveReadinessTimeoutMs } from "./readiness-timeout.js";
import { SettingsStore } from "./user-settings/settings-store.js";
import { shellQuote } from "../adapters/shell-quote.js";
import { observeClaudeResumeLaunch, type ClaudeLaunchedProcess, type NativeProcessLister } from "./native-process-lineage.js";
import { AgentAppRuntimeAdapter } from "../adapters/agent-app-runtime-adapter.js";
import { CommunityHarnessRuntimeAdapter } from "../adapters/community-harness-runtime-adapter.js";
// Expanded startup text can put the current input marker above 50 scrollback lines.
const STARTUP_SUBMIT_CAPTURE_LINES = 200;
@@ -414,10 +414,10 @@ export class StartupOrchestrator {
}
// 5. Launch harness (unless skipped for legacy nodes)
// A community agent app's seat: record the selection on this session before the app starts. It's provenance for
// A community harness's seat: record the selection on this session before the app starts. It's provenance for
// later reads (identity, activity, labels, restore), not launch success or identity proof.
if (input.adapter instanceof AgentAppRuntimeAdapter) {
try { this.sessionRegistry.recordAgentAppSelection(input.sessionId, input.adapter.runtime); }
if (input.adapter instanceof CommunityHarnessRuntimeAdapter) {
try { this.sessionRegistry.recordCommunityHarnessSelection(input.sessionId, input.adapter.runtime); }
catch (err) { errors.push(`Couldn't record the community harness selection: ${(err as Error).message}`); return this.fail(input, "failed", errors); }
}
+5 -5
View File
@@ -105,8 +105,8 @@ export interface Session {
origin: "launched" | "claimed";
startupStatus: "pending" | "ready" | "attention_required" | "failed";
startupCompletedAt: string | null;
/** The community agent app this session was launched for (selected through its preset); absent otherwise. */
agentApp?: string;
/** The community harness this session was launched for (selected through its preset); absent otherwise. */
communityHarness?: string;
}
// -- Event types --
@@ -592,7 +592,7 @@ export type NodeLifecycleState = "running" | "detached" | "recoverable" | "atten
* projection; `verified`, `binding_absent`, and `tmux_unavailable` (and an
* absent verdict) leave it unchanged.
*/
/** `unconfirmed`: the seat's pane is live, but nothing proves which session it runs (a community agent app). */
/** `unconfirmed`: the seat's pane is live, but nothing proves which session it runs (a community harness). */
export type SeatIdentityVerdictKind = "verified" | "unconfirmed" | "mismatch" | "pane_missing" | "binding_absent" | "tmux_unavailable";
export interface SeatIdentityVerdict {
@@ -665,8 +665,8 @@ export interface NodeInventoryEntry {
attachmentType?: "tmux" | "external_cli" | null;
nodeKind: "agent" | "infrastructure";
runtime: string | null;
/** Present only on a community agent app's seat: its label, maintainer and declared limits. */
agentApp?: import("./agent-app-presets.js").AgentAppLabel;
/** Present only on a community harness's seat: its label, maintainer and declared limits. */
communityHarness?: import("./community-harness-presets.js").CommunityHarnessLabel;
/** Effective current status; confirmed session absence projects as detached. */
sessionStatus: string | null;
/** Persisted session status, retained for history and registry-based rollups. */
@@ -1,15 +1,15 @@
// Community agent apps: the installed presets, usable or not, so a person or an agent can see which apps can run in a
// Community harnesses: the installed presets, usable or not, so a person or an agent can see which apps can run in a
// seat, who maintains each, and what each declares it can't do.
// GET /api/agent-apps — every preset under $OPENRIG_HOME/agent-apps
// GET /api/community-harnesses — every preset under $OPENRIG_HOME/community-harnesses
import { basename } from "node:path";
import { Hono } from "hono";
import { agentAppsDir, listAgentAppPresets } from "../domain/agent-app-presets.js";
import { communityHarnessesDir, listCommunityHarnessPresets } from "../domain/community-harness-presets.js";
export function agentAppsRoutes(): Hono {
export function communityHarnessesRoutes(): Hono {
const app = new Hono();
app.get("/", (c) => c.json({
dir: agentAppsDir(),
apps: listAgentAppPresets().map(found => found.ok
dir: communityHarnessesDir(),
harnesses: listCommunityHarnessPresets().map(found => found.ok
? { name: found.preset.name, usable: true, community: true, testedByOpenRig: false, maintainer: found.preset.maintainer,
limits: found.preset.limits, command: found.preset.command, path: found.preset.path }
: { name: basename(found.path, ".yaml"), usable: false, path: found.path, problem: found.message }),
+2 -2
View File
@@ -84,7 +84,7 @@ import { jsonBodyErrorHandler, trackJsonBodyParseErrors } from "./middleware/jso
import { getSelfHostId, getSelfHostIdSource } from "./domain/hosts/fanout-contract.js";
import { contextPacksRoutes } from "./routes/context-packs.js";
import { agentImagesRoutes } from "./routes/agent-images.js";
import { agentAppsRoutes } from "./routes/agent-apps.js";
import { communityHarnessesRoutes } from "./routes/community-harnesses.js";
import type { SpecReviewService } from "./domain/spec-review-service.js";
import type { SpecLibraryService } from "./domain/spec-library-service.js";
import type { ChatRepository } from "./domain/chat-repository.js";
@@ -777,7 +777,7 @@ export function createApp(deps: AppDeps): Hono {
app.route("/api/agent-images", agentImagesRoutes({
specRoots: deps.agentImageSpecRoots ?? (() => []),
}));
app.route("/api/agent-apps", agentAppsRoutes());
app.route("/api/community-harnesses", communityHarnessesRoutes());
app.route("/api/whoami", whoamiRoutes());
// Slice-04 OPR.0.5.0.4: registered so the endpoints exist and 503 honestly; the providerService
// is not set in context until the collection/service seam (C) wires it (until then -> 503).
+3 -3
View File
@@ -178,7 +178,7 @@ import type { NotificationAdapter } from "./domain/mission-control/notification-
import { OPENRIG_HOME } from "./openrig-compat.js";
import { homeIsolationAdvisory } from "./domain/home-isolation.js";
import { materializeBuiltinPolicyReference } from "./domain/builtin-policy-reference.js";
import { withAgentAppResolver, withAgentApps } from "./adapters/agent-app-runtime-adapter.js";
import { withCommunityHarnessResolver, withCommunityHarnesses } from "./adapters/community-harness-runtime-adapter.js";
import { ensureActivityHookToken, writeActivityEndpointFile, deriveActivityUrl, readActivityEndpointFile } from "./domain/activity-endpoint.js";
import {
getCompatibleOpenRigPath,
@@ -1025,7 +1025,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
db, rigRepo, podRepo,
sessionRegistry, eventBus, nodeLauncher, startupOrchestrator,
fsOps: { readFile: (p: string) => fs.readFileSync(p, "utf-8"), exists: (p: string) => fs.existsSync(p) },
adapters: withAgentApps({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment }),
adapters: withCommunityHarnesses({ "claude-code": claudeAdapter, "codex": codexAdapter, "pi": piAdapter, "omp": ompAdapter, "stub": stubAdapter, "terminal": new (await import("./adapters/terminal-adapter.js")).TerminalAdapter() }, { tmux: tmuxAdapter, seatLaunchEnvironment }),
tmuxAdapter,
agentImageLibrary,
continuityPolicyMaterializer,
@@ -1263,7 +1263,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// Capture compatibility before the spec-library initializer creates the primary directory.
const userSpecLibraryRoot = getDefaultOpenRigPath("specs");
const compatibleSpecLibraryRoot = getCompatibleOpenRigPath("specs");
const runtimeAdaptersMap = withAgentAppResolver({
const runtimeAdaptersMap = withCommunityHarnessResolver({
"claude-code": claudeAdapter,
"codex": codexAdapter,
"pi": piAdapter,
@@ -6,17 +6,17 @@ import { fileURLToPath } from "node:url";
import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js";
import { runRig, spawnScenarioDaemon, type ScenarioDaemon } from "./helpers/scenario-daemon.js";
// One community agent app end to end, through the real CLI, daemon, SQLite and a private tmux server: a synthetic
// One community harness end to end, through the real CLI, daemon, SQLite and a private tmux server: a synthetic
// Node app installed as a preset, then discovery, start, delivery through `rig send`, activity, resume, a missing
// preset and a built-in name collision. Nothing here runs a real agent app or touches a real home.
// preset and a built-in name collision. Nothing here runs a real community harness or touches a real home.
const here = dirname(fileURLToPath(import.meta.url));
const rigBin = resolve(here, "../../cli/dist/bin-wrapper.js");
const appScript = resolve(here, "fixtures/agent-app/synth-app.mjs");
const appScript = resolve(here, "fixtures/community-harness/synth-harness.mjs");
const agents = resolve(here, "../../test-system/scenarios/agents");
const session = "work-worker@agent-app-rig";
const session = "work-worker@community-harness-rig";
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
describe("a community agent app from a data preset", () => {
describe("a community harness from a data preset", () => {
let scaffold: HermeticScaffold;
let daemon: ScenarioDaemon;
let rigRoot: string;
@@ -29,7 +29,7 @@ describe("a community agent app from a data preset", () => {
return JSON.parse(result.stdout);
}
async function node() {
const nodes = await json(["ps", "--nodes", "--rig", "agent-app-rig", "--full", "--json"]);
const nodes = await json(["ps", "--nodes", "--rig", "community-harness-rig", "--full", "--json"]);
return nodes[0];
}
const capture = () => execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "capture-pane", "-p", "-t", `=${session}:`], { env: scaffold.env as NodeJS.ProcessEnv }).toString();
@@ -39,7 +39,7 @@ describe("a community agent app from a data preset", () => {
while (!ok(value) && Date.now() < end) { await sleep(500); value = await read(); }
return value;
}
const presetDir = () => join(scaffold.env.OPENRIG_HOME!, "agent-apps");
const presetDir = () => join(scaffold.env.OPENRIG_HOME!, "community-harnesses");
const writePreset = (file: string, body: string) => { mkdirSync(presetDir(), { recursive: true }); writeFileSync(join(presetDir(), file), body); };
const specFor = (name: string, runtime: string) => {
const path = join(rigRoot, `${name}.yaml`);
@@ -52,8 +52,8 @@ describe("a community agent app from a data preset", () => {
rigRoot = join(scaffold.root, "rig");
mkdirSync(rigRoot);
cpSync(agents, join(rigRoot, "agents"), { recursive: true });
writePreset("synth-app.yaml", [
"format: 1", "name: synth-app", `command: node ${appScript}`, "maintainer: Example Maintainer",
writePreset("synth-harness.yaml", [
"format: 1", "name: synth-harness", `command: node ${appScript}`, "maintainer: Example Maintainer",
"limits:", " - resume unsupported", " - fork unsupported", " - no approval-prompt detection", "",
].join("\n"));
writePreset("codex.yaml", ["format: 1", "name: codex", "command: node -e 0", "maintainer: someone", ""].join("\n"));
@@ -61,29 +61,43 @@ describe("a community agent app from a data preset", () => {
}, 120_000);
afterAll(async () => {
try { if (daemon) for (const r of ["agent-app-rig", "free-rig"]) await rig(["down", r, "--json", "--force"]); }
try { if (daemon) for (const r of ["community-harness-rig", "free-rig"]) await rig(["down", r, "--json", "--force"]); }
finally { await daemon?.stop(); }
}, 60_000);
it("serves the renamed route and JSON keys without exposing the removed public route", async () => {
const response = await fetch(`${daemon.baseUrl}/api/community-harnesses`);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.dir).toBe(presetDir());
expect(data.harnesses.map((preset: { name: string }) => preset.name)).toContain("synth-harness");
expect(data).not.toHaveProperty("apps");
const oldRoute = ["agent", "apps"].join("-");
expect((await fetch(`${daemon.baseUrl}/api/${oldRoute}`)).status).toBe(404);
const help = await rig(["community-harness", "list", "--help"]);
expect(help.code).toBe(0);
expect(help.stdout).toContain("community harness presets");
});
it("is discoverable with its maintainer and limits, and a preset can't take a built-in name", async () => {
const listed = await json(["agent-app", "list", "--json"]);
const byName = Object.fromEntries(listed.apps.map((app: { name: string }) => [app.name, app]));
expect(byName["synth-app"]).toMatchObject({ usable: true, community: true, testedByOpenRig: false, maintainer: "Example Maintainer",
const listed = await json(["community-harness", "list", "--json"]);
const byName = Object.fromEntries(listed.harnesses.map((app: { name: string }) => [app.name, app]));
expect(byName["synth-harness"]).toMatchObject({ usable: true, community: true, testedByOpenRig: false, maintainer: "Example Maintainer",
limits: ["resume unsupported", "fork unsupported", "no approval-prompt detection"] });
expect(byName["codex"]).toMatchObject({ usable: false, problem: '"codex" is a built-in runtime; a community harness preset can\'t use that name' });
});
it("starts from the preset, labelled as a community app with identity not confirmed", async () => {
const up = await rig(["up", specFor("agent-app-rig", "synth-app"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
const up = await rig(["up", specFor("community-harness-rig", "synth-harness"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
expect(up.code, JSON.stringify(up)).toBe(0);
rigId = JSON.parse(up.stdout).rigId;
expect(await until(async () => capture(), text => text.includes("synth-app 1.0 started"))).toContain("app> ");
expect(await until(async () => capture(), text => text.includes("synth-harness 1.0 started"))).toContain("app> ");
const seat = await node();
expect(seat).toMatchObject({ runtime: "synth-app", sessionStatus: "running",
agentApp: { app: "synth-app", maintainer: "Example Maintainer", identity: "not confirmed", needsInput: "from the screen only: no harness prompt pattern" } });
const human = await rig(["ps", "--nodes", "--rig", "agent-app-rig"]);
expect(human.stdout).toContain(`Community harness (${session}): synth-app, maintained by Example Maintainer, not tested by OpenRig. Identity not confirmed`);
const verdict = await until(() => json(["ps", "--nodes", "--rig", "agent-app-rig", "--full", "--json"]).then(n => n[0]?.identityVerdict),
expect(seat).toMatchObject({ runtime: "synth-harness", sessionStatus: "running",
communityHarness: { harness: "synth-harness", maintainer: "Example Maintainer", identity: "not confirmed", needsInput: "from the screen only: no harness prompt pattern" } });
const human = await rig(["ps", "--nodes", "--rig", "community-harness-rig"]);
expect(human.stdout).toContain(`Community harness (${session}): synth-harness, maintained by Example Maintainer, not tested by OpenRig. Identity not confirmed`);
const verdict = await until(() => json(["ps", "--nodes", "--rig", "community-harness-rig", "--full", "--json"]).then(n => n[0]?.identityVerdict),
v => v?.verdict === "unconfirmed", 20_000);
expect(verdict).toMatchObject({ verdict: "unconfirmed", reason: "process_identity_unconfirmed" });
}, 150_000);
@@ -117,7 +131,7 @@ describe("a community agent app from a data preset", () => {
// rig ps keeps the prompt the screen shows: the app's default-zero needs-input isn't a clearance.
// The ACTIVITY cell is clipped to its width, as for a built-in's "needs-input x1".
const row = (out: string) => out.split("\n").find(line => line.includes(session)) ?? "";
const shown = await until(() => rig(["ps", "--nodes", "--rig", "agent-app-rig"]).then(r => row(r.stdout)), line => line.includes("needs-input"), 20_000);
const shown = await until(() => rig(["ps", "--nodes", "--rig", "community-harness-rig"]).then(r => row(r.stdout)), line => line.includes("needs-input"), 20_000);
expect(shown).toContain("needs-input");
// The person answers it at the pane.
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "send-keys", "-t", `=${session}:`, "y", "Enter"], { env: scaffold.env as NodeJS.ProcessEnv });
@@ -154,12 +168,12 @@ describe("a community agent app from a data preset", () => {
const launched = await rig(["launch", rigId, "work.worker", "--json"], 60_000);
expect(launched.code).not.toBe(0);
expect(JSON.parse(launched.stdout)).toMatchObject({ status: "awaiting-decision",
error: `Resume is unsupported for community harness "synth-app": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh work.worker only if you want a new conversation.` });
error: `Resume is unsupported for community harness "synth-harness": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh work.worker only if you want a new conversation.` });
expect(() => capture()).toThrow();
// A new conversation stays the person's deliberate choice.
const fresh = await rig(["up", "agent-app-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
const fresh = await rig(["up", "community-harness-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
expect(JSON.parse(fresh.stdout).nodes, fresh.stdout).toEqual([expect.objectContaining({ logicalId: "work.worker", status: "fresh-primed" })]);
expect(await until(async () => capture(), text => text.includes("synth-app 1.0 started"))).toContain("app> ");
expect(await until(async () => capture(), text => text.includes("synth-harness 1.0 started"))).toContain("app> ");
await rig(["send", session, "quit", "--json"]);
await until(async () => capture(), text => text.includes("bye"));
execFileSync("tmux", ["-S", scaffold.tmuxSocketPath, "kill-session", "-t", `=${session}`], { env: scaffold.env as NodeJS.ProcessEnv });
@@ -175,16 +189,16 @@ describe("a community agent app from a data preset", () => {
expect(all.map((n: { rigName: string }) => n.rigName)).not.toContain("missing-app-rig");
}, 90_000);
it("keeps an app's seat an agent app after its preset is removed, across a daemon restart and a snapshot", async () => {
it("keeps an app's seat a community harness after its preset is removed, across a daemon restart and a snapshot", async () => {
// A fresh start while the preset is installed: this occupant was launched for the app.
const fresh = await rig(["up", "agent-app-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
const fresh = await rig(["up", "community-harness-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
expect(JSON.parse(fresh.stdout).nodes, fresh.stdout).toEqual([expect.objectContaining({ logicalId: "work.worker", status: "fresh-primed" })]);
await until(async () => capture(), text => text.includes("synth-app 1.0 started"));
rmSync(join(presetDir(), "synth-app.yaml"));
await until(async () => capture(), text => text.includes("synth-harness 1.0 started"));
rmSync(join(presetDir(), "synth-harness.yaml"));
await daemon.restart();
const seat = await until(node, n => n.identityVerdict?.verdict === "unconfirmed", 30_000);
expect(seat).toMatchObject({ runtime: "synth-app", identityVerdict: { verdict: "unconfirmed", reason: "process_identity_unconfirmed" } });
expect(seat.agentApp.presetError).toContain(`no community harness preset named "synth-app" is installed`);
expect(seat).toMatchObject({ runtime: "synth-harness", identityVerdict: { verdict: "unconfirmed", reason: "process_identity_unconfirmed" } });
expect(seat.communityHarness.presetError).toContain(`no community harness preset named "synth-harness" is installed`);
await sleep(8_000);
expect((await node()).activityState).toMatchObject({ activity: "unknown", decidedBy: null });
// Stopped, then a snapshot: still launch-only, so nothing resumes and nothing starts in its place.
@@ -196,13 +210,13 @@ describe("a community agent app from a data preset", () => {
const launched = await rig(["launch", rigId, "work.worker", "--json"], 60_000);
expect(launched.code).not.toBe(0);
expect(JSON.parse(launched.stdout)).toMatchObject({ status: "awaiting-decision",
error: expect.stringContaining(`Resume is unsupported for community harness "synth-app"`) });
error: expect.stringContaining(`Resume is unsupported for community harness "synth-harness"`) });
expect(() => capture()).toThrow();
const again = await rig(["up", "agent-app-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
expect(again.stdout + again.stderr).toContain("Harness not started: no synth-app runtime adapter for work.worker.");
expect(capture()).not.toContain("synth-app 1.0 started");
expect((await node()).agentApp.presetError).toContain(`no community harness preset named "synth-app" is installed`);
expect(readFileSync(join(rigRoot, "agent-app-rig.yaml"), "utf-8")).toContain("runtime: synth-app");
const again = await rig(["up", "community-harness-rig", "--existing", "--fresh", "work.worker", "--json"], 120_000);
expect(again.stdout + again.stderr).toContain("Harness not started: no synth-harness runtime adapter for work.worker.");
expect(capture()).not.toContain("synth-harness 1.0 started");
expect((await node()).communityHarness.presetError).toContain(`no community harness preset named "synth-harness" is installed`);
expect(readFileSync(join(rigRoot, "community-harness-rig.yaml"), "utf-8")).toContain("runtime: synth-harness");
}, 300_000);
/** Self-attach from a new pane on the private tmux server, the way a person runs `rig attach --self`. */
@@ -221,17 +235,17 @@ describe("a community agent app from a data preset", () => {
const up = await rig(["up", specFor("free-rig", "stub"), "--cwd", rigRoot, "--json", "--yes"], 120_000);
expect(up.code, JSON.stringify(up)).toBe(0);
const freeRigId = JSON.parse(up.stdout).rigId;
const attached = await attachSelf("free-seat", ["--rig", freeRigId, "--pod", "work", "--member", "free", "--runtime", "free-app", "--cwd", rigRoot]);
const attached = await attachSelf("free-seat", ["--rig", freeRigId, "--pod", "work", "--member", "free", "--runtime", "free-harness", "--cwd", rigRoot]);
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true });
const freeSeat = async () => (await json(["ps", "--nodes", "--rig", "free-rig", "--full", "--json"])).find((n: { logicalId: string }) => n.logicalId === "work.free");
const check = async () => {
const seat = await until(freeSeat, n => n?.identityVerdict?.verdict === "verified" && n?.activityState?.activity === "idle-at-prompt", 30_000);
expect(seat).toMatchObject({ runtime: "free-app", identityVerdict: { verdict: "verified", reason: null },
expect(seat).toMatchObject({ runtime: "free-harness", identityVerdict: { verdict: "verified", reason: null },
activityState: { activity: "idle-at-prompt", decidedBy: "window-sampling" } });
expect(seat).not.toHaveProperty("agentApp");
expect(seat).not.toHaveProperty("communityHarness");
};
await check();
writePreset("free-app.yaml", ["format: 1", "name: free-app", `command: node ${appScript}`, "maintainer: Example Maintainer", ""].join("\n"));
writePreset("free-harness.yaml", ["format: 1", "name: free-harness", `command: node ${appScript}`, "maintainer: Example Maintainer", ""].join("\n"));
await sleep(4_000);
await check();
// A relaunch doesn't start the same-named app in its place.
@@ -250,12 +264,12 @@ describe("a community agent app from a data preset", () => {
await rig(["down", "free-rig", "--json", "--force"]);
}, 300_000);
it("doesn't pass the agent-app classification to a different occupant of the same seat", async () => {
expect((await rig(["down", "agent-app-rig", "--json", "--force"])).code).toBe(0);
it("doesn't pass the community-harness classification to a different occupant of the same seat", async () => {
expect((await rig(["down", "community-harness-rig", "--json", "--force"])).code).toBe(0);
const attached = await attachSelf("other-occupant", ["--rig", rigId, "--node", "work.worker"]);
expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true });
const seat = await until(node, n => n.identityVerdict?.verdict === "verified", 30_000);
expect(seat.identityVerdict).toMatchObject({ verdict: "verified", reason: null });
expect(seat).not.toHaveProperty("agentApp");
expect(seat).not.toHaveProperty("communityHarness");
}, 120_000);
});
@@ -2,11 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { listAgentAppPresets, loadAgentAppPreset } from "../src/domain/agent-app-presets.js";
import { AgentAppRuntimeAdapter, seatAdapter, withAgentAppResolver, withAgentApps } from "../src/adapters/agent-app-runtime-adapter.js";
import { listCommunityHarnessPresets, loadCommunityHarnessPreset } from "../src/domain/community-harness-presets.js";
import { CommunityHarnessRuntimeAdapter, seatAdapter, withCommunityHarnessResolver, withCommunityHarnesses } from "../src/adapters/community-harness-runtime-adapter.js";
import { migrate } from "../src/db/migrate.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import {
AGENT_APP_RUNG_INVENTORY,
COMMUNITY_HARNESS_RUNG_INVENTORY,
CLAUDE_ACTIVITY_RUNG_INVENTORY,
CODEX_ACTIVITY_RUNG_INVENTORY,
TMUX_GENERIC_RUNG_INVENTORY,
@@ -15,8 +16,8 @@ import {
import { SeatActivityService } from "../src/domain/seat-activity-service.js";
import { SeatIdentityReconciler } from "../src/domain/seat-identity-reconciler.js";
import { SeatIdentityStore } from "../src/domain/seat-identity-store.js";
import { createFullTestDb } from "./helpers/test-app.js";
import { agentAppPaneActivity, classifyPaneActivity } from "../src/domain/session-transport.js";
import { createFullTestDb, migrationsForFullTestDb } from "./helpers/test-app.js";
import { communityHarnessPaneActivity, classifyPaneActivity } from "../src/domain/session-transport.js";
import type { RuntimeAdapter, NodeBinding } from "../src/domain/runtime-adapter.js";
import { TmuxAdapter } from "../src/adapters/tmux.js";
import Database from "better-sqlite3";
@@ -31,18 +32,18 @@ afterEach(() => {
});
function presets(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "agent-apps-"));
const dir = mkdtempSync(join(tmpdir(), "community-harnesses-"));
dirs.push(dir);
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
return dir;
}
const GOOD = "format: 1\nname: synth\ncommand: synth-app --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n";
const GOOD = "format: 1\nname: synth\ncommand: synth-harness --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n";
describe("agent app presets", () => {
describe("community harness presets", () => {
it("loads a preset with the fields this format reads", () => {
const dir = presets({ "synth.yaml": GOOD });
expect(loadAgentAppPreset("synth", dir)).toEqual({ ok: true, preset: {
name: "synth", command: "synth-app --serve", maintainer: "Example Maintainer", limits: ["resume unsupported"], path: join(dir, "synth.yaml") } });
expect(loadCommunityHarnessPreset("synth", dir)).toEqual({ ok: true, preset: {
name: "synth", command: "synth-harness --serve", maintainer: "Example Maintainer", limits: ["resume unsupported"], path: join(dir, "synth.yaml") } });
});
it.each([
@@ -50,24 +51,24 @@ describe("agent app presets", () => {
["another format", { "synth.yaml": GOOD.replace("format: 1", "format: 2") }, "incompatible", "it declares format 2; this OpenRig reads format 1"],
["a field this format doesn't read", { "synth.yaml": `${GOOD}resume: --resume {token}\n` }, "incompatible", "unknown field resume"],
["a name that isn't the file's", { "synth.yaml": GOOD.replace("name: synth", "name: other") }, "incompatible", `its name is "other", not "synth"`],
["no command", { "synth.yaml": GOOD.replace("command: synth-app --serve\n", "") }, "incompatible", "command must be a non-empty string"],
["no command", { "synth.yaml": GOOD.replace("command: synth-harness --serve\n", "") }, "incompatible", "command must be a non-empty string"],
["YAML that doesn't parse", { "synth.yaml": "format: [1" }, "incompatible", "it isn't valid YAML"],
])("says why it can't use %s", (_label, files, kind, text) => {
const found = loadAgentAppPreset("synth", presets(files as Record<string, string>));
const found = loadCommunityHarnessPreset("synth", presets(files as Record<string, string>));
expect(found).toMatchObject({ ok: false, kind });
expect(!found.ok && found.message).toContain(text);
});
it("calls a preset file with a name it can't take incompatible, and never looks outside the directory", () => {
const dir = presets({ "My_App.yaml": GOOD.replace("name: synth", "name: My_App") });
expect(loadAgentAppPreset("My_App", dir)).toMatchObject({ ok: false, kind: "incompatible",
expect(loadCommunityHarnessPreset("My_App", dir)).toMatchObject({ ok: false, kind: "incompatible",
message: expect.stringContaining("its name must be lowercase letters, digits and hyphens") });
expect(listAgentAppPresets(dir)).toEqual([expect.objectContaining({ ok: false, kind: "incompatible" })]);
expect(listCommunityHarnessPresets(dir)).toEqual([expect.objectContaining({ ok: false, kind: "incompatible" })]);
// A file one level up, in a directory this test owns, is never reached by "../outside".
const parent = presets({ "outside.yaml": GOOD.replace("name: synth", "name: outside") });
const inner = join(parent, "agent-apps");
const inner = join(parent, "community-harnesses");
mkdirSync(inner);
expect(loadAgentAppPreset("../outside", inner)).toMatchObject({ ok: false, kind: "missing" });
expect(loadCommunityHarnessPreset("../outside", inner)).toMatchObject({ ok: false, kind: "missing" });
});
it.each([
@@ -82,55 +83,55 @@ describe("agent app presets", () => {
["! (history expansion)", "node app.mjs --last !!"],
["! (history expansion)", 'node app.mjs --label "hi!"'],
])("keeps shell syntax (%s) out of the launch line typed into the pane's shell", (shown, command) => {
const found = loadAgentAppPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-app --serve", `command: ${command}`) }));
const found = loadCommunityHarnessPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-harness --serve", `command: ${command}`) }));
expect(found).toMatchObject({ ok: false, kind: "incompatible" });
expect(!found.ok && found.message).toContain(`command must be a single program and its arguments; shell syntax such as ${shown} isn't supported: put it in a script and name the script`);
});
it("accepts a program whose arguments are quoted, operators inside single quotes included", () => {
const command = `node "/srv/my apps/app.mjs" --label 'a|b && c!' --name "two words" --path 'C:\\apps\\x'`;
const found = loadAgentAppPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-app --serve", `command: ${command}`) }));
const found = loadCommunityHarnessPreset("synth", presets({ "synth.yaml": GOOD.replace("command: synth-harness --serve", `command: ${command}`) }));
expect(found).toMatchObject({ ok: true, preset: { command } });
});
it("reserves every built-in runtime name", () => {
for (const name of ["claude-code", "codex", "pi", "omp", "terminal", "stub"]) {
const dir = presets({ [`${name}.yaml`]: GOOD.replace("name: synth", `name: ${name}`) });
expect(loadAgentAppPreset(name, dir)).toMatchObject({ ok: false, kind: "incompatible",
expect(loadCommunityHarnessPreset(name, dir)).toMatchObject({ ok: false, kind: "incompatible",
message: `"${name}" is a built-in runtime; a community harness preset can't use that name` });
}
});
it("lists every preset file, usable or not", () => {
const dir = presets({ "synth.yaml": GOOD, "codex.yaml": GOOD.replace("name: synth", "name: codex"), "notes.txt": "x" });
expect(listAgentAppPresets(dir).map(found => found.ok)).toEqual([false, true]);
expect(listCommunityHarnessPresets(dir).map(found => found.ok)).toEqual([false, true]);
});
});
describe("the agent app adapter", () => {
describe("the community harness adapter", () => {
function adapter(dir: string) {
const tmux = {
hasSession: vi.fn(async () => true),
getPaneCommand: vi.fn(async () => "node"),
sendShellCommand: vi.fn(async () => ({ ok: true as const })),
};
return { tmux, app: new AgentAppRuntimeAdapter("synth", { tmux, presetsDir: () => dir }) };
return { tmux, app: new CommunityHarnessRuntimeAdapter("synth", { tmux, presetsDir: () => dir }) };
}
const binding = { tmuxSession: "w@r" } as NodeBinding;
it("types the preset's command into the seat's pane", async () => {
const { tmux, app } = adapter(presets({ "synth.yaml": GOOD }));
expect(await app.launchHarness(binding, { name: "w@r" })).toEqual({ ok: true });
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "synth-app --serve", undefined, { stageIfLong: true, execInScript: true });
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "synth-harness --serve", undefined, { stageIfLong: true, execInScript: true });
});
it("types the command through the seat's launch environment, as a real daemon does", async () => {
const { tmux } = adapter(presets({ "synth.yaml": GOOD }));
const command = vi.fn(async (_session: string, cmd: string, target?: { runtime?: string }) => `/usr/bin/env OPENRIG_RUNTIME=${target?.runtime} ${cmd}`);
const app = new AgentAppRuntimeAdapter("synth", { tmux, seatLaunchEnvironment: { command }, presetsDir: () => presets({ "synth.yaml": GOOD }) });
const app = new CommunityHarnessRuntimeAdapter("synth", { tmux, seatLaunchEnvironment: { command }, presetsDir: () => presets({ "synth.yaml": GOOD }) });
expect(await app.launchHarness(binding, { name: "w@r" })).toEqual({ ok: true });
expect(command).toHaveBeenCalledWith("w@r", "synth-app --serve", { runtime: "synth" });
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "/usr/bin/env OPENRIG_RUNTIME=synth synth-app --serve", undefined, { stageIfLong: true, execInScript: true });
expect(command).toHaveBeenCalledWith("w@r", "synth-harness --serve", { runtime: "synth" });
expect(tmux.sendShellCommand).toHaveBeenCalledWith("w@r", "/usr/bin/env OPENRIG_RUNTIME=synth synth-harness --serve", undefined, { stageIfLong: true, execInScript: true });
});
it("refuses resume and fork without starting anything", async () => {
@@ -146,7 +147,7 @@ describe("the agent app adapter", () => {
const { tmux, app } = adapter(presets({ "synth.yaml": GOOD }));
expect(await app.checkReady(binding)).toEqual({ ready: true });
tmux.getPaneCommand.mockResolvedValue("bash");
expect(await app.checkReady(binding)).toMatchObject({ ready: false, code: "app_not_running" });
expect(await app.checkReady(binding)).toMatchObject({ ready: false, code: "harness_not_running" });
});
it("places nothing and says so", async () => {
@@ -161,7 +162,7 @@ describe("relaunching a seat", () => {
const builtIn = (runtime: string) => ({ runtime }) as RuntimeAdapter;
const tmux = {} as Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
function map(dir: string) {
return withAgentAppResolver({ codex: builtIn("codex"), terminal: builtIn("terminal") } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => dir });
return withCommunityHarnessResolver({ codex: builtIn("codex"), terminal: builtIn("terminal") } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => dir });
}
it("keeps the map's keys the built-ins, and returns them as they are", () => {
@@ -171,35 +172,55 @@ describe("relaunching a seat", () => {
expect(m["synth"]).toBeUndefined();
});
it("resolves an agent app only for a seat launched for it", () => {
it("resolves a community harness only for a seat launched for it", () => {
const m = map(presets({ "synth.yaml": GOOD }));
expect(seatAdapter(m, "synth", "synth")).toBeInstanceOf(AgentAppRuntimeAdapter);
expect(seatAdapter(m, "synth", "synth")).toBeInstanceOf(CommunityHarnessRuntimeAdapter);
// A free-text runtime that only matches a preset's name gets no adapter, as before the preset existed.
expect(seatAdapter(m, "synth", null)).toBeUndefined();
expect(seatAdapter(m, "synth", "other-app")).toBeUndefined();
});
it("gives an agent app's seat no adapter once its preset is gone", () => {
it("gives a community harness's seat no adapter once its preset is gone", () => {
expect(seatAdapter(map(presets({})), "synth", "synth")).toBeUndefined();
});
});
describe("the agent app selection recorded on a session", () => {
describe("the community harness selection recorded on a session", () => {
it("keeps schema 114 applied once and maps its existing selection through the renamed field", () => {
const db = createFullTestDb(); dbs.push(db);
const migration = migrationsForFullTestDb.find(item => item.name.startsWith("114_"))!;
expect(migration).toBeDefined();
const ledgerBefore = db.prepare("SELECT name, applied_at FROM schema_migrations WHERE name = ?").get(migration.name);
expect(ledgerBefore).toBeDefined();
const registry = new SessionRegistry(db);
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('n1', 'rig-1', 'work.worker', 'synth', '/tmp')").run();
const session = registry.registerSession("n1", "work-worker@r");
registry.recordCommunityHarnessSelection(session.id, "synth");
const before = db.prepare("SELECT * FROM sessions WHERE id = ?").get(session.id);
// Calling migrate on a database that has 114 must skip it, not repeat its ALTER or rewrite data.
migrate(db, migrationsForFullTestDb);
expect(db.prepare("SELECT name, applied_at FROM schema_migrations WHERE name = ?").get(migration.name)).toEqual(ledgerBefore);
expect(db.prepare("SELECT * FROM sessions WHERE id = ?").get(session.id)).toEqual(before);
expect(registry.getSessionsForRig("rig-1")[0]).toHaveProperty("communityHarness", "synth");
expect(Object.keys(registry.getSessionsForRig("rig-1")[0]!)).not.toContain(["agent", "App"].join(""));
});
it("is read back only for the session it was recorded on", () => {
const db = createFullTestDb(); dbs.push(db);
const registry = new SessionRegistry(db);
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('n1', 'rig-1', 'work.worker', 'synth', '/tmp')").run();
const launched = registry.registerSession("n1", "work-worker@r");
registry.recordAgentAppSelection(launched.id, "synth");
registry.recordCommunityHarnessSelection(launched.id, "synth");
const claimed = registry.registerClaimedSession("n1", "work-worker@r");
const sessions = registry.getSessionsForRig("rig-1");
expect(sessions.find(s => s.id === launched.id)?.agentApp).toBe("synth");
expect(sessions.find(s => s.id === claimed.id)).not.toHaveProperty("agentApp");
expect(sessions.find(s => s.id === launched.id)?.communityHarness).toBe("synth");
expect(sessions.find(s => s.id === claimed.id)).not.toHaveProperty("communityHarness");
});
});
describe("the runtime adapter map with agent apps", () => {
describe("the runtime adapter map with community harnesses", () => {
const builtIn = (runtime: string) => ({ runtime }) as RuntimeAdapter;
const builtIns = () => ({ "claude-code": builtIn("claude-code"), codex: builtIn("codex"), pi: builtIn("pi"), omp: builtIn("omp"), stub: builtIn("stub"), terminal: builtIn("terminal") });
const tmux = {} as Pick<TmuxAdapter, "hasSession" | "getPaneCommand" | "sendShellCommand">;
@@ -207,15 +228,15 @@ describe("the runtime adapter map with agent apps", () => {
it("returns each built-in adapter as it is, even when a preset file takes its name", () => {
const original = builtIns();
const dir = presets({ "codex.yaml": GOOD.replace("name: synth", "name: codex"), "synth.yaml": GOOD });
const map = withAgentApps(original, { tmux, presetsDir: () => dir });
const map = withCommunityHarnesses(original, { tmux, presetsDir: () => dir });
for (const [name, adapter] of Object.entries(original)) expect(map[name as keyof typeof original]).toBe(adapter);
expect(Object.keys(map)).toEqual(Object.keys(original));
});
it("resolves an installed app, and nothing for one that isn't installed", () => {
const dir = presets({ "synth.yaml": GOOD });
const map: Record<string, RuntimeAdapter> = withAgentApps(builtIns(), { tmux, presetsDir: () => dir });
expect(map["synth"]).toBeInstanceOf(AgentAppRuntimeAdapter);
const map: Record<string, RuntimeAdapter> = withCommunityHarnesses(builtIns(), { tmux, presetsDir: () => dir });
expect(map["synth"]).toBeInstanceOf(CommunityHarnessRuntimeAdapter);
expect(map["synth"]!.runtime).toBe("synth");
expect("synth" in map).toBe(true);
expect(map["no-such-app"]).toBeUndefined();
@@ -223,18 +244,18 @@ describe("the runtime adapter map with agent apps", () => {
});
});
describe("activity for an agent app", () => {
it("gives built-ins the inventories they had, and agent apps their own", () => {
describe("activity for a community harness", () => {
it("gives built-ins the inventories they had, and community harnesses their own", () => {
expect(runtimeRungInventory("claude-code")).toBe(CLAUDE_ACTIVITY_RUNG_INVENTORY);
expect(runtimeRungInventory("codex")).toBe(CODEX_ACTIVITY_RUNG_INVENTORY);
for (const runtime of ["pi", "omp", "terminal", "stub", null]) expect(runtimeRungInventory(runtime)).toBe(TMUX_GENERIC_RUNG_INVENTORY);
// Only a seat launched for the app gets its inventory; a free-text runtime keeps the generic floor, as before.
expect(runtimeRungInventory("synth", "synth")).toBe(AGENT_APP_RUNG_INVENTORY);
expect(runtimeRungInventory("synth", "synth")).toBe(COMMUNITY_HARNESS_RUNG_INVENTORY);
expect(runtimeRungInventory("synth")).toBe(TMUX_GENERIC_RUNG_INVENTORY);
expect(runtimeRungInventory("synth", "other-app")).toBe(TMUX_GENERIC_RUNG_INVENTORY);
});
function seat(inventory: typeof AGENT_APP_RUNG_INVENTORY) {
function seat(inventory: typeof COMMUNITY_HARNESS_RUNG_INVENTORY) {
const svc = new SeatActivityService({ tmux: {} as never, defaultWindowSeconds: 3 });
svc.declareRungInventory({ seatNodeId: "n1", sessionName: "w@r" }, inventory);
let seq = 0;
@@ -244,7 +265,7 @@ describe("activity for an agent app", () => {
}
it("reads a quiet screen as unknown and a changing one as working, decided by window sampling", () => {
const s = seat(AGENT_APP_RUNG_INVENTORY);
const s = seat(COMMUNITY_HARNESS_RUNG_INVENTORY);
expect(s.state()).toMatchObject({ activity: "unknown", decidedBy: null });
s.sample("idle-at-prompt");
expect(s.state()).toMatchObject({ activity: "unknown", decidedBy: null, needsInputEvidence: null });
@@ -261,23 +282,23 @@ describe("activity for an agent app", () => {
});
});
describe("the pane reader for an agent app", () => {
it("turns an idle-looking screen into unknown for an agent app, and keeps work, attention and built-ins as they were", () => {
describe("the pane reader for a community harness", () => {
it("turns an idle-looking screen into unknown for a community harness, and keeps work, attention and built-ins as they were", () => {
const idle = classifyPaneActivity("some output\n❯ ");
expect(idle.state).toBe("agent_idle");
expect(agentAppPaneActivity(idle, "synth")).toEqual({ state: "unknown", reason: "agent_app_no_idle_signal", evidence: idle.evidence });
for (const agentApp of [null, undefined, ""]) expect(agentAppPaneActivity(idle, agentApp)).toBe(idle);
expect(communityHarnessPaneActivity(idle, "synth")).toEqual({ state: "unknown", reason: "community_harness_no_idle_signal", evidence: idle.evidence });
for (const communityHarness of [null, undefined, ""]) expect(communityHarnessPaneActivity(idle, communityHarness)).toBe(idle);
const prompt = classifyPaneActivity("Do you want to proceed? (y/n)");
expect(prompt.state).toBe("attention");
expect(agentAppPaneActivity(prompt, "synth")).toBe(prompt);
expect(communityHarnessPaneActivity(prompt, "synth")).toBe(prompt);
const busy = classifyPaneActivity("⠋ Working (esc to interrupt)");
expect(agentAppPaneActivity(busy, "synth")).toBe(busy);
expect(communityHarnessPaneActivity(busy, "synth")).toBe(busy);
});
});
describe("activity after the seat's occupant is replaced under the same session name", () => {
it("follows the new occupant's own selection: a free-text occupant's quiet screen reads idle, as on main", async () => {
const scratch = mkdtempSync(join(tmpdir(), "agent-app-swap-"));
const scratch = mkdtempSync(join(tmpdir(), "community-harness-swap-"));
dirs.push(scratch);
const socket = join(scratch, "tmux.sock");
const tmux = async (...args: string[]) => (await promisify(execFile)("tmux", ["-S", socket, ...args])).stdout;
@@ -306,12 +327,12 @@ describe("activity after the seat's occupant is replaced under the same session
});
});
describe("identity for an agent app", () => {
function reconcile(runtime: string, command: string, agentApp: string | null = null) {
describe("identity for a community harness", () => {
function reconcile(runtime: string, command: string, communityHarness: string | null = null) {
const db = createFullTestDb(); dbs.push(db);
db.prepare("INSERT INTO rigs (id, name) VALUES ('rig-1', 'r')").run();
db.prepare("INSERT INTO nodes (id, rig_id, logical_id, runtime, cwd) VALUES ('n1', 'rig-1', 'work.worker', ?, '/tmp')").run(runtime);
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, agent_app) VALUES ('s1', 'n1', 'w@r', 'running', 'ready', ?)").run(agentApp);
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status, agent_app) VALUES ('s1', 'n1', 'w@r', 'running', 'ready', ?)").run(communityHarness);
db.prepare("INSERT INTO bindings (id, node_id, attachment_type, tmux_session, tmux_pane) VALUES ('b1', 'n1', 'tmux', 'w@r', '%1')").run();
const tmux = { listSessions: vi.fn(async () => [{ name: "w@r" }] as never), getPanePid: vi.fn(async () => 10), getPaneCommand: vi.fn(async () => command) };
return { rec: new SeatIdentityReconciler({ db, tmux, now: () => new Date("2026-10-17T12:00:00Z") }), verdict: () => new SeatIdentityStore(db).getForNode("n1") };
@@ -1,10 +1,10 @@
// A synthetic community agent app for the agent-app runtime tests. It runs in the seat's pane as a Node process and
// A synthetic community harness for the community-harness runtime tests. It runs in the seat's pane as a Node process and
// reads one line at a time, skipping the envelope lines `rig send` adds: "busy" prints changing output for a few seconds, "ask" shows an approval prompt, "quit"
// exits back to the shell, and anything else is echoed back.
import { createInterface } from "node:readline";
const write = (text) => process.stdout.write(text);
write("synth-app 1.0 started\napp> ");
write("synth-harness 1.0 started\napp> ");
const rl = createInterface({ input: process.stdin });
let asking = false;
// `rig send` wraps a message in a short envelope (sender lines, a separator, a reply hint). Those lines aren't input.
+2 -2
View File
@@ -7,7 +7,7 @@ import { sessionStopIntentSchema } from "../../src/db/migrations/103_session_sto
import { nodeEffortChoiceSchema } from "../../src/db/migrations/104_node_effort_choice.js";
import { seatEnvSchema } from "../../src/db/migrations/111_seat_env.js";
import { appliedLaunchDaemonOptOutSchema } from "../../src/db/migrations/113_applied_launch_daemon_opt_out.js";
import { sessionAgentAppSchema } from "../../src/db/migrations/114_session_agent_app.js";
import { sessionAgentAppSchema as sessionCommunityHarnessSchema } from "../../src/db/migrations/114_session_agent_app.js";
import { threadSeatMapChannelKeySchema } from "../../src/db/migrations/115_thread_seat_map_channel_key.js";
import { modePostureCoexistSchema } from "../../src/db/migrations/109_mode_posture_coexist.js";
import { rigNonInterruptiveSchema } from "../../src/db/migrations/095_rig_non_interruptive.js";
@@ -132,7 +132,7 @@ import fs from "node:fs";
/** Seam B R6: the canonical full-fixture migration list, exported so file-backed
* DB-reopen tests migrate IDENTICALLY to createFullTestDb. */
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionAgentAppSchema, threadSeatMapChannelKeySchema];
export const migrationsForFullTestDb = [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, nodePermissionPolicySchema, rigPermissionPolicySchema, nodePolicyProvenanceSchema, rigPolicyProvenanceSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema, rigArchiveSchema, resumeProvenanceSchema, resumeVerificationSchema, seatIdentityVerdictsSchema, selfHostIdentitySchema, occupantTenuresSchema, daemonLifecycleSchema, watchdogJobsSchema, occupantGenerationStampsSchema, projectionManifestSchema, watchdogTargetGenerationSchema, appliedLaunchObservationsSchema, appliedLaunchObservationInvalidationsSchema, threadSeatMapSchema, queueTransitionWakesSchema, nodeSessionSourceSchema, scopedOperatingPostureSchema, humanNotificationIntentSchema, reviewReadIndexesSchema, inventoryEventIndexesSchema, rigClaudeManagedBlockFileSchema, nodePermissionSelectionsSchema, humanReplyToSchema, humanQuestionsSchema, nodeEffortSchema, rigNonInterruptiveSchema, rigInstallRootSchema, threadPartMapSchema, queueTransitionsQitemIdOrderSchema, queueItemsRecoveryTagsSchema, resumeRotationSchema, sessionStopIntentSchema, nodeEffortChoiceSchema, modePostureCoexistSchema, seatEnvSchema, appliedLaunchDaemonOptOutSchema, sessionCommunityHarnessSchema, threadSeatMapChannelKeySchema];
/**
* P24 — the DECLARED exclusions for {@link migrationsForFullTestDb}. That list is deliberately a
@@ -2647,23 +2647,23 @@ describe("RestoreOrchestrator", () => {
}
});
it("an agent app's seat stops at resume-unsupported even with a saved token, launching nothing", async () => {
it("a community harness's seat stops at resume-unsupported even with a saved token, launching nothing", async () => {
const rig = rigRepo.createRig("test-rig");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-agent-app", rig.id, "Dev");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-app", podId: "pod-agent-app" });
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-community-harness", rig.id, "Dev");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-harness", podId: "pod-community-harness" });
const session = sessionRegistry.registerSession(node.id, "dev-impl@test-rig");
sessionRegistry.updateStatus(session.id, "running");
// A token left by a former runtime: nothing resumes an agent app with it.
// A token left by a former runtime: nothing resumes a community harness with it.
sessionRegistry.updateResumeToken(session.id, "codex_id", "former-runtime-token");
sessionRegistry.recordAgentAppSelection(session.id, "synth-app");
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-app");
sessionRegistry.recordCommunityHarnessSelection(session.id, "synth-harness");
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-harness");
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
sessionRegistry.updateStatus(session.id, "exited");
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
const launchHarness = vi.fn(async () => ({ ok: true as const }));
const mockAdapter = {
runtime: "synth-app",
runtime: "synth-harness",
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
@@ -2672,25 +2672,25 @@ describe("RestoreOrchestrator", () => {
};
const orch = createOrchestrator();
const result = await orch.restore(snap.id, { adapters: { "synth-app": mockAdapter } });
const result = await orch.restore(snap.id, { adapters: { "synth-harness": mockAdapter } });
expect(result.ok).toBe(true);
if (result.ok) {
const nodeResult = result.result.nodes.find((n) => n.nodeId === node.id);
expect(nodeResult!.status).toBe("awaiting-decision");
expect(nodeResult!.error).toBe(`Resume is unsupported for community harness "synth-app": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh dev.impl only if you want a new conversation.`);
expect(nodeResult!.error).toBe(`Resume is unsupported for community harness "synth-harness": its preset is launch-only. No session was started, and the earlier conversation is left as it is. Re-run with --fresh dev.impl only if you want a new conversation.`);
expect(launchHarness).not.toHaveBeenCalled();
}
});
it("a free-text runtime seat that wasn't launched for an agent app keeps the existing restore result", async () => {
it("a free-text runtime seat that wasn't launched for a community harness keeps the existing restore result", async () => {
const rig = rigRepo.createRig("test-rig");
db.prepare("INSERT INTO pods (id, rig_id, label) VALUES (?, ?, ?)").run("pod-free-text", rig.id, "Dev");
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-app", podId: "pod-free-text" });
const node = rigRepo.addNode(rig.id, "dev.impl", { runtime: "synth-harness", podId: "pod-free-text" });
const session = sessionRegistry.registerSession(node.id, "dev-impl@test-rig");
sessionRegistry.updateStatus(session.id, "running");
sessionRegistry.updateResumeToken(session.id, "codex_id", "some-token");
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-app");
db.prepare("INSERT INTO node_startup_context (node_id, projection_entries_json, resolved_files_json, startup_actions_json, runtime) VALUES (?, ?, ?, ?, ?)").run(node.id, "[]", "[]", "[]", "synth-harness");
const snap = snapshotCapture.captureSnapshot(rig.id, "test");
sessionRegistry.updateStatus(session.id, "exited");
db.prepare("DELETE FROM bindings WHERE node_id = ?").run(node.id);
+7 -7
View File
@@ -19,7 +19,7 @@ import { CodexRuntimeAdapter } from "../src/adapters/codex-runtime-adapter.js";
import { PiRuntimeAdapter } from "../src/adapters/pi-runtime-adapter.js";
import { OmpRuntimeAdapter } from "../src/adapters/omp-runtime-adapter.js";
import { StubRuntimeAdapter } from "../src/adapters/stub-runtime-adapter.js";
import { withAgentAppResolver } from "../src/adapters/agent-app-runtime-adapter.js";
import { withCommunityHarnessResolver } from "../src/adapters/community-harness-runtime-adapter.js";
import { TerminalAdapter } from "../src/adapters/terminal-adapter.js";
import { piSeatPaths } from "../src/adapters/pi-runner-protocol.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
@@ -345,14 +345,14 @@ describe("changing an existing seat's launch directory", () => {
expect(cwdEvents()).toEqual([]);
});
it("resolves a community agent app's adapter the way launch does, and checks none of the startup files it never takes", async () => {
it("resolves a community harness's adapter the way launch does, and checks none of the startup files it never takes", async () => {
const seat = seed();
const presets = path.join(root, "agent-apps");
const presets = path.join(root, "community-harnesses");
fs.mkdirSync(presets);
fs.writeFileSync(path.join(presets, "synth.yaml"), "format: 1\nname: synth\ncommand: synth-app --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n");
fs.writeFileSync(path.join(presets, "synth.yaml"), "format: 1\nname: synth\ncommand: synth-harness --serve\nmaintainer: Example Maintainer\nlimits: [resume unsupported]\n");
db.prepare("UPDATE nodes SET runtime = 'synth' WHERE id = ?").run(seat.node.id);
db.prepare("UPDATE node_startup_context SET runtime = 'synth' WHERE node_id = ?").run(seat.node.id);
// A required startup skill the app would never take (an agent app takes no startup files).
// A required startup skill the app would never take (a community harness takes no startup files).
const skill = path.join(root, "app-skill", "review", "SKILL.md");
fs.mkdirSync(path.dirname(skill), { recursive: true });
fs.writeFileSync(skill, "# Review\n");
@@ -360,9 +360,9 @@ describe("changing an existing seat's launch directory", () => {
...seat.files, { path: "SKILL.md", absolutePath: skill, ownerRoot: path.dirname(skill), deliveryHint: "skill_install", required: true, appliesOn: ["fresh_start"] },
]), seat.node.id);
const sessionId = (db.prepare("SELECT id FROM sessions WHERE node_id = ? ORDER BY id DESC LIMIT 1").get(seat.node.id) as { id: string }).id;
registry.recordAgentAppSelection(sessionId, "synth");
registry.recordCommunityHarnessSelection(sessionId, "synth");
service = new SeatLifecycleService({ db, rigRepo, sessionRegistry: registry, eventBus, tmuxAdapter: tmux,
runtimeAdapters: withAgentAppResolver({ "claude-code": adapter } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => presets }) });
runtimeAdapters: withCommunityHarnessResolver({ "claude-code": adapter } as Record<string, RuntimeAdapter>, { tmux, presetsDir: () => presets }) });
const result = await service.setCwd({ seatRef: seat.sessionName, cwd: newCwd, reason: "isolate workspace" });
// No refusal code, named first so a failure shows which one.
expect((result as { code?: string }).code).toBeUndefined();
+4 -4
View File
@@ -76,8 +76,8 @@ interface NodeInventoryRead {
decidedBy?: string | null;
} | null;
identityVerdict?: { verdict?: string; reason?: string | null } | null;
/** Present only on a community agent app's seat. */
agentApp?: { app: string } | null;
/** Present only on a community harness's seat. */
communityHarness?: { harness: string } | null;
canonicalSessionName: string | null;
tmuxAttachCommand?: string | null;
cwd?: string | null;
@@ -268,8 +268,8 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
: node.activityState?.display === "idle"
? "idle"
: (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active"
// A community agent app gives no idle signal: a quiet screen is unknown, not idle.
: node.agentApp ? "unknown" : "idle"))
// A community harness gives no idle signal: a quiet screen is unknown, not idle.
: node.communityHarness ? "unknown" : "idle"))
: (node.sessionStatus ?? "unknown"),
live: node.lifecycleState === "running",
canRun: node.lifecycleState !== "running"
+4 -4
View File
@@ -446,14 +446,14 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
expect(output.lines.find((line) => /\bmismatch\s/.test(line))).not.toContain("run ▸");
expect(output.lines.find((line) => /\bmissing\s/.test(line))).not.toContain("run ▸");
});
it("shows a quiet community agent app as unknown, never idle, and leaves built-in rows as they were", async () => {
it("shows a quiet community harness as unknown, never idle, and leaves built-in rows as they were", async () => {
const base = (FIXTURES["/api/rigs/01JRIG/nodes"] as Array<Record<string, unknown>>)[0]!;
const quiet = { startupStatus: "ready", sessionStatus: "running", terminalActive: false, agentActivity: { state: "unknown" },
activityState: { activity: "unknown", display: "unknown", needsInput: { count: 0, reason: null }, decidedBy: null } };
const nodes = [
{ ...base, ...quiet, logicalId: "dev.app-quiet", runtime: "synth-app", agentApp: { app: "synth-app" } },
{ ...base, ...quiet, logicalId: "dev.app-moving", runtime: "synth-app", agentApp: { app: "synth-app" }, terminalActive: true },
{ ...base, ...quiet, logicalId: "dev.app-prompt", runtime: "synth-app", agentApp: { app: "synth-app" }, agentActivity: { state: "needs_input" } },
{ ...base, ...quiet, logicalId: "dev.app-quiet", runtime: "synth-harness", communityHarness: { harness: "synth-harness" } },
{ ...base, ...quiet, logicalId: "dev.app-moving", runtime: "synth-harness", communityHarness: { harness: "synth-harness" }, terminalActive: true },
{ ...base, ...quiet, logicalId: "dev.app-prompt", runtime: "synth-harness", communityHarness: { harness: "synth-harness" }, agentActivity: { state: "needs_input" } },
{ ...base, ...quiet, logicalId: "dev.pi-quiet", runtime: "pi" },
{ ...base, ...quiet, logicalId: "dev.claude-quiet", runtime: "claude-code" },
];