mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Merge branch 'main' into fix/add-whatsapp-qr-browser-wrapper
This commit is contained in:
@@ -75,7 +75,7 @@ Stop and wait for the user to confirm before continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.im) account
|
||||
1. Set up a [Photon](https://photon.codes) account
|
||||
2. Get your server URL and API key
|
||||
|
||||
### Configure environment
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: add-rtk
|
||||
description: Install rtk token-compression proxy into agent containers. Routes Bash tool calls through rtk for 60–90% token savings on dev commands (git, cargo, pytest, docker, kubectl, etc.).
|
||||
---
|
||||
|
||||
# Add rtk
|
||||
|
||||
Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90% token savings on common dev commands (git, cargo, pytest, docker, kubectl, etc.) — and wire it transparently into agent containers via the Claude Code `PreToolUse` hook.
|
||||
|
||||
## What this sets up
|
||||
|
||||
- `rtk` binary at `~/.local/bin/rtk` on the host
|
||||
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
|
||||
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
|
||||
|
||||
## Step 1 — Install rtk on the host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
|
||||
```
|
||||
|
||||
If the script put the binary elsewhere, move it:
|
||||
|
||||
```bash
|
||||
find ~/.local ~/.cargo/bin ~/bin -name rtk 2>/dev/null
|
||||
mv "$(which rtk 2>/dev/null)" ~/.local/bin/rtk
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
~/.local/bin/rtk --version
|
||||
chmod +x ~/.local/bin/rtk # if needed
|
||||
```
|
||||
|
||||
## Step 2 — Identify the target agent group
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
```
|
||||
|
||||
Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each group.
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
`additional_mounts` is a JSON column not exposed via `ncl config update`. Update it directly via the DB helper, merging with any existing mounts.
|
||||
|
||||
Read current mounts first:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
Then write the merged array (include all existing entries plus the rtk entry):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
The rtk entry to append: `{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}`
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
|
||||
Each agent group has a `settings.json` at:
|
||||
|
||||
```
|
||||
data/v2-sessions/<group-id>/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
This file is mounted at `/home/node/.claude/settings.json` inside the container and is read by Claude Code for hooks, env, and model config.
|
||||
|
||||
Add the `PreToolUse` entry using `jq` to merge safely:
|
||||
|
||||
```bash
|
||||
SETTINGS="data/v2-sessions/<group-id>/.claude-shared/settings.json"
|
||||
|
||||
jq '.hooks.PreToolUse = [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
|
||||
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
|
||||
```
|
||||
|
||||
If `PreToolUse` already exists, append instead of overwriting:
|
||||
|
||||
```bash
|
||||
jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
|
||||
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
|
||||
```
|
||||
|
||||
## Step 5 — Restart the container
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
No `--message` needed — the hook is transparent and requires no agent awareness.
|
||||
|
||||
## Verify
|
||||
|
||||
Ask the agent to run `git status` or any other supported command. rtk intercepts it silently. Check savings with:
|
||||
|
||||
```bash
|
||||
~/.local/bin/rtk gain
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `rtk: command not found` inside the container
|
||||
|
||||
Mount wasn't applied or container wasn't restarted:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
# Look for entry with /usr/local/bin/rtk
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
### Hook not firing
|
||||
|
||||
Verify the hook is in `settings.json`:
|
||||
|
||||
```bash
|
||||
jq '.hooks.PreToolUse' data/v2-sessions/<group-id>/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
If missing, re-run Step 4.
|
||||
|
||||
### Binary won't execute — permission denied
|
||||
|
||||
```bash
|
||||
chmod +x ~/.local/bin/rtk
|
||||
```
|
||||
@@ -55,6 +55,47 @@ pnpm run build
|
||||
|
||||
## Credentials
|
||||
|
||||
Two paths — manual (Azure Portal) or auto (Teams CLI).
|
||||
|
||||
### Auto: Teams CLI
|
||||
|
||||
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
|
||||
|
||||
1. Install the CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @microsoft/teams.cli@preview
|
||||
```
|
||||
|
||||
2. Sign in and verify:
|
||||
|
||||
```bash
|
||||
teams login
|
||||
teams status
|
||||
```
|
||||
|
||||
3. Create the Entra app, client secret, and bot registration:
|
||||
|
||||
```bash
|
||||
teams app create \
|
||||
--name "NanoClaw" \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```
|
||||
|
||||
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
|
||||
|
||||
- `CLIENT_ID` → `TEAMS_APP_ID`
|
||||
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
|
||||
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
|
||||
|
||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
||||
|
||||
Continue to [Configure environment](#configure-environment).
|
||||
|
||||
---
|
||||
|
||||
The steps below describe the **manual Azure Portal path**.
|
||||
|
||||
### Step 1: Create an Azure AD App Registration
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
|
||||
|
||||
@@ -58,6 +58,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// a Codex thread id never gets handed to Claude or vice versa.
|
||||
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||
|
||||
// Before resuming, drop a session whose on-disk transcript has grown too
|
||||
// large/old to cold-resume within the host's idle ceiling. Without this a
|
||||
// long-lived hub keeps trying to reload an ever-growing .jsonl, hangs the
|
||||
// first turn, and gets killed before it can reply (then repeats forever).
|
||||
if (continuation) {
|
||||
const rotateReason = config.provider.maybeRotateContinuation?.(continuation, config.cwd);
|
||||
if (rotateReason) {
|
||||
log(`Rotating session — ${rotateReason}; starting fresh`);
|
||||
clearContinuation(config.providerName);
|
||||
continuation = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ClaudeProvider } from './claude.js';
|
||||
|
||||
// maybeRotateContinuation guards the cold-resume failure mode: a long-lived
|
||||
// session whose on-disk transcript has grown so large (or old) that the SDK
|
||||
// can't reload it before the host's idle ceiling kills the container.
|
||||
|
||||
let tmp: string;
|
||||
let prevHome: string | undefined;
|
||||
let prevConv: string | undefined;
|
||||
let prevBytes: string | undefined;
|
||||
let prevDays: string | undefined;
|
||||
|
||||
const PROJECT_DIR = '-workspace-agent';
|
||||
const CWD = '/workspace/agent';
|
||||
|
||||
function writeTranscript(sessionId: string, bytes: number, firstTs?: string): string {
|
||||
const dir = path.join(tmp, '.claude', 'projects', PROJECT_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const p = path.join(dir, `${sessionId}.jsonl`);
|
||||
const first =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: firstTs ?? new Date().toISOString(),
|
||||
message: { role: 'user', content: 'hello' },
|
||||
}) + '\n';
|
||||
const filler = 'x'.repeat(Math.max(0, bytes - first.length));
|
||||
fs.writeFileSync(p, first + filler);
|
||||
return p;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-rotate-'));
|
||||
prevHome = process.env.HOME;
|
||||
prevConv = process.env.NANOCLAW_CONVERSATIONS_DIR;
|
||||
prevBytes = process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES;
|
||||
prevDays = process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS;
|
||||
process.env.HOME = tmp;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.NANOCLAW_CONVERSATIONS_DIR = path.join(tmp, 'conversations');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const restore = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v));
|
||||
restore('HOME', prevHome);
|
||||
restore('NANOCLAW_CONVERSATIONS_DIR', prevConv);
|
||||
restore('CLAUDE_TRANSCRIPT_ROTATE_BYTES', prevBytes);
|
||||
restore('CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS', prevDays);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('ClaudeProvider.maybeRotateContinuation', () => {
|
||||
it('keeps a small, recent transcript (returns null, leaves file in place)', () => {
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024);
|
||||
const p = writeTranscript('sess-small', 4096);
|
||||
const provider = new ClaudeProvider();
|
||||
expect(provider.maybeRotateContinuation('sess-small', CWD)).toBeNull();
|
||||
expect(fs.existsSync(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('rotates an oversized transcript (returns reason, moves the .jsonl aside)', () => {
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(64 * 1024);
|
||||
const p = writeTranscript('sess-big', 200 * 1024);
|
||||
const provider = new ClaudeProvider();
|
||||
const reason = provider.maybeRotateContinuation('sess-big', CWD);
|
||||
expect(reason).toContain('MB');
|
||||
expect(fs.existsSync(p)).toBe(false); // original moved out of the resume path
|
||||
const dir = path.dirname(p);
|
||||
expect(fs.readdirSync(dir).some((f) => f.startsWith('sess-big.jsonl.rotated-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rotates an aged transcript even when small', () => {
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024);
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS = '7';
|
||||
const old = new Date(Date.now() - 10 * 86400_000).toISOString();
|
||||
writeTranscript('sess-old', 2048, old);
|
||||
const provider = new ClaudeProvider();
|
||||
expect(provider.maybeRotateContinuation('sess-old', CWD)).toContain('d');
|
||||
});
|
||||
|
||||
it('returns null for an unknown session id', () => {
|
||||
const provider = new ClaudeProvider();
|
||||
expect(provider.maybeRotateContinuation('does-not-exist', CWD)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
@@ -188,49 +189,122 @@ const postToolUseHook: HookCallback = async () => {
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a Claude transcript .jsonl, render a markdown summary, and drop it into
|
||||
* the agent's `conversations/` folder so context survives a compaction or a
|
||||
* session rotation. Best-effort: returns false (and logs) on any failure.
|
||||
*/
|
||||
function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: string | undefined, assistantName?: string): boolean {
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
log('No transcript found for archiving');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
||||
const messages = parseTranscript(content);
|
||||
if (messages.length === 0) return false;
|
||||
|
||||
// Try to get summary from sessions index
|
||||
let summary: string | undefined;
|
||||
const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const name = summary
|
||||
? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
|
||||
: `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`;
|
||||
|
||||
const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
||||
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
||||
log(`Archived conversation to ${filename}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
return async (input) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
const { transcript_path: transcriptPath, session_id: sessionId } = preCompact;
|
||||
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
log('No transcript found for archiving');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
||||
const messages = parseTranscript(content);
|
||||
if (messages.length === 0) return {};
|
||||
|
||||
// Try to get summary from sessions index
|
||||
let summary: string | undefined;
|
||||
const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const name = summary
|
||||
? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
|
||||
: `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`;
|
||||
|
||||
const conversationsDir = '/workspace/agent/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
||||
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
||||
log(`Archived conversation to ${filename}`);
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
archiveTranscriptFile(preCompact.transcript_path, preCompact.session_id, assistantName);
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
// ── Continuation rotation (cold-resume guard) ──
|
||||
|
||||
/**
|
||||
* Resume cost is dominated by transcript size. Past this many bytes a fresh
|
||||
* cold container can't reload the .jsonl before the host's 30-min idle ceiling
|
||||
* fires, so the session is dropped and started clean. Operator-overridable.
|
||||
*/
|
||||
function transcriptRotateBytes(): number {
|
||||
return Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES) || 12 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary age trigger, measured from the transcript's first entry. 0 (or a
|
||||
* non-positive value) disables the age check; size alone then governs.
|
||||
*/
|
||||
function transcriptRotateAgeMs(): number {
|
||||
const days = Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS);
|
||||
return Number.isFinite(days) && days > 0 ? days * 86_400_000 : 14 * 86_400_000;
|
||||
}
|
||||
|
||||
function claudeProjectsDir(): string {
|
||||
const base = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || os.homedir(), '.claude');
|
||||
return path.join(base, 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the .jsonl backing a session id. The SDK names project dirs by a
|
||||
* mangled cwd; rather than reproduce that convention we scan project dirs for
|
||||
* `<sessionId>.jsonl` (session ids are UUIDs, so this is unambiguous).
|
||||
*/
|
||||
function findTranscriptPath(sessionId: string): string | null {
|
||||
const projects = claudeProjectsDir();
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(projects);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(projects, dir, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Epoch-ms of the first transcript entry, or null if unreadable. */
|
||||
function transcriptStartMs(transcriptPath: string): number | null {
|
||||
try {
|
||||
const fd = fs.openSync(transcriptPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(4096);
|
||||
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
|
||||
const ts = JSON.parse(firstLine)?.timestamp;
|
||||
const ms = ts ? Date.parse(ts) : NaN;
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider ──
|
||||
|
||||
/**
|
||||
@@ -277,6 +351,41 @@ export class ClaudeProvider implements AgentProvider {
|
||||
return STALE_SESSION_RE.test(msg);
|
||||
}
|
||||
|
||||
maybeRotateContinuation(continuation: string): string | null {
|
||||
const transcriptPath = findTranscriptPath(continuation);
|
||||
if (!transcriptPath) return null;
|
||||
|
||||
let size: number;
|
||||
try {
|
||||
size = fs.statSync(transcriptPath).size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxBytes = transcriptRotateBytes();
|
||||
const startMs = transcriptStartMs(transcriptPath);
|
||||
const ageMs = startMs === null ? 0 : Date.now() - startMs;
|
||||
const maxAgeMs = transcriptRotateAgeMs();
|
||||
|
||||
let reason: string | null = null;
|
||||
if (size > maxBytes) {
|
||||
reason = `transcript ${(size / 1_048_576).toFixed(1)}MB > ${(maxBytes / 1_048_576).toFixed(0)}MB cap`;
|
||||
} else if (startMs !== null && ageMs > maxAgeMs) {
|
||||
reason = `transcript ${(ageMs / 86_400_000).toFixed(1)}d old > ${(maxAgeMs / 86_400_000).toFixed(0)}d cap`;
|
||||
}
|
||||
if (!reason) return null;
|
||||
|
||||
// Preserve a readable summary, then move the heavy .jsonl out of the
|
||||
// resume path so the SDK starts a fresh session and the disk is reclaimed.
|
||||
archiveTranscriptFile(transcriptPath, continuation, this.assistantName);
|
||||
try {
|
||||
fs.renameSync(transcriptPath, `${transcriptPath}.rotated-${Date.now()}`);
|
||||
} catch (err) {
|
||||
log(`Failed to move rotated transcript aside: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const stream = new MessageStream();
|
||||
stream.push(input.prompt);
|
||||
|
||||
@@ -14,6 +14,21 @@ export interface AgentProvider {
|
||||
* (missing transcript, unknown session, etc.) and should be cleared.
|
||||
*/
|
||||
isSessionInvalid(err: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Optional pre-resume maintenance. Given the stored continuation token,
|
||||
* decide whether its backing transcript has grown too large or too old to
|
||||
* resume cheaply. Return a non-null reason string to tell the caller to drop
|
||||
* the continuation and start a fresh session (the provider archives any
|
||||
* recoverable summary first); return null to keep resuming.
|
||||
*
|
||||
* Guards the cold-resume failure mode: a long-lived hub session accumulates
|
||||
* days of history — including base64 image blocks the agent Read — and the
|
||||
* SDK reloads the whole .jsonl on every resume. Past a threshold the first
|
||||
* turn alone can exceed the host's idle ceiling, so the container is killed
|
||||
* before it ever replies. Providers without an on-disk transcript omit this.
|
||||
*/
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.64",
|
||||
"version": "2.0.65",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -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="174k tokens, 87% of context window">
|
||||
<title>174k tokens, 87% 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="176k tokens, 88% of context window">
|
||||
<title>176k tokens, 88% 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">174k</text>
|
||||
<text x="71" y="14">174k</text>
|
||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">176k</text>
|
||||
<text x="71" y="14">176k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -247,7 +247,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.im',
|
||||
' 1. Set up a Photon server: https://photon.codes',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
|
||||
@@ -70,8 +70,8 @@ export const CONFIG: Entry[] = [
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'url',
|
||||
default: 'https://app.onecli.sh',
|
||||
placeholder: 'https://app.onecli.sh',
|
||||
default: 'https://api.onecli.sh',
|
||||
placeholder: 'https://api.onecli.sh',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
|
||||
+34
-31
@@ -194,7 +194,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
// 4. Send onboarding message — only on first wiring, not re-registration
|
||||
if (newlyWired) {
|
||||
const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared');
|
||||
const { session } = resolveSession(
|
||||
agentGroup.id,
|
||||
messagingGroup.id,
|
||||
null,
|
||||
parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
);
|
||||
writeSessionMessage(agentGroup.id, session.id, {
|
||||
id: generateId('onboard'),
|
||||
kind: 'task',
|
||||
@@ -208,40 +213,38 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel });
|
||||
}
|
||||
|
||||
// 5. Update assistant name in CLAUDE.md files if different from default
|
||||
// 5. Apply assistant name to JUST the group being registered.
|
||||
//
|
||||
// Earlier behavior did a project-wide find-replace of "Andy" across every
|
||||
// `groups/*/CLAUDE.md` and overwrote `.env`'s `ASSISTANT_NAME`, which
|
||||
// caused two real-world problems:
|
||||
// - registering a second agent (e.g. "Homie") clobbered the unrelated
|
||||
// primary agent's CLAUDE.md (replacing "Andy" with "Homie" in
|
||||
// groups/diddyclaw/CLAUDE.md when Diddyclaw was already in place);
|
||||
// - the global `.env` ASSISTANT_NAME flipped to the most recently-
|
||||
// registered agent, which then became the install-wide default
|
||||
// trigger for any *new* group registered without an explicit
|
||||
// `--assistant-name`.
|
||||
// Both were unintentional global side-effects of a per-agent operation.
|
||||
// Scope is now strictly: only the freshly-registered agent's own
|
||||
// `groups/<folder>/CLAUDE.md`.
|
||||
let nameUpdated = false;
|
||||
if (parsed.assistantName !== 'Andy') {
|
||||
log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName });
|
||||
|
||||
const groupsDir = path.join(projectRoot, 'groups');
|
||||
const mdFiles = fs
|
||||
.readdirSync(groupsDir)
|
||||
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
|
||||
.filter((f) => fs.existsSync(f));
|
||||
|
||||
for (const mdFile of mdFiles) {
|
||||
let content = fs.readFileSync(mdFile, 'utf-8');
|
||||
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
|
||||
fs.writeFileSync(mdFile, content);
|
||||
log.info('Updated CLAUDE.md', { file: mdFile });
|
||||
}
|
||||
|
||||
// Update .env
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
let envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
if (envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent = envContent.replace(/^ASSISTANT_NAME=.*$/m, `ASSISTANT_NAME="${parsed.assistantName}"`);
|
||||
} else {
|
||||
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
|
||||
const mdFile = path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md');
|
||||
if (fs.existsSync(mdFile)) {
|
||||
const before = fs.readFileSync(mdFile, 'utf-8');
|
||||
const after = before
|
||||
.replace(/^# Andy$/m, `# ${parsed.assistantName}`)
|
||||
.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(mdFile, after);
|
||||
log.info('Updated assistant name in registered group only', {
|
||||
file: mdFile,
|
||||
to: parsed.assistantName,
|
||||
});
|
||||
nameUpdated = true;
|
||||
}
|
||||
fs.writeFileSync(envFile, envContent);
|
||||
} else {
|
||||
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
|
||||
}
|
||||
log.info('Set ASSISTANT_NAME in .env');
|
||||
nameUpdated = true;
|
||||
}
|
||||
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
|
||||
@@ -36,6 +36,7 @@ const LINK_TIMEOUT_MS = 180_000;
|
||||
const DEFAULT_DEVICE_NAME = 'NanoClaw';
|
||||
|
||||
interface SignalAccount {
|
||||
number?: string;
|
||||
account?: string;
|
||||
registered?: boolean;
|
||||
}
|
||||
@@ -59,7 +60,7 @@ function listAccounts(): string[] {
|
||||
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
|
||||
return parsed
|
||||
.filter((a) => a.registered !== false)
|
||||
.map((a) => a.account ?? '')
|
||||
.map((a) => a.number ?? a.account ?? '')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
@@ -357,6 +357,87 @@ describe('unknown-channel registration flow', () => {
|
||||
.c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let a scoped admin connect an unknown channel to another agent group', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
createAgentGroup({ id: 'ag-2', name: 'Betty', folder: 'betty', agent_provider: null, created_at: now() });
|
||||
upsertUser({ id: 'telegram:scoped-admin', kind: 'telegram', display_name: 'Scoped Admin', created_at: now() });
|
||||
grantRole({
|
||||
user_id: 'telegram:scoped-admin',
|
||||
role: 'admin',
|
||||
agent_group_id: 'ag-1',
|
||||
granted_by: 'telegram:owner',
|
||||
granted_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-dm-scoped-admin',
|
||||
channel_type: 'telegram',
|
||||
platform_id: 'dm-scoped-admin',
|
||||
name: 'Scoped Admin DM',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run('telegram:scoped-admin', 'telegram', 'mg-dm-scoped-admin', now());
|
||||
|
||||
await routeInbound(groupMention('chat-scoped-cross-group'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
expect(pending).toBeDefined();
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverMock.mock.calls[0][1]).toBe('dm-scoped-admin');
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'choose_existing',
|
||||
userId: 'scoped-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-scoped-admin',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
const followupPayload = JSON.parse(deliverMock.mock.calls[1][4] as string) as {
|
||||
options: Array<{ label: string; value: string }>;
|
||||
};
|
||||
expect(followupPayload.options.map((option) => option.value)).toContain('connect:ag-1');
|
||||
expect(followupPayload.options.map((option) => option.value)).not.toContain('connect:ag-2');
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'connect:ag-2',
|
||||
userId: 'scoped-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-scoped-admin',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
const mgaCount = (
|
||||
getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as { c: number }
|
||||
).c;
|
||||
expect(mgaCount).toBe(0);
|
||||
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number })
|
||||
.c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-owner / no-agent failure modes', () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import type { AgentGroup } from '../../types.js';
|
||||
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
||||
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||
|
||||
// ── Value constants (response handler in index.ts parses these) ──
|
||||
|
||||
@@ -76,15 +77,24 @@ function toFolder(name: string): string {
|
||||
|
||||
// ── Card builders ──
|
||||
|
||||
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
|
||||
function visibleAgentGroupsForApprover(
|
||||
agentGroups: AgentGroup[],
|
||||
approverUserId: string | null | undefined,
|
||||
): AgentGroup[] {
|
||||
if (!approverUserId) return agentGroups;
|
||||
return agentGroups.filter((agentGroup) => hasAdminPrivilege(approverUserId, agentGroup.id));
|
||||
}
|
||||
|
||||
function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string | null): RawOption[] {
|
||||
const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId);
|
||||
const options: RawOption[] = [];
|
||||
if (agentGroups.length === 1) {
|
||||
if (visibleAgentGroups.length === 1) {
|
||||
options.push({
|
||||
label: `Connect to ${agentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
|
||||
label: `Connect to ${visibleAgentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${visibleAgentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${visibleAgentGroups[0].id}`,
|
||||
});
|
||||
} else {
|
||||
} else if (visibleAgentGroups.length > 1) {
|
||||
options.push({
|
||||
label: 'Choose existing agent',
|
||||
selectedLabel: '📋 Choosing…',
|
||||
@@ -194,7 +204,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
const channelName = originMg?.name ?? null;
|
||||
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups));
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId));
|
||||
|
||||
createPendingChannelApproval({
|
||||
messaging_group_id: messagingGroupId,
|
||||
@@ -241,8 +251,12 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
/**
|
||||
* Build normalized options for the agent-selection follow-up card.
|
||||
*/
|
||||
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
|
||||
const options: RawOption[] = agentGroups.map((ag) => ({
|
||||
export function buildAgentSelectionOptions(
|
||||
agentGroups: AgentGroup[],
|
||||
approverUserId?: string | null,
|
||||
): NormalizedOption[] {
|
||||
const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId);
|
||||
const options: RawOption[] = visibleAgentGroups.map((ag) => ({
|
||||
label: ag.name,
|
||||
selectedLabel: `✅ Connected to ${ag.name}`,
|
||||
value: `${CONNECT_PREFIX}${ag.id}`,
|
||||
|
||||
@@ -354,7 +354,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
if (!adapter) return true;
|
||||
|
||||
const agentGroups = getAllAgentGroups();
|
||||
const options = buildAgentSelectionOptions(agentGroups);
|
||||
const options = buildAgentSelectionOptions(agentGroups, approverId);
|
||||
const title = '📋 Choose an agent';
|
||||
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
||||
|
||||
@@ -438,6 +438,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
if (!hasAdminPrivilege(approverId, targetAgentGroupId)) {
|
||||
log.warn('Channel registration: target agent group rejected for unauthorized approver', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
targetAgentGroupId,
|
||||
approverId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
log.warn('Channel registration: unknown response value', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
|
||||
Reference in New Issue
Block a user