Compare commits

..

9 Commits

Author SHA1 Message Date
gavrielc e3f38dbed9 Merge branch 'main' into cleanup/resolve-session-reprovision 2026-07-04 19:35:26 +03:00
github-actions[bot] d8a6b99f0e chore: bump version to 2.1.33 2026-07-04 16:35:06 +00:00
gavrielc 8ca7564fbc Merge pull request #2936 from nanocoai/cleanup/cli-protocol-vocab
Clean up dead ncl CLI protocol vocabulary
2026-07-04 19:34:51 +03:00
gavrielc afa07f0566 Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:42 +03:00
github-actions[bot] 8059ee4eec docs: update token count to 207k tokens · 104% of context window 2026-07-04 16:34:08 +00:00
gavrielc 41c486cacc Merge branch 'main' into cleanup/cli-protocol-vocab 2026-07-04 19:34:02 +03:00
gavrielc 190a7d4f43 Merge pull request #2935 from nanocoai/cleanup/remove-dead-v1-config
Delete dead v1 config knobs and the broken pnpm auth script
2026-07-04 19:33:53 +03:00
gavrielc b42329551d Re-provision a missing session folder so the documented reset works
The /debug skill tells operators to rm -rf a session folder to reset a
stuck session, but the sessions row survives. The next message then takes
the existing-session path and opens inbound.db in a directory that no
longer exists — better-sqlite3 throws and the message is dropped forever.

writeSessionMessage now calls the idempotent initSessionFolder when the
inbound.db path is missing, so the documented reset actually re-provisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:55 +03:00
gavrielc 855f204d8a Clean up dead ncl CLI protocol vocabulary
Remove scaffold-era vocabulary in the ncl protocol that no code produces:
the duplicate `Access` union in crud.ts (now imported from registry.ts),
the unused `'hidden'` access level, and the never-emitted `'permission-denied'`
and `'not-found'` error codes. Also refresh three stale docstrings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:05:43 +03:00
8 changed files with 111 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.32",
"version": "2.1.33",
"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="208k tokens, 104% of context window">
<title>208k tokens, 104% 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="207k tokens, 104% of context window">
<title>207k tokens, 104% 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">208k</text>
<text x="71" y="14">208k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">207k</text>
<text x="71" y="14">207k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1 -1
View File
@@ -27,7 +27,7 @@ register({
if (cliScope === 'group') {
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
}
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
const commands = listCommands().filter((c) => !c.resource);
const lines: string[] = [];
+1 -2
View File
@@ -10,14 +10,13 @@ import { randomUUID } from 'crypto';
import { getDb } from '../db/connection.js';
import { register } from './registry.js';
import type { Access } from './registry.js';
import type { CallerContext } from './frame.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type Access = 'open' | 'approval' | 'hidden';
export interface ColumnDef {
name: string;
type: 'string' | 'number' | 'boolean' | 'json';
+1 -3
View File
@@ -11,7 +11,7 @@
export type RequestFrame = {
/** Correlation key set by the client. */
id: string;
/** Registry name, e.g. "list-groups". */
/** Registry name, e.g. "groups-list". */
command: string;
/** Command-specific. Each command's parseArgs validates. */
args: Record<string, unknown>;
@@ -24,10 +24,8 @@ export type ResponseFrame =
export type ErrorCode =
| 'unknown-command'
| 'invalid-args'
| 'permission-denied'
| 'forbidden'
| 'approval-pending'
| 'not-found'
| 'handler-error'
| 'transport-error';
+14 -5
View File
@@ -1,19 +1,28 @@
/**
* Command registry — single source of truth for what `ncl` can do.
*
* Each command file under `commands/` calls `register()` at top level,
* and `commands/index.ts` imports them all for side effects so the
* registry is populated before the host's CLI server accepts connections.
* Most commands come from resource modules under `resources/`, which call
* `registerResource()` (one `register()` per CRUD verb); the top-level `help`
* command and the per-resource help commands register directly. The barrel
* `commands/index.ts` imports the resource barrel for its side effects and then
* registers the help commands, so the registry is populated before the host's
* CLI server accepts connections.
*/
import type { CallerContext } from './frame.js';
export type Access = 'open' | 'approval' | 'hidden';
export type Access = 'open' | 'approval';
export type CommandDef<TArgs = unknown, TData = unknown> = {
name: string;
description: string;
access: Access;
/** Resource this command belongs to (for help grouping). */
/**
* The group-scope whitelist key. Under `cli_scope: 'group'` the dispatcher
* only lets an agent run commands whose `resource` is on the whitelist
* (`groups`, `sessions`, `destinations`, `members`); it also drives help
* grouping. Omitting `resource` exempts the command from the whitelist —
* that's how general commands like `help` stay reachable in group scope.
*/
resource?: string;
/**
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
+79 -1
View File
@@ -16,7 +16,17 @@ vi.mock('./config.js', async () => {
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-write-outbound' };
});
import { initSessionFolder, outboundDbPath, writeOutboundDirect } from './session-manager.js';
import {
initSessionFolder,
inboundDbPath,
outboundDbPath,
sessionDir,
writeOutboundDirect,
writeSessionMessage,
} from './session-manager.js';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
import { createSession } from './db/sessions.js';
import type { Session } from './types.js';
const TEST_DIR = '/tmp/nanoclaw-test-write-outbound';
const AG = 'ag-test';
@@ -98,3 +108,71 @@ describe('writeOutboundDirect', () => {
expect(rows.map((r) => r.seq)).toEqual([2, 4]);
});
});
/**
* The `/debug` skill tells operators to `rm -rf` a session folder to reset a
* stuck session. The sessions row survives, so the next message takes the
* existing-session path and lands in `writeSessionMessage` with a missing
* inbound.db. Without re-provisioning, better-sqlite3 throws on open and the
* message is logged-and-dropped forever — the reset silently kills the chat.
*/
describe('writeSessionMessage re-provisions a deleted session folder', () => {
beforeEach(() => {
const db = initTestDb();
runMigrations(db);
createAgentGroup({
id: AG,
name: 'Reset',
folder: 'reset',
agent_provider: null,
created_at: new Date().toISOString(),
});
const sess: Session = {
id: SESS,
agent_group_id: AG,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: new Date().toISOString(),
};
createSession(sess);
});
afterEach(() => {
closeDb();
});
it('re-creates the folder + inbound.db and does not throw when the row still exists', () => {
// Operator resets a stuck session by deleting its folder; the row survives.
fs.rmSync(sessionDir(AG, SESS), { recursive: true, force: true });
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(false);
expect(() =>
writeSessionMessage(AG, SESS, {
id: 'after-reset-1',
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: 'slack:C1',
channelType: 'slack',
threadId: null,
content: JSON.stringify({ text: 'still here?' }),
}),
).not.toThrow();
// The folder + inbound.db are back and the message landed.
expect(fs.existsSync(inboundDbPath(AG, SESS))).toBe(true);
const db = new Database(inboundDbPath(AG, SESS), { readonly: true });
try {
const row = db.prepare('SELECT id, content FROM messages_in WHERE id = ?').get('after-reset-1') as
| { id: string; content: string }
| undefined;
expect(row?.id).toBe('after-reset-1');
expect(JSON.parse(row!.content).text).toBe('still here?');
} finally {
db.close();
}
});
});
+10
View File
@@ -219,6 +219,16 @@ export function writeSessionMessage(
onWake?: 0 | 1;
},
): void {
// Documented reset: operators `rm -rf` a session folder to clear a stuck
// session. The sessions row survives, so the next message takes the
// existing-session path and lands here with a missing inbound.db — the open
// below would throw and the message would be logged-and-dropped forever.
// Re-provision the folder + DBs (initSessionFolder is idempotent) so the
// documented reset actually re-provisions instead of killing the chat.
if (!fs.existsSync(inboundDbPath(agentGroupId, sessionId))) {
initSessionFolder(agentGroupId, sessionId);
}
// Extract base64 attachment data, save to inbox, replace with file paths
const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);