Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
c4ecd51
docs(roadmap): cite PR #67 on the 2.5.H status (merged to main via #68)
cemililik Jul 7, 2026
b4d9619
docs(roadmap): 2.5-close Step 1 — reconcile 2.5.G Done + phase-2.5 in…
cemililik Jul 7, 2026
6e2e24a
docs(roadmap): 2.5-close Step 1 Opus-review fixes — ADR-0059 status +…
cemililik Jul 7, 2026
0d0574e
docs(roadmap): 2.5-close Step 1 Sonnet-review fixes — PR #66 merge da…
cemililik Jul 7, 2026
377a272
docs: 2.5-close Step 2 — correct surface-blind "encrypted history" wo…
cemililik Jul 7, 2026
d3267f8
docs: 2.5-close Step 2 Opus-review fixes — finish the surface-blind S…
cemililik Jul 7, 2026
d6544eb
docs: 2.5-close Step 2 Sonnet-review fixes — surface-neutral UVP/visi…
cemililik Jul 7, 2026
d12b997
fix(db): 2.5-close Step 3 — loadFull reads its snapshot in one read t…
cemililik Jul 7, 2026
2d14989
fix(db): 2.5-close Step 3 Opus-review fixes — bind the regression to …
cemililik Jul 7, 2026
6eec63c
test(db): 2.5-close Step 3 Sonnet-review fix — fully failure-isolate …
cemililik Jul 7, 2026
a60c362
fix(db): 2.5-close Step 4 — BEGIN IMMEDIATE + fail-loud SQLITE_BUSY r…
cemililik Jul 7, 2026
7b7d9ff
fix(db): 2.5-close Step 4 Opus-review fixes — 4th write path + doc ac…
cemililik Jul 7, 2026
d1d4b20
fix(db): 2.5-close Step 4 Sonnet-review fixes — harden the media-refe…
cemililik Jul 7, 2026
7769c5d
test(cli): 2.5-close Step 5 — concurrent chat+run e2e (2.5.I S3)
cemililik Jul 7, 2026
bca913d
test(cli): 2.5-close Step 5 Opus-review fixes — real held-lock conten…
cemililik Jul 7, 2026
9b68a07
test(cli): 2.5-close Step 5 Sonnet-review fixes — READY handshake for…
cemililik Jul 7, 2026
0180362
test(cli): 2.5-close Step 6 — Home→chat→resume→export chain e2e (2.5.…
cemililik Jul 7, 2026
edf7508
test(cli): 2.5-close Step 6 Opus-review fixes — prove context-threadi…
cemililik Jul 7, 2026
bfa5ee4
test(cli): 2.5-close Step 6 Sonnet-review fixes — exact reconstructio…
cemililik Jul 7, 2026
fcad2bb
test(cli): 2.5-close Step 7 — query-shape perf budgets (2.5.I S5)
cemililik Jul 7, 2026
c276145
test(cli,db): 2.5-close Step 7 Opus-review fixes — close the loadMess…
cemililik Jul 7, 2026
3eebb52
test(cli): 2.5-close Step 7 Sonnet-review fixes — covering-index tole…
cemililik Jul 7, 2026
0059f0d
ci(cli): 2.5-close Step 8 — Windows concurrency CI job + headless raw…
cemililik Jul 7, 2026
60b2cc8
ci(cli,db): 2.5-close Step 8 Opus-review fixes — Windows rmSync flake…
cemililik Jul 7, 2026
6a37f89
ci: 2.5-close Step 8 Sonnet-review fix — name the correct (stdout) TT…
cemililik Jul 7, 2026
b90cdba
docs(reference): 2.5-close Step 9 — reconcile regression-harness.md w…
cemililik Jul 7, 2026
1b01239
docs(reference): 2.5-close Step 9 Opus-review fixes — scope the in-pr…
cemililik Jul 7, 2026
4867366
feat(cli): 2.5-close Step 10 — NO_COLOR/FORCE_COLOR env support + --c…
cemililik Jul 7, 2026
77eee12
feat(cli): 2.5-close Step 10 Opus-review fixes — give FORCE_COLOR rea…
cemililik Jul 7, 2026
5a9d841
test(cli): 2.5-close Step 10 Sonnet-review fix — honest NO_COLOR/FORC…
cemililik Jul 7, 2026
11ccdc2
test(cli): 2.5-close Step 11 (Batch A) — fix the media-gc flake + a 2…
cemililik Jul 7, 2026
1d52111
test(cli): 2.5-close Step 11 Opus-review fixes — genuinely lock the g…
cemililik Jul 7, 2026
f32803a
feat(cli): 2.5-close Step 12 (Batch B) — 2.5.H TUI polish (persistent…
cemililik Jul 7, 2026
73de915
fix(cli): 2.5-close Step 12 Opus-review fixes — width-count honesty +…
cemililik Jul 7, 2026
c5a7d36
fix(cli): 2.5-close Step 12 Sonnet-review fixes — trailing-newline ro…
cemililik Jul 7, 2026
2e6527b
fix(core): 2.5-close Step 13 (Batch C) — AgentParseError line/col ({{…
cemililik Jul 8, 2026
a8dd337
fix(core): 2.5-close Step 13 Opus-review fix — keep the alias-branch …
cemililik Jul 8, 2026
9132ff9
test(core,cli): 2.5-close Step 13 Sonnet-review fixes — cause + locat…
cemililik Jul 8, 2026
1dce56c
feat(cli): 2.5-close Step 14 (Batch D-5) — bidi/format-control strip …
cemililik Jul 8, 2026
abfbdbe
feat(cli): 2.5-close Step 14 (Batch D-4) — whitelist the view-only re…
cemililik Jul 8, 2026
3e0af06
feat(cli): 2.5-close Step 14 (Batch D-1) — [c] reject-with-typed-reas…
cemililik Jul 8, 2026
a8cb441
refactor(cli): 2.5-close Step 14 (Batch D-2) — consolidate the non-TT…
cemililik Jul 8, 2026
8f7d954
feat(core,cli): 2.5-close Step 14 (Batch D-3) — SCOPE-denial conversa…
cemililik Jul 8, 2026
a3d7147
docs(adr,security): 2.5-close Step 14 — ADR-0057 amendment note + sec…
cemililik Jul 8, 2026
babd0e4
fix(cli,docs): 2.5-close Step 14 review fixes — canonical-home drift …
cemililik Jul 8, 2026
9aebbe5
docs,test: 2.5-close Step 14 Sonnet-review fixes — propagate the reco…
cemililik Jul 8, 2026
bd84a38
feat(cli): 2.5-close Step 15 (Batch E) — profile-aware advertise-filter
cemililik Jul 8, 2026
334e367
feat(cli): 2.5-close Step 15 (Batch E) — in-house .gitignore matcher …
cemililik Jul 8, 2026
3dffc8c
docs(roadmap): 2.5-close Final — mark 2.5.I/2.5.J + M2.5-4 done; Phas…
cemililik Jul 8, 2026
e08b8d0
fix(cli,db,docs): PR #69 review — gitignore ReDoS+dir-only, spawn-han…
cemililik Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,60 @@ jobs:
- name: Engine coverage floor (>=90% line+branch)
run: pnpm coverage

# Cross-OS concurrency + headless gate (2.5.I S6). The DB write-path hardening (BEGIN IMMEDIATE + the
# SQLITE_BUSY retry's Atomics.wait sleep + WAL locking) and the two-process concurrency e2e (a child spawn +
# a file:// import of the built @relavium/db) are the parts most likely to behave differently on Windows —
# ci.yml ran ubuntu-only (release.yml already smokes the built binary cross-OS, but never the test suite).
# Runs the @relavium/db concurrency suite (the real Windows native-addon + WAL exercise) + the CLI
# concurrency/perf harness there, plus a headless no-TTY smoke. A SEPARATE, advisory job (the required check
# stays the ubuntu `ci` job) — promote to required in branch protection once confirmed stable. POSIX 0600/0700
# perm assertions are NOT exercised here (a documented Windows no-op — ADR-0050); nor is ink's raw-mode code,
# which is TTY-gated (the driver-selection gate picks the plain driver without a TTY — see the smoke below).
windows-concurrency:
name: windows · db concurrency + headless smoke (advisory)
runs-on: windows-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
- name: Restore Turborepo cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
# The two-process concurrency e2e spawns children that import the BUILT @relavium/db (by file:// URL),
# so the dist must exist before the tests run.
- name: Build
run: pnpm turbo run build
- name: DB concurrency suite (BEGIN IMMEDIATE + SQLITE_BUSY retry + WAL behave identically on Windows)
run: pnpm --filter @relavium/db test
- name: CLI concurrency + query-shape harness (cross-process spawn + EXPLAIN plans on Windows)
run: pnpm --filter relavium exec vitest run concurrency perf-budget session-chain
- name: Headless no-TTY smoke (the bundle loads; a piped chat picks the plain driver and exits cleanly)
shell: bash
run: |
set -euo pipefail
node apps/cli/dist/index.js --help >/dev/null # the tsup bundle loads + --help renders
# `chat` is TTY-gated on STDOUT (selectChatDriver / io.stdoutIsTty): with stdout captured (non-TTY)
# here, the driver-selection gate picks the PLAIN driver (ink/setRawMode is never entered), and the
# plain driver must exit cleanly on EOF — never crash. A regression routing to ink without a TTY would
# throw "Raw mode is not supported…", which this catches.
out="$(printf '' | node apps/cli/dist/index.js chat 2>&1 || true)"
if printf '%s' "$out" | grep -qiE 'setRawMode|raw mode'; then
echo "::error::chat entered raw mode without a TTY (the driver-selection gate regressed)"; exit 1
fi
echo "✓ windows headless no-TTY smoke passed"

# --- Reserved Phase-1 lanes (TODO: enable with the first provider adapter) ------------
# The per-provider conformance suite and the nightly live-API lane land WITH the adapters
# in Phase 1 (testing.md); only their CI slots are reserved here so the testing standard
Expand Down
24 changes: 18 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,26 @@ under a new `sessionId` (TTY-interactive only, rejected under `--json`/plain per
compaction-moment UX polishes (a `session:compacting` "Summarizing…" event amending ADR-0036, and the footer
context-fullness indicator via a pure `@relavium/llm` `contextWindowForModel` helper), completing the ADR-0062
compaction story alongside the earlier-landed model-summarised `/compact` + deterministic `/trim` + automatic
compaction. **2.5.G is now underway** — its scope expanded to **Option A** (a **live** model catalog + a complete
model-pricing story that governs cost) behind three new ADRs ([ADR-0063](docs/decisions/0063-cli-config-write-contract.md)
compaction. **2.5.G** (onboarding wizard + Home `/models` + the live model catalog) is ✅ **Done (PR #66,
2026-07-07)** — its scope expanded to **Option A** (a **live** model catalog + a complete model-pricing story that
governs cost) behind three ADRs ([ADR-0063](docs/decisions/0063-cli-config-write-contract.md)
config-write, [ADR-0064](docs/decisions/0064-live-model-catalog.md) live catalog,
[ADR-0065](docs/decisions/0065-provider-economics-and-extensibility.md) provider economics), across 12 reviewed
steps. The additive lane **2.5.H** (reasoning render + live-turn feedback + an actionable error taxonomy — behind
[ADR-0065](docs/decisions/0065-provider-economics-and-extensibility.md) provider economics); all 12 steps landed,
plus the post-2.5.G model-UX follow-up ([ADR-0059](docs/decisions/0059-cli-mid-session-model-reseat.md) mid-session reseat +
[ADR-0066](docs/decisions/0066-normalized-reasoning-effort-control.md) reasoning-effort). With it **milestone
M2.5-2** is reached. The additive lane **2.5.H** (reasoning render + live-turn feedback + an actionable error taxonomy — behind
**EA6**, a dual-envelope `agent:reasoning` stream event that *amends* [ADR-0036](docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md);
no new top-level ADR) is ✅ **Done (2026-07-07)**, reaching milestone **M2.5-3** with 2.5.E; the remaining additive
lanes 2.5.I / J run in parallel.
no new top-level ADR) is ✅ **Done (PR #67, 2026-07-07)**, reaching milestone **M2.5-3** with 2.5.E; and the consolidation
lanes **2.5.I** (regression harness + DB concurrency hardening: `loadFull` read-txn snapshot, `BEGIN IMMEDIATE` writes with a
deterministic `SQLITE_BUSY` retry, the concurrent chat+run + cassette-chain + perf-budget e2es, an advisory Windows CI lane) and
**2.5.J** (docs-debt: the accurate unencrypted-history posture per ADR-0050 + `NO_COLOR`/`FORCE_COLOR`/`--color` resolution) are
✅ **Done (2.5-close-out, 2026-07-08)** — **reaching milestone M2.5-4, so Phase 2.5 is complete** — landed with the doable-now
Batch A–E backlog (test-hardening; 2.5.H TUI polish; `AgentParseError` line/col; the ADR-0057 approval/security batch — `[c]`
reject-with-reason + non-TTY policy + SCOPE-denial recovery + Ctrl+T-in-approval + the Trojan-Source bidi floor, behind an
append-only ADR-0057 amendment; the profile-aware advertise-filter + the in-house `.gitignore` matcher), each implement → Opus →
Sonnet with a security-review pass on the approval batch. Deferred to a focused follow-up (both refactor the security-sensitive
`gate.ts` resume path): the `relavium budget resume` command + secret re-provide on gate resume; the session `{{ctx.*}}`
interpolation stays with the Proposed [ADR-0060](docs/decisions/0060-session-ctx-prompt-interpolation.md) (Phase-2.6).
For live status, per-PR history, milestone dates, and open obligations, see the canonical home
[docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview.

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ surface); and the **inbound MCP client** — agents consume external MCP servers
proven by a real-spawn end-to-end test; and the **YAML-authoring lifecycle** — `relavium create` (a wizard
scaffolding an agent or a minimal single-agent workflow), `import`, and a share-safe `export` (re-serialized
from the validated AST, no provider key by construction). With every in-phase workstream merged, the CLI is
cut as **v0.1.1** (the public npm publish is the pending final maintainer step). For live status and the full roadmap, see
cut as **v0.1.1** (the public npm publish is the pending final maintainer step). **Phase 2.5 (CLI
Consolidation) is complete** (milestone **M2.5-4**, 2026-07-08) — the conversational Home, the slash-command
system, reseat-less chat modes with per-tool approval, context compaction, a live model catalog with provider
economics, reasoning render, and the closing consolidation lanes (regression harness + DB-concurrency
hardening + documentation reconciliation) have all landed. For live status and the full roadmap, see
[docs/roadmap/current.md](docs/roadmap/current.md) and the
[roadmap](docs/roadmap/README.md).

Expand Down
3 changes: 2 additions & 1 deletion apps/cli/src/chat/agent-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ export interface ResolveChatAgentOptions {
* discovered under `<projectConfigDir>/agents/`) parsed by the same strict core {@link parseAgent} a
* workflow uses, or — when omitted — the {@link buildDefaultChatAgent built-in default agent} over
* `[chat].default_model`. The host owns the file read ({@link resolveYamlSource}); the parser stays pure.
* A missing ref is a clean exit-2 invocation error; an invalid `.agent.yaml` is a field-named parse error.
* A missing ref is a clean exit-2 invocation error; an invalid `.agent.yaml` surfaces the raw, field-named
* {@link AgentParseError} (deliberately NOT re-tagged as a CliError — see agent-source.test.ts).
*/
export function resolveChatAgent(
agentRef: string | undefined,
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/chat/chat-mode-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export interface ChatModeEnv {
readonly governed: ReadonlySet<string>;
/** The session once/always memory (shared across mode changes — an "always" persists until the session ends). */
readonly cache: ApprovalCache;
/** The REPL's interactive `[y] yes / [a] always / [n] no / [esc] abort` prompt (accept-edits, and auto's
* protected-path fallback). A reject-with-typed-reason (`[c]` comment) is a deferred follow-up. */
/** The REPL's interactive `[y] yes / [a] always / [n] no / [c] reason / [esc] abort` prompt (accept-edits, and
* auto's protected-path fallback). `[c]` opens the typed-reason capture (Step 14 — a reject carrying WHY). */
readonly prompt: ApprovalPrompt;
/** Whether an approval preview targets a protected path — `auto` then falls back to a prompt (ADR-0057). */
readonly isProtectedTarget: (preview: ToolActionPreview) => boolean;
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/src/chat/chat-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
governedToolIds,
isGovernedTool,
nextMode,
nonInteractiveApprovalPrompt,
parseMode,
type ApprovalAnswer,
type ApprovalPrompt,
Expand Down Expand Up @@ -91,6 +92,33 @@ describe('isGovernedTool — mirrors the registry governedAction (advertise-filt
});
});

describe('nonInteractiveApprovalPrompt — the fail-closed no-TTY policy (Step 14)', () => {
it('DENIES every request (any tool / mode) — never a hang, never an auto-approve', async () => {
const prompt = nonInteractiveApprovalPrompt('in a one-shot agent run');
// A governed write and a process action both reject — the outcome does not depend on the request.
await expect(prompt(req(), true)).resolves.toEqual({
outcome: 'reject',
reason: 'interactive approval is unavailable in a one-shot agent run',
});
const proc = await prompt(
req({ toolId: 'run_command', action: 'process', preview: {} }),
false,
);
expect(proc.outcome).toBe('reject'); // still a reject — no tool is ever auto-approved off a TTY
});

it('names the surface in the (secret-free) reason so the two call sites stay distinguishable', async () => {
const driver = await nonInteractiveApprovalPrompt('on this non-interactive driver')(
req(),
true,
);
expect(driver).toEqual({
outcome: 'reject',
reason: 'interactive approval is unavailable on this non-interactive driver',
});
});
});

describe('ApprovalCache — session once/always memory', () => {
it('remembers an always-approval by tool id; once caches nothing', () => {
const cache = new ApprovalCache();
Expand Down
14 changes: 14 additions & 0 deletions apps/cli/src/chat/chat-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ export type ApprovalPrompt = (
signal?: AbortSignalLike,
) => Promise<ApprovalAnswer>;

/**
* The canonical fail-closed {@link ApprovalPrompt} for a NON-INTERACTIVE surface (Step 14 — the one home for the
* no-TTY policy, previously hand-rolled in `chat.ts` and `agent-run.ts`). When there is no user at a TTY to answer
* an interactive consent prompt — a plain non-TTY / `--json` chat, or a one-shot `agent run` — a governed dispatch
* must be DENIED immediately: never a hang (an unanswerable `store.requestApproval` promise), and never an
* auto-approve (that would be the "bypass all permissions" valve ADR-0057 forbids). So every request resolves to a
* reject, regardless of the tool / mode; the `context` names the surface in the secret-free denial reason. This is
* the ADR-0057 fail-closed floor with no interactive answerer — the model-facing outcome is the same `tool_denied`.
*/
export function nonInteractiveApprovalPrompt(context: string): ApprovalPrompt {
const reason = `interactive approval is unavailable ${context}`;
return () => Promise.resolve({ outcome: 'reject', reason });
}

/**
* The session-scoped, IN-MEMORY once/always cache (ADR-0057 — NOT persisted across resume, so a `chat-resume`
* re-prompts). `always` = a tool id approved for the remainder of this session instance; `once` caches
Expand Down
42 changes: 41 additions & 1 deletion apps/cli/src/chat/session-host.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -183,6 +184,45 @@ describe('buildChatSession', () => {
expect(tokens).toContain('the answer'); // the post-tool answer reached the stream
});

it('dispatches git_status through the process arm end-to-end (2.5.A union pin: session→host→process)', async () => {
// The process arm and the session→host dispatch are each covered separately (assemble.test.ts / the
// read_file case above); this pins the UNION for a real process-arm tool. git_status is granted to the
// default agent, pre-approved (no confirm gate), and takes no model-controlled args, so it reaches the host.
const repo = mkdtempSync(join(tmpdir(), 'relavium-git-'));
try {
execFileSync('git', ['init', '-q'], { cwd: repo }); // `git status` needs no user identity (unlike commits)
const built = await build({
cwd: repo,
providers: scriptedResolver([callWithArgs('c1', 'git_status', {}), textTurn('clean')]),
});
built.session.start();
await built.session.sendMessage('what changed?');
built.session.cancel();
const events = await drainHandle(built.handle.events);

// The git_status tool call was annotated on the stream (the session routed it to the host)…
const toolCall = events.find((e) => e.type === 'agent:tool_call');
expect(toolCall?.type === 'agent:tool_call' && toolCall.toolId).toBe('git_status');
// …the process arm produced a git_status RESULT that folded back. `success` here means only "the dispatch
// did not THROW" (the registry stamps it true for any resolved spawn) — it rules out git-not-on-PATH but,
// crucially, does NOT distinguish a clean run from an exit-128 "not a repository" (a non-zero git exit still
// RESOLVES a ProcessResult, so success stays true). The real lock is the summary: the process arm captured
// git's `{exitCode:0,…}` stdout, proving it ran to a CLEAN exit against the repo we init'd (not a silent
// wrong-cwd/uninit'd error the model could narrate 'clean' over from the cassette regardless).
const result = events.find((e) => e.type === 'agent:tool_result');
expect(result?.type === 'agent:tool_result' && result.toolId).toBe('git_status');
expect(result?.type === 'agent:tool_result' && result.success).toBe(true);
expect(result?.type === 'agent:tool_result' && result.outputSummary).toContain(
'"exitCode":0',
);
// …and the post-tool answer streamed, so the loop completed after the fold.
const tokens = events.flatMap((e) => (e.type === 'agent:token' ? [e.token] : [])).join('');
expect(tokens).toContain('clean');
} finally {
rmSync(repo, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});

it('enforces [chat].max_turns: an over-cap sendMessage settles loudly as turn_limit with no provider call', async () => {
// Two ENGAGED (successful) turns reach the cap of 2; the 3rd is blocked as turn_limit WITHOUT a provider
// call. Only an engaged turn counts toward the cap (F7, ADR-0055) — so the cap is reached by real turns,
Expand Down
9 changes: 4 additions & 5 deletions apps/cli/src/commands/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { StringDecoder } from 'node:string_decoder';

import type { SessionStreamHandleEvent } from '@relavium/core';

import { nonInteractiveApprovalPrompt } from '../chat/chat-mode.js';
import { applyChatMode, makeChatModeEnv } from '../chat/chat-mode-host.js';
import { cassetteResolver, loadCassette } from '../chat/fixture.js';
import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js';
Expand Down Expand Up @@ -164,11 +165,9 @@ async function runOneShotTurn(
session: built.session,
tools: built.tools,
workspaceDir: built.context.workingDir,
prompt: () =>
Promise.resolve({
outcome: 'reject',
reason: 'interactive approval is unavailable in a one-shot agent run',
}),
// The one canonical no-TTY fail-closed policy (Step 14) — a one-shot has no user to approve, so every
// governed dispatch is denied (never a hang / auto-approve). Shared with the non-interactive chat driver.
prompt: nonInteractiveApprovalPrompt('in a one-shot agent run'),
});
applyChatMode(modeEnv, 'ask');
built.session.start();
Expand Down
7 changes: 2 additions & 5 deletions apps/cli/src/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
CHAT_MODES,
MODE_DESCRIPTION,
MODE_LABEL,
nonInteractiveApprovalPrompt,
parseMode,
type ApprovalPrompt,
type ChatMode,
Expand Down Expand Up @@ -694,11 +695,7 @@ export function createChatModeControl(
const interactive = opts?.interactive ?? true;
const prompt: ApprovalPrompt = interactive
? store.requestApproval
: () =>
Promise.resolve({
outcome: 'reject',
reason: 'interactive approval is unavailable on this non-interactive driver',
});
: nonInteractiveApprovalPrompt('on this non-interactive driver');
const modeEnv = makeChatModeEnv({
session: built.session,
tools: built.tools,
Expand Down
Loading
Loading