Compare commits

..

15 Commits

Author SHA1 Message Date
github-actions[bot] ee7f891698 docs: update token count to 196k tokens · 98% of context window 2026-06-16 11:15:10 +00:00
github-actions[bot] 7fde348e2b chore: bump version to 2.1.17 2026-06-16 11:15:04 +00:00
Gabi Simons 122135e6dc Merge pull request #2759 from assapin/fix/budget-error-surfaced-to-user
fix(agent-runner): deliver budget/billing error turns instead of dropping them
2026-06-16 14:14:48 +03:00
Gabi Simons 8563fb0681 Merge remote-tracking branch 'origin/main' into fix/budget-error-surfaced-to-user
# Conflicts:
#	CHANGELOG.md
2026-06-16 11:35:45 +03:00
omri-maya 0155ab1943 Merge pull request #2775 from nanocoai/docs/onecli-gateway-upgrade-notice
docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
2026-06-16 09:55:25 +03:00
Koshkoshinsk d1f94fcd24 docs(changelog): clarify the OneCLI gateway is a separate, operator-driven upgrade
The breaking notice said the onecli setup step enforces the pinned versions, which is only true for fresh installs — on an existing install, updating does not upgrade the running gateway. Clarify that the gateway is separate: /update-nanoclaw upgrades it when the pin moves, otherwise upgrade manually per docs/onecli-upgrades.md.
2026-06-15 20:25:42 +03:00
gavrielc dd60983f7f Merge pull request #2774 from nanocoai/feat/update-nanoclaw-onecli-pin
feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
2026-06-15 20:09:01 +03:00
Koshkoshinsk 096b8bf589 feat(update-nanoclaw): upgrade OneCLI gateway when its pinned version moves
When an update moves the onecli-gateway/onecli-cli pin in versions.json, the running gateway must be upgraded to match — otherwise the new code's @onecli-sh/sdk calls fail (404 on /v1/agents) and agents can't spawn. update-nanoclaw never detected this, so the upgrade was silently skipped. Add a conditional step that follows docs/onecli-upgrades.md before restart when the pin moves.
2026-06-15 19:37:23 +03:00
Gabi Simons 59c4d33adc Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 17:42:01 +03:00
omri-maya 5f5c28d18d Merge pull request #2773 from nanocoai/docs/codex-fix-docs
docs(add-codex): drop redundant TTY warning in auth note
2026-06-15 16:04:28 +03:00
Koshkoshinsk b92d1f9343 docs(add-codex): drop redundant TTY warning in auth note
The 'don't run via `!` prefix or Bash tool' sentence was redundant with
the leading 'Run this in a separate, real terminal — it is interactive.'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:32:04 +03:00
Gabi Simons e03c5c194a Merge branch 'main' into fix/budget-error-surfaced-to-user 2026-06-15 12:17:20 +03:00
Daniel M acbb1144b7 Merge pull request #2769 from nanocoai/docs/codex-interactive-host-restart
docs(add-codex): flag interactive auth step + add host-restart step
2026-06-15 02:24:06 +03:00
Koshkoshinsk 028897f38f docs(add-codex): flag interactive auth step + add host-restart step
- Authenticate: run in a separate real terminal, not Claude Code's `!`
  prefix or an agent Bash tool — the provider-auth picker + browser/device
  login need an interactive TTY, so those prompts stall otherwise (CDX-002).
- add a "Restart the host" step after the image rebuild so the host
  reloads Codex's /home/node/.codex mount + env; skipping it left the dir
  root-owned and the container hit EACCES writing config.toml (CDX-003).

Refs CDX-002, CDX-003.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:58:30 +03:00
assafpin 01433bae32 fix(agent-runner): deliver budget/billing error turns instead of dropping them
A turn that ends in a non-retryable provider error (e.g. an Anthropic
403 billing_error) comes back from the streaming SDK as a result with
is_error=true and no <message> envelope. dispatchResultText treated it
as scratchpad and dropped it, then the poll-loop pushed a re-wrap nudge
-> new turn -> same error, re-hammering the gateway until idle-kill. The
user saw silence.

- providers/claude.ts: surface is_error on the result event, and fall
  back to errors[] for the message text (error subtypes carry no result).
- poll-loop.ts: when a result has no <message> blocks and is_error, deliver
  the notice verbatim to the originating channel and skip the nudge.

Verified live (real agent image + SDK, 403 mock): the notice is delivered
to the channel and the retry loop is gone.

Refs #2751
2026-06-14 12:56:02 +03:00
12 changed files with 173 additions and 232 deletions
+18
View File
@@ -89,6 +89,22 @@ pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit
./container/build.sh
```
### Restart the host
The image rebuild does not reload the **host**. Codex's host contribution
(`src/providers/codex.ts`) registers the `/home/node/.codex` bind mount + env
passthrough, and the running host only picks it up on restart. Skip this and the
first Codex turn fails with `EACCES` writing `/home/node/.codex/config.toml`
with no mount, Docker auto-creates the dir root-owned and the non-root container
user can't write to it.
```bash
# macOS (launchd)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux (systemd)
systemctl --user restart nanoclaw
```
### Validate
```bash
@@ -100,6 +116,8 @@ The registration tests import only the real barrels — they go red if a barrel
## Authenticate
> **Run this in a separate, real terminal — it is interactive.** It prompts for ChatGPT-subscription vs OpenAI-API-key and then drives a browser/device login, so it needs a TTY to answer prompts.
```bash
pnpm exec tsx setup/index.ts --step provider-auth codex
```
+6
View File
@@ -121,6 +121,7 @@ Bucket the upstream changed files:
- **Host source** (`src/`): may conflict if user modified the same files
- **Container** (`container/`): triggers container rebuild (+ typecheck if `agent-runner/src/` changed)
- **Build/config** (`package.json`, `pnpm-lock.yaml`, `tsconfig*.json`): lockfile changes trigger dep install
- **Version pins** (`versions.json`): a changed `onecli-gateway` / `onecli-cli` value requires upgrading the OneCLI gateway/CLI to match — see Step 5.5
- **Other**: docs, tests, setup scripts, misc
**Large drift check:** If the upstream commit count and age suggest the user has a lot of catching up to do, mention that `/migrate-nanoclaw` might be a better fit — it extracts customizations and reapplies them on clean upstream instead of merging. Offer it as an option but don't push.
@@ -215,6 +216,11 @@ If build fails:
- Do not refactor unrelated code.
- If unclear, ask the user before making changes.
# Step 5.5: OneCLI upgrade (if pins moved)
The OneCLI gateway and CLI are external components pinned in `versions.json`; when a pin moves, the running version must be upgraded to match or the new code may fail against it.
If `git diff <backup-tag-from-step-1>..HEAD -- versions.json` shows the `onecli-gateway` or `onecli-cli` value changed, follow `docs/onecli-upgrades.md` before the service restart (Step 8). Otherwise skip.
# Step 6: Breaking changes check
After validation succeeds, check if the update introduced any breaking changes.
+2 -1
View File
@@ -4,7 +4,8 @@ All notable changes to NanoClaw will be documented in this file.
## [Unreleased]
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`; the `onecli` setup step enforces them. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **Budget/billing-exhausted LLM turns now reach the user instead of being silently dropped.** When a turn ends in a non-retryable provider error (e.g. an Anthropic `403 billing_error`) with no `<message>` wrapping, the agent-runner delivers the provider's notice to the originating channel and stops re-nudging the failing gateway. `providers/claude.ts` now surfaces the SDK's `is_error` flag (and the error subtype's `errors[]` text); `poll-loop.ts` delivers that text and skips the re-wrap retry. Fixes the case where a spend-limit notice produced silence plus a turn-after-turn retry loop.
- [BREAKING] **`@onecli-sh/sdk` 0.5.0 -> 2.2.1 — requires a OneCLI server with the `/v1` API** (older servers 404 every SDK call). The sanctioned gateway and CLI versions are pinned in `versions.json`. **The gateway is a separate component — updating NanoClaw does not upgrade it for you:** `/update-nanoclaw` upgrades it when the pin moves, otherwise upgrade manually. **Migration:** [docs/onecli-upgrades.md](docs/onecli-upgrades.md).
- **New agent provider: Codex (OpenAI) — run `/add-codex`.** Full runtime via `codex app-server` (planning, MCP tools, server-side history, resume). Trunk ships the seams and the skill; the payload installs from the `providers` branch (the skill, the setup picker, or `--step provider-auth codex`). Auth is vault-only — no credential ever enters a container.
- **Setup can now select, install, and authenticate a non-default agent provider.** A provider registry feeds the setup picker, an installer pulls the provider's payload from its branch, a vault auth walkthrough runs (`--step provider-auth`), and the picked provider is set on the first agent (a DB property) before its first spawn. Default (Claude) installs are unaffected — picking Claude changes nothing.
- **Provider choice is explicit per group — no install-wide default.** Provider is a DB property set via `ncl groups config update --provider` + restart; creation is provider-agnostic.
+15 -11
View File
@@ -13,7 +13,6 @@ import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
import { enqueueFileOut } from '../outbox.js';
import { registerTools } from './server.js';
import type { McpToolDefinition } from './types.js';
@@ -157,16 +156,21 @@ export const sendFile: McpToolDefinition = {
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath);
if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`);
const { id, filename } = enqueueFileOut({
srcPath: resolvedPath,
routing: {
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
in_reply_to: getCurrentInReplyTo(),
},
text: (args.text as string) || '',
filename: (args.filename as string) || undefined,
const id = generateId();
const filename = (args.filename as string) || path.basename(resolvedPath);
const outboxDir = path.join('/workspace/outbox', id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(resolvedPath, path.join(outboxDir, filename));
writeMessageOut({
id,
in_reply_to: getCurrentInReplyTo(),
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
thread_id: routing.thread_id,
content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }),
});
log(`send_file: ${id}${routing.resolvedName} (${filename})`);
-87
View File
@@ -1,87 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { initTestSessionDb, closeSessionDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { enqueueFileOut } from './outbox.js';
let outboxDir: string;
let srcDir: string;
beforeEach(() => {
initTestSessionDb();
outboxDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-outbox-'));
srcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-src-'));
process.env.NANOCLAW_OUTBOX_DIR = outboxDir;
});
afterEach(() => {
closeSessionDb();
delete process.env.NANOCLAW_OUTBOX_DIR;
fs.rmSync(outboxDir, { recursive: true, force: true });
fs.rmSync(srcDir, { recursive: true, force: true });
});
function writeSrc(name: string, bytes: string): string {
const p = path.join(srcDir, name);
fs.writeFileSync(p, bytes);
return p;
}
describe('enqueueFileOut', () => {
it('stages the file under the outbox and enqueues a messages_out row with files[]', () => {
const src = writeSrc('ig_abc.png', 'PNGDATA');
const { id, filename } = enqueueFileOut({
srcPath: src,
routing: { platform_id: 'chan-1', channel_type: 'discord', thread_id: 'thr-9', in_reply_to: 'm1' },
text: 'here you go',
});
// Bytes staged at <outbox>/<id>/<filename> for the host to read.
const staged = path.join(outboxDir, id, filename);
expect(fs.existsSync(staged)).toBe(true);
expect(fs.readFileSync(staged, 'utf8')).toBe('PNGDATA');
// Exactly one outbound row, carrying the file reference + routing.
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
const row = out[0];
expect(row.platform_id).toBe('chan-1');
expect(row.channel_type).toBe('discord');
expect(row.thread_id).toBe('thr-9');
expect(row.in_reply_to).toBe('m1');
const content = JSON.parse(row.content);
expect(content.files).toEqual(['ig_abc.png']);
expect(content.text).toBe('here you go');
});
it('defaults filename to the basename and text to empty', () => {
const src = writeSrc('chart.png', 'X');
const { filename } = enqueueFileOut({
srcPath: src,
routing: { platform_id: 'C-1', channel_type: 'slack', thread_id: null },
});
expect(filename).toBe('chart.png');
const row = getUndeliveredMessages()[0];
expect(row.in_reply_to).toBeNull();
const content = JSON.parse(row.content);
expect(content.text).toBe('');
expect(content.files).toEqual(['chart.png']);
});
it('throws when the source file is missing — callers decide how to surface it', () => {
expect(() =>
enqueueFileOut({
srcPath: path.join(srcDir, 'does-not-exist.png'),
routing: { platform_id: 'C-1', channel_type: 'slack', thread_id: null },
}),
).toThrow();
// Nothing enqueued on failure.
expect(getUndeliveredMessages()).toHaveLength(0);
});
});
-68
View File
@@ -1,68 +0,0 @@
/**
* File delivery via the outbox.
*
* A file is delivered in two parts that must stay in lockstep: the bytes are
* staged under `/workspace/outbox/<id>/<filename>` (the host reads them from
* there after polling), and a `messages_out` row carries `{ files: [name] }`
* so the host knows to attach them. This helper owns that contract so the two
* callers — the `send_file` MCP tool (model-driven) and the poll-loop's `file`
* event consumer (harness-generated images) — can't drift apart.
*/
import fs from 'fs';
import path from 'path';
import { writeMessageOut } from './db/messages-out.js';
/** Where staged files live. Overridable for tests; production is always the mount. */
function outboxBase(): string {
return process.env.NANOCLAW_OUTBOX_DIR ?? '/workspace/outbox';
}
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export interface FileOutRouting {
platform_id: string;
channel_type: string;
thread_id: string | null;
in_reply_to?: string | null;
}
export interface EnqueueFileOut {
/** Absolute or already-resolved path to the file to deliver. Must exist. */
srcPath: string;
routing: FileOutRouting;
/** Optional accompanying message text. */
text?: string;
/** Display name; defaults to the basename of `srcPath`. */
filename?: string;
}
/**
* Stage a file into the outbox and enqueue its `messages_out` row.
*
* Throws if `srcPath` cannot be read/copied — callers decide whether that
* should surface to the user (the MCP tool validates existence first; the
* poll-loop consumer logs and moves on so one bad image can't fail the turn).
*/
export function enqueueFileOut(opts: EnqueueFileOut): { id: string; filename: string; seq: number } {
const id = generateId();
const filename = opts.filename ?? path.basename(opts.srcPath);
const outboxDir = path.join(outboxBase(), id);
fs.mkdirSync(outboxDir, { recursive: true });
fs.copyFileSync(opts.srcPath, path.join(outboxDir, filename));
const seq = writeMessageOut({
id,
in_reply_to: opts.routing.in_reply_to ?? null,
kind: 'chat',
platform_id: opts.routing.platform_id,
channel_type: opts.routing.channel_type,
thread_id: opts.routing.thread_id,
content: JSON.stringify({ text: opts.text ?? '', files: [filename] }),
});
return { id, filename, seq };
}
+60 -1
View File
@@ -4,8 +4,9 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
import { getPendingMessages, markCompleted } from './db/messages-in.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { formatMessages, extractRouting } from './formatter.js';
import { isCorruptionError } from './poll-loop.js';
import { isCorruptionError, processQuery } from './poll-loop.js';
import { MockProvider } from './providers/mock.js';
import type { AgentQuery, ProviderEvent } from './providers/types.js';
beforeEach(() => {
initTestSessionDb();
@@ -379,6 +380,64 @@ describe('end-to-end with mock provider', () => {
});
});
/**
* Build a one-shot stub query that yields init + a single result event, then
* ends. `pushes` records any follow-ups the loop tried to inject (e.g. the
* re-wrap nudge), so a test can assert the loop did NOT re-hammer.
*/
function makeResultQuery(result: ProviderEvent): { query: AgentQuery; pushes: string[] } {
const pushes: string[] = [];
async function* events(): AsyncGenerator<ProviderEvent> {
yield { type: 'init', continuation: 'sess-1' };
yield result;
}
return {
pushes,
query: {
push: (m: string) => {
pushes.push(m);
},
end: () => {},
events: events(),
abort: () => {},
},
};
}
const ERR_ROUTING = {
platformId: 'chan-1',
channelType: 'discord',
threadId: null,
inReplyTo: 'm1',
};
describe('error result with no <message> envelope', () => {
it('delivers a budget/billing error to the triggering channel and does not nudge', async () => {
const budgetText = 'Spending limit reached. Add your own key at https://example.com/keys';
const { query, pushes } = makeResultQuery({ type: 'result', text: budgetText, isError: true });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe(budgetText);
expect(out[0].platform_id).toBe('chan-1');
expect(out[0].channel_type).toBe('discord');
// No re-wrap nudge — an error result must not re-hammer the gateway.
expect(pushes).toHaveLength(0);
});
it('still nudges (and does not deliver) a normal unwrapped result', async () => {
const { query, pushes } = makeResultQuery({ type: 'result', text: 'bare text, no envelope' });
await processQuery(query, ERR_ROUTING, ['m1'], 'claude', undefined, 'prompt', undefined);
expect(getUndeliveredMessages()).toHaveLength(0);
expect(pushes).toHaveLength(1);
expect(pushes[0]).toContain('was not delivered');
});
});
describe('isCorruptionError', () => {
it('matches the Docker Desktop macOS torn-read symptom', () => {
expect(isCorruptionError('database disk image is malformed')).toBe(true);
+53 -49
View File
@@ -14,7 +14,6 @@ import {
type RoutingContext,
} from './formatter.js';
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
import { enqueueFileOut } from './outbox.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderExchange } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -324,7 +323,7 @@ interface QueryResult {
continuation?: string;
}
async function processQuery(
export async function processQuery(
query: AgentQuery,
routing: RoutingContext,
initialBatchIds: string[],
@@ -483,33 +482,46 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
const { sent, hasUnwrapped } = dispatchResultText(event.text, routing);
if (sent === 0 && event.isError === true) {
// Non-retryable error turn (e.g. a 403 billing_error) with no
// <message> envelope: deliver the notice instead of dropping it as
// scratchpad, and skip the re-wrap nudge — it would just re-hammer
// the failing gateway turn after turn.
deliverErrorResult(event.text, routing);
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: 'error',
});
archivePrompts.shift();
} else {
const willRetryWrapping = hasUnwrapped && !unwrappedNudged;
notifyExchangeComplete(onExchangeComplete, {
prompt: archivePrompts[0] ?? initialPrompt,
result: event.text,
continuation: queryContinuation ?? initialContinuation,
status: hasUnwrapped ? 'undelivered' : 'completed',
});
if (willRetryWrapping) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
}
// The wrapping-retry result answers the SAME user prompt — keep it
// queued so the retry archives against it, not the nudge text.
if (!willRetryWrapping) archivePrompts.shift();
} else {
archivePrompts.shift();
}
} else if (event.type === 'file') {
deliverHarnessFile(event.path, routing);
}
}
} catch (err) {
@@ -561,31 +573,23 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
}
/**
* Deliver a harness-generated file (e.g. a Codex-rendered image) to the
* batch's reply destination. The model never sends these itself — its native
* client already rendered them — so the loop delivers them via the same outbox
* path send_file uses. Best-effort: a missing reply destination or an
* unreadable file logs and is skipped rather than failing the whole turn.
* Deliver a turn's text straight to the channel the batch arrived on. Used when
* a turn ends in a provider error (e.g. a non-retryable 403 billing_error) with
* no <message> envelope: the notice would otherwise be dropped as scratchpad.
* This is the same user-facing write the outer catch block does, minus the
* `Error:` prefix — the provider's text is already a user-facing message.
*/
function deliverHarnessFile(filePath: string, routing: RoutingContext): void {
if (!routing.platformId || !routing.channelType) {
log(`Dropping harness file ${filePath}: batch has no reply destination`);
return;
}
try {
const { filename, seq } = enqueueFileOut({
srcPath: filePath,
routing: {
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
in_reply_to: routing.inReplyTo,
},
});
log(`Delivered harness file #${seq}${routing.channelType}:${routing.platformId} (${filename})`);
} catch (err) {
log(`Failed to deliver harness file ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
}
function deliverErrorResult(text: string, routing: RoutingContext): void {
log('Error result with no <message> envelope — delivering to channel');
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text }),
});
}
/**
@@ -440,8 +440,13 @@ export class ClaudeProvider implements AgentProvider {
if (message.type === 'system' && message.subtype === 'init') {
yield { type: 'init', continuation: message.session_id };
} else if (message.type === 'result') {
const text = 'result' in message ? (message as { result?: string }).result ?? null : null;
yield { type: 'result', text };
// `result` text exists only on subtype:"success"; error subtypes
// (e.g. a non-retryable 403 billing_error) carry their message in
// `errors[]` instead. Surface either so the poll-loop can deliver a
// billing/quota notice to the user rather than dropping the turn.
const m = message as { result?: string; is_error?: boolean; errors?: string[] };
const text = m.result ?? (m.errors && m.errors.length > 0 ? m.errors.join('\n') : null);
yield { type: 'result', text, isError: m.is_error === true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'api_retry') {
yield { type: 'error', message: 'API retry', retryable: true };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'rate_limit_event') {
@@ -125,16 +125,15 @@ export interface AgentQuery {
export type ProviderEvent =
| { type: 'init'; continuation: string }
| { type: 'result'; text: string | null }
/**
* A completed turn. `isError` is set when the underlying SDK flagged the
* turn as an error (e.g. a non-retryable Anthropic 403 billing_error). The
* poll-loop uses it to surface the result text to the user instead of
* dropping it as un-wrapped scratchpad, and to skip the re-wrap nudge.
*/
| { type: 'result'; text: string | null; isError?: boolean }
| { type: 'error'; message: string; retryable: boolean; classification?: string }
| { type: 'progress'; message: string }
/**
* A file the harness produced that the model won't deliver itself (e.g.
* Codex's built-in image generation renders to its native client, so the
* model believes delivery already happened). The poll-loop delivers it to
* the batch's reply destination. `path` is absolute inside the container.
*/
| { type: 'file'; path: string }
/**
* Liveness signal. Providers MUST yield this on every underlying SDK
* event (tool call, thinking, partial message, anything) so the
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.16",
"version": "2.1.17",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="195k tokens, 98% of context window">
<title>195k tokens, 98% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="196k tokens, 98% of context window">
<title>196k tokens, 98% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">195k</text>
<text x="71" y="14">195k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">196k</text>
<text x="71" y="14">196k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB