Switch default runtime to pi
This commit is contained in:
parent
4fccff3f4e
commit
d7e57e2374
13 changed files with 755 additions and 417 deletions
21
.env.example
21
.env.example
|
|
@ -1 +1,22 @@
|
|||
ASSISTANT_NAME=Clawdie
|
||||
|
||||
# Primary engine for fresh installs
|
||||
AGENT_ENGINE=pi-tui
|
||||
PI_TUI_BIN=pi
|
||||
PI_TUI_PROVIDER=openrouter
|
||||
PI_TUI_MODEL=anthropic/claude-3.5-sonnet
|
||||
|
||||
# Primary API key for pi
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Optional alternative providers supported by pi
|
||||
# OPENAI_API_KEY=
|
||||
# ANTHROPIC_API_KEY=
|
||||
# GEMINI_API_KEY=
|
||||
# ZAI_API_KEY=
|
||||
|
||||
# Legacy Claude fallback
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=
|
||||
|
||||
# Channels
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Clawdie - Personal AI Assistant on FreeBSD
|
||||
|
||||
> **NanoClaw fork optimized for FreeBSD 15 with native jail isolation**
|
||||
> **Clawdie for FreeBSD 15 with native jail isolation**
|
||||
|
||||
[](https://www.freebsd.org/)
|
||||
[](https://docs.freebsd.org/en/books/handbook/jails/)
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
Clawdie is a lean, personal AI assistant built on NanoClaw, optimized for FreeBSD 15 with native jail isolation instead of Docker. This fork removes all Docker/Linux emulation overhead and uses FreeBSD's native container technology.
|
||||
Clawdie is a lean personal AI assistant for FreeBSD 15 with native jail isolation instead of Docker. It builds on NanoClaw's minimal design, then replaces Linux-container assumptions with FreeBSD-native runtime choices.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
|
|
@ -69,8 +69,8 @@ Jail: clawdie
|
|||
├── IP: 192.168.1.xxx
|
||||
├── Path: /jails/clawdie
|
||||
├── Mounts:
|
||||
│ ├── /home/clawdie/nanoclaw (ro)
|
||||
│ └── /home/clawdie/.config/nanoclaw (rw)
|
||||
│ ├── /home/clawdie/clawdie-cp (ro)
|
||||
│ └── /home/clawdie/.config/clawdie-cp (rw)
|
||||
└── Resources:
|
||||
├── Memory: 2G max
|
||||
├── CPU: 2 cores
|
||||
|
|
@ -90,14 +90,24 @@ Jail: clawdie
|
|||
|
||||
```bash
|
||||
# 1. Clone your fork
|
||||
git clone https://codeberg.org/Clawdie/Clawdie-AI.git /home/clawdie/nanoclaw
|
||||
cd /home/clawdie/nanoclaw
|
||||
git clone https://codeberg.org/Clawdie/Clawdie-AI.git /home/clawdie/clawdie-cp
|
||||
cd /home/clawdie/clawdie-cp
|
||||
|
||||
# 2. Install dependencies
|
||||
pkg install node24 npm git python312 py312-uv
|
||||
|
||||
# 3. Setup
|
||||
# 3. Install project dependencies
|
||||
npm install
|
||||
|
||||
# 4. Install the default PI TUI engine
|
||||
npm install -g @mariozechner/pi-coding-agent
|
||||
|
||||
# 5. Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 6. Add your provider key to .env (default: OPENROUTER_API_KEY)
|
||||
|
||||
# 7. Setup
|
||||
npm run setup
|
||||
```
|
||||
|
||||
|
|
@ -125,7 +135,7 @@ sudo pkg -j clawdie install node20 npm git
|
|||
|
||||
## Applied Skills
|
||||
|
||||
This fork has the following skills applied (customizations from NanoClaw base):
|
||||
This installation includes the following Clawdie skills and customizations:
|
||||
|
||||
| Skill | Description |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
|
|
@ -140,7 +150,7 @@ This fork has the following skills applied (customizations from NanoClaw base):
|
|||
| `/qodo-pr-resolver` | AI-powered PR review |
|
||||
| `/get-qodo-rules` | Load coding rules from Qodo |
|
||||
|
||||
## Configuration Differences from NanoClaw
|
||||
## Configuration Differences From Upstream
|
||||
|
||||
| Setting | NanoClaw Default | Clawdie Custom |
|
||||
| ----------------- | ---------------- | ------------------------- |
|
||||
|
|
@ -164,7 +174,7 @@ This fork has the following skills applied (customizations from NanoClaw base):
|
|||
### Local Documentation
|
||||
|
||||
- [FreeBSD Jail Implementation](docs/FREEBSD-JAIL-IMPLEMENTATION.md) - Complete jail setup guide
|
||||
- [NanoClaw → Clawdie Version History](docs/NANOCLAW-TO-CLAWDIE.md) - Version tracking
|
||||
- [NanoClaw → Clawdie Version History](docs/NANOCLAW-TO-CLAWDIE.md) - Upstream lineage and version tracking
|
||||
- [Architecture](docs/SPEC.md) - Technical architecture
|
||||
- [Security](docs/SECURITY.md) - Security model
|
||||
- [Requirements](docs/REQUIREMENTS.md) - Design requirements
|
||||
|
|
@ -204,7 +214,7 @@ npm test
|
|||
### File Structure
|
||||
|
||||
```
|
||||
/home/clawdie/nanoclaw/
|
||||
/home/clawdie/clawdie-cp/
|
||||
├── src/
|
||||
│ ├── jail-runtime.ts # Jail detection and management
|
||||
│ ├── jail-runtime.test.ts # Jail runtime tests
|
||||
|
|
@ -222,7 +232,7 @@ npm test
|
|||
└── package.json
|
||||
```
|
||||
|
||||
### Key Files Modified from Base
|
||||
### Key Files Modified From Upstream
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------- | --------------------------------------------------- |
|
||||
|
|
@ -232,7 +242,7 @@ npm test
|
|||
| `container/agent-runner/src/index.ts` | Voice transcription, Gmail tools |
|
||||
| `AGENTS.md` | European date format, custom rules |
|
||||
|
||||
### Added Files (Not in Base)
|
||||
### Added Files (Not in Upstream)
|
||||
|
||||
```
|
||||
src/channels/telegram.ts
|
||||
|
|
@ -277,7 +287,7 @@ State is tracked in `.nanoclaw/state.json`:
|
|||
|
||||
## Update Process
|
||||
|
||||
To pull upstream NanoClaw changes while preserving customizations:
|
||||
To pull upstream NanoClaw changes while preserving Clawdie customizations:
|
||||
|
||||
```bash
|
||||
# Add upstream remote (one-time)
|
||||
|
|
@ -304,7 +314,7 @@ The skills engine automatically rebases your customizations onto the new base.
|
|||
|
||||
### Attack Surface Reduction
|
||||
|
||||
| Surface | Standard NanoClaw | Clawdie on FreeBSD |
|
||||
| Surface | Upstream NanoClaw | Clawdie on FreeBSD |
|
||||
| ----------------- | ----------------- | ----------------------------------- |
|
||||
| Container runtime | Docker daemon | None (native jails) |
|
||||
| Emulation layer | Linux compat | None |
|
||||
|
|
@ -357,7 +367,7 @@ See [SECURITY.md](docs/SECURITY.md) for full security model.
|
|||
|
||||
## Contributing
|
||||
|
||||
This is a personal fork optimized for FreeBSD. For contributions to the main project:
|
||||
This is the active Clawdie project for FreeBSD. For upstream contributions:
|
||||
|
||||
- **NanoClaw:** https://github.com/openclaw/nanoclaw
|
||||
- **OpenClaw:** https://github.com/openclaw/openclaw
|
||||
|
|
@ -366,7 +376,7 @@ For Clawdie-specific issues, use Codeberg issues.
|
|||
|
||||
## License
|
||||
|
||||
Same as NanoClaw (check LICENSE file).
|
||||
Same as upstream NanoClaw (check LICENSE file).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
## Overview
|
||||
|
||||
Clawdie is a personal AI assistant forked from [NanoClaw](https://github.com/openclaw/nanoclaw), optimized for FreeBSD 15 with native jail isolation instead of Docker. It runs AI agents securely in FreeBSD jails with ZFS integration, providing better performance and simpler architecture than container emulation.
|
||||
Clawdie is a personal AI assistant for FreeBSD 15 with native jail isolation instead of Docker. It runs AI agents securely in FreeBSD jails with ZFS integration, providing better performance and simpler architecture than container emulation.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
|
|
@ -48,9 +48,9 @@ Clawdie is a personal AI assistant forked from [NanoClaw](https://github.com/ope
|
|||
|
||||
**Result:** Simpler, faster, more secure.
|
||||
|
||||
### Why This Fork?
|
||||
### Why Clawdie?
|
||||
|
||||
[NanoClaw](https://github.com/openclaw/nanoclaw) provides an excellent foundation: a minimal, secure AI assistant that runs agents in isolated containers. Clawdie extends this for FreeBSD users who want:
|
||||
[NanoClaw](https://github.com/openclaw/nanoclaw) provides the upstream foundation: a minimal, secure AI assistant that runs agents in isolated containers. Clawdie extends that work for FreeBSD users who want:
|
||||
|
||||
- Native performance without Linux emulation
|
||||
- ZFS integration for snapshots and quotas
|
||||
|
|
@ -121,13 +121,22 @@ pkg install node24 npm git python312 py312-uv
|
|||
# 3. Install Node.js dependencies
|
||||
npm install
|
||||
|
||||
# 4. Run setup
|
||||
# 4. Install the default PI TUI engine
|
||||
npm install -g @mariozechner/pi-coding-agent
|
||||
|
||||
# 5. Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# 6. Add your provider key to .env (default: OPENROUTER_API_KEY)
|
||||
|
||||
# 7. Run setup
|
||||
npm run setup
|
||||
```
|
||||
|
||||
The setup process will guide you through:
|
||||
|
||||
- Environment configuration
|
||||
- PI TUI runtime configuration (`pi`, default provider, model)
|
||||
- Channel authentication (Telegram, etc.)
|
||||
- Jail creation (optional, can run without jails initially)
|
||||
- Service configuration
|
||||
|
|
|
|||
426
container/agent-runner/src/engines/claude-sdk.ts
Normal file
426
container/agent-runner/src/engines/claude-sdk.ts
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
HookCallback,
|
||||
PreCompactHookInput,
|
||||
PreToolUseHookInput,
|
||||
query,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import {
|
||||
AgentEngine,
|
||||
ContainerOutput,
|
||||
EngineRunInput,
|
||||
EngineRunResult,
|
||||
} from './types.js';
|
||||
|
||||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
fullPath: string;
|
||||
summary: string;
|
||||
firstPrompt: string;
|
||||
}
|
||||
|
||||
interface SessionsIndex {
|
||||
entries: SessionEntry[];
|
||||
}
|
||||
|
||||
interface SDKUserMessage {
|
||||
type: 'user';
|
||||
message: { role: 'user'; content: string };
|
||||
parent_tool_use_id: null;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
const IPC_POLL_MS = 500;
|
||||
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
class MessageStream {
|
||||
private queue: SDKUserMessage[] = [];
|
||||
private waiting: (() => void) | null = null;
|
||||
private done = false;
|
||||
|
||||
push(text: string): void {
|
||||
this.queue.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
});
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.done = true;
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
||||
while (true) {
|
||||
while (this.queue.length > 0) {
|
||||
yield this.queue.shift()!;
|
||||
}
|
||||
if (this.done) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiting = resolve;
|
||||
});
|
||||
this.waiting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionSummary(
|
||||
sessionId: string,
|
||||
transcriptPath: string,
|
||||
log: (message: string) => void,
|
||||
): string | null {
|
||||
const projectDir = path.dirname(transcriptPath);
|
||||
const indexPath = path.join(projectDir, 'sessions-index.json');
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
log(`Sessions index not found at ${indexPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
const entry = index.entries.find((e) => e.sessionId === sessionId);
|
||||
if (entry?.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
} catch (err) {
|
||||
log(
|
||||
`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTranscript(content: string): ParsedMessage[] {
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text =
|
||||
typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: entry.message.content
|
||||
.map((c: { text?: string }) => c.text || '')
|
||||
.join('');
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const textParts = entry.message.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text);
|
||||
const text = textParts.join('');
|
||||
if (text) messages.push({ role: 'assistant', content: text });
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed transcript lines.
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function generateFallbackName(): string {
|
||||
const time = new Date();
|
||||
return `conversation-${time.getHours().toString().padStart(2, '0')}${time
|
||||
.getMinutes()
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatTranscriptMarkdown(
|
||||
messages: ParsedMessage[],
|
||||
title: string | null | undefined,
|
||||
assistantName?: string,
|
||||
): string {
|
||||
const now = new Date();
|
||||
const formatDateTime = (d: Date) =>
|
||||
d.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${title || 'Conversation'}`);
|
||||
lines.push('');
|
||||
lines.push(`Archived: ${formatDateTime(now)}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
for (const msg of messages) {
|
||||
const sender =
|
||||
msg.role === 'user' ? 'User' : assistantName || 'Assistant';
|
||||
const content =
|
||||
msg.content.length > 2000 ? `${msg.content.slice(0, 2000)}...` : msg.content;
|
||||
lines.push(`**${sender}**: ${content}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function createPreCompactHook(
|
||||
assistantName: string | undefined,
|
||||
log: (message: string) => void,
|
||||
): HookCallback {
|
||||
return async (input: unknown) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
const transcriptPath = preCompact.transcript_path;
|
||||
const sessionId = preCompact.session_id;
|
||||
|
||||
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) {
|
||||
log('No messages to archive');
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary = getSessionSummary(sessionId, transcriptPath, log);
|
||||
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
||||
|
||||
const conversationsDir = '/workspace/group/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const filename = `${date}-${name}.md`;
|
||||
const filePath = path.join(conversationsDir, filename);
|
||||
|
||||
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
log(`Archived conversation to ${filePath}`);
|
||||
} catch (err) {
|
||||
log(
|
||||
`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function createSanitizeBashHook(): HookCallback {
|
||||
return async (input: unknown) => {
|
||||
const preInput = input as PreToolUseHookInput;
|
||||
const command = (preInput.tool_input as { command?: string })?.command;
|
||||
if (!command) return {};
|
||||
|
||||
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
updatedInput: {
|
||||
...(preInput.tool_input as Record<string, unknown>),
|
||||
command: unsetPrefix + command,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class ClaudeSdkEngine implements AgentEngine {
|
||||
readonly name = 'claude-sdk';
|
||||
|
||||
async runTurn(input: EngineRunInput): Promise<EngineRunResult> {
|
||||
const stream = new MessageStream();
|
||||
stream.push(input.prompt);
|
||||
|
||||
let ipcPolling = true;
|
||||
let closedDuringTurn = false;
|
||||
const pollIpcDuringTurn = () => {
|
||||
if (!ipcPolling) return;
|
||||
if (input.shouldClose()) {
|
||||
input.log('Close sentinel detected during query, ending stream');
|
||||
closedDuringTurn = true;
|
||||
stream.end();
|
||||
ipcPolling = false;
|
||||
return;
|
||||
}
|
||||
const messages = input.drainIpcInput();
|
||||
for (const text of messages) {
|
||||
input.log(`Piping IPC message into active query (${text.length} chars)`);
|
||||
stream.push(text);
|
||||
}
|
||||
setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
|
||||
};
|
||||
setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
|
||||
|
||||
let newSessionId: string | undefined;
|
||||
let lastAssistantUuid: string | undefined;
|
||||
let messageCount = 0;
|
||||
let resultCount = 0;
|
||||
|
||||
const globalAgentMdPath = '/workspace/global/AGENT.md';
|
||||
let globalAgentMd: string | undefined;
|
||||
if (!input.containerInput.isMain && fs.existsSync(globalAgentMdPath)) {
|
||||
globalAgentMd = fs.readFileSync(globalAgentMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
const extraDirs: string[] = [];
|
||||
const extraBase = '/workspace/extra';
|
||||
if (fs.existsSync(extraBase)) {
|
||||
for (const entry of fs.readdirSync(extraBase)) {
|
||||
const fullPath = path.join(extraBase, entry);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
extraDirs.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraDirs.length > 0) {
|
||||
input.log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||
}
|
||||
|
||||
for await (const message of query({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||
resume: input.sessionId,
|
||||
resumeSessionAt: input.resumeAt,
|
||||
systemPrompt: globalAgentMd
|
||||
? {
|
||||
type: 'preset' as const,
|
||||
preset: 'claude_code' as const,
|
||||
append: globalAgentMd,
|
||||
}
|
||||
: undefined,
|
||||
allowedTools: [
|
||||
'Bash',
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'Task',
|
||||
'TaskOutput',
|
||||
'TaskStop',
|
||||
'TeamCreate',
|
||||
'TeamDelete',
|
||||
'SendMessage',
|
||||
'TodoWrite',
|
||||
'ToolSearch',
|
||||
'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*',
|
||||
],
|
||||
env: input.sdkEnv,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
mcpServers: {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: [input.mcpServerPath],
|
||||
env: {
|
||||
NANOCLAW_CHAT_JID: input.containerInput.chatJid,
|
||||
NANOCLAW_GROUP_FOLDER: input.containerInput.groupFolder,
|
||||
NANOCLAW_IS_MAIN: input.containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [
|
||||
{
|
||||
hooks: [
|
||||
createPreCompactHook(
|
||||
input.containerInput.assistantName,
|
||||
input.log,
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||
},
|
||||
},
|
||||
})) {
|
||||
messageCount++;
|
||||
const msgType =
|
||||
message.type === 'system'
|
||||
? `system/${(message as { subtype?: string }).subtype}`
|
||||
: message.type;
|
||||
input.log(`[msg #${messageCount}] type=${msgType}`);
|
||||
|
||||
if (message.type === 'assistant' && 'uuid' in message) {
|
||||
lastAssistantUuid = (message as { uuid: string }).uuid;
|
||||
}
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
newSessionId = message.session_id;
|
||||
input.log(`Session initialized: ${newSessionId}`);
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === 'system' &&
|
||||
(message as { subtype?: string }).subtype === 'task_notification'
|
||||
) {
|
||||
const tn = message as {
|
||||
task_id: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
};
|
||||
input.log(
|
||||
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultCount++;
|
||||
const textResult =
|
||||
'result' in message ? (message as { result?: string }).result : null;
|
||||
input.log(
|
||||
`Result #${resultCount}: subtype=${message.subtype}${
|
||||
textResult ? ` text=${textResult.slice(0, 200)}` : ''
|
||||
}`,
|
||||
);
|
||||
input.emitOutput({
|
||||
status: 'success',
|
||||
result: textResult || null,
|
||||
newSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ipcPolling = false;
|
||||
input.log(
|
||||
`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${
|
||||
lastAssistantUuid || 'none'
|
||||
}, closedDuringQuery: ${closedDuringTurn}`,
|
||||
);
|
||||
return { newSessionId, lastAssistantUuid, closedDuringTurn };
|
||||
}
|
||||
}
|
||||
18
container/agent-runner/src/engines/index.ts
Normal file
18
container/agent-runner/src/engines/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { AgentEngine } from './types.js';
|
||||
import { ClaudeSdkEngine } from './claude-sdk.js';
|
||||
import { PiTuiEngine } from './pi-tui.js';
|
||||
|
||||
export function createAgentEngine(): AgentEngine {
|
||||
const engine = (process.env.AGENT_ENGINE || 'pi-tui').toLowerCase();
|
||||
|
||||
switch (engine) {
|
||||
case 'pi':
|
||||
case 'pi-tui':
|
||||
case 'coding-agent':
|
||||
case 'pi-coding-agent':
|
||||
return new PiTuiEngine();
|
||||
case 'claude':
|
||||
default:
|
||||
return new ClaudeSdkEngine();
|
||||
}
|
||||
}
|
||||
107
container/agent-runner/src/engines/pi-tui.ts
Normal file
107
container/agent-runner/src/engines/pi-tui.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { spawn } from 'child_process';
|
||||
|
||||
import { AgentEngine, EngineRunInput, EngineRunResult } from './types.js';
|
||||
|
||||
const DEFAULT_PI_TUI_BIN = 'pi';
|
||||
const DEFAULT_PI_SESSION_DIR = '/home/node/.agent/pi';
|
||||
const PI_SESSION_SENTINEL = 'pi-session';
|
||||
|
||||
export class PiTuiEngine implements AgentEngine {
|
||||
readonly name = 'pi-tui';
|
||||
|
||||
async runTurn(input: EngineRunInput): Promise<EngineRunResult> {
|
||||
if (input.shouldClose()) {
|
||||
input.log('Close sentinel detected before pi-tui turn started');
|
||||
return { closedDuringTurn: true };
|
||||
}
|
||||
|
||||
const binary = process.env.PI_TUI_BIN || DEFAULT_PI_TUI_BIN;
|
||||
const args = this.buildArgs(input);
|
||||
|
||||
input.log(`Starting pi-tui turn with ${binary}`);
|
||||
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const proc = spawn(binary, args, {
|
||||
cwd: '/workspace/group',
|
||||
env: {
|
||||
...process.env,
|
||||
...input.sdkEnv,
|
||||
PI_CODING_AGENT_DIR:
|
||||
process.env.PI_CODING_AGENT_DIR || DEFAULT_PI_SESSION_DIR,
|
||||
PI_TUI_GROUP_FOLDER: input.containerInput.groupFolder,
|
||||
PI_TUI_CHAT_JID: input.containerInput.chatJid,
|
||||
PI_TUI_IS_MAIN: input.containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
return;
|
||||
}
|
||||
reject(
|
||||
new Error(
|
||||
`${binary} exited with code ${code}: ${stderr.trim() || 'no stderr'}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const sessionId = input.sessionId || PI_SESSION_SENTINEL;
|
||||
|
||||
input.emitOutput({
|
||||
status: 'success',
|
||||
result,
|
||||
newSessionId: sessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
newSessionId: sessionId,
|
||||
lastAssistantUuid: input.resumeAt,
|
||||
closedDuringTurn: false,
|
||||
};
|
||||
}
|
||||
|
||||
private buildArgs(input: EngineRunInput): string[] {
|
||||
const args = ['-p', '--mode', 'text'];
|
||||
const provider = process.env.PI_TUI_PROVIDER;
|
||||
if (provider) {
|
||||
args.push('--provider', provider);
|
||||
}
|
||||
const model = process.env.PI_TUI_MODEL || process.env.CLAUDE_MODEL;
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
if (input.sessionId) {
|
||||
args.push('--continue');
|
||||
}
|
||||
args.push(
|
||||
'--session-dir',
|
||||
process.env.PI_CODING_AGENT_DIR || DEFAULT_PI_SESSION_DIR,
|
||||
);
|
||||
if (input.containerInput.assistantName) {
|
||||
args.push(
|
||||
'--append-system-prompt',
|
||||
`Assistant name: ${input.containerInput.assistantName}`,
|
||||
);
|
||||
}
|
||||
args.push(input.prompt);
|
||||
return args;
|
||||
}
|
||||
}
|
||||
41
container/agent-runner/src/engines/types.ts
Normal file
41
container/agent-runner/src/engines/types.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface EngineRunInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
resumeAt?: string;
|
||||
containerInput: ContainerInput;
|
||||
sdkEnv: Record<string, string | undefined>;
|
||||
mcpServerPath: string;
|
||||
shouldClose: () => boolean;
|
||||
drainIpcInput: () => string[];
|
||||
emitOutput: (output: ContainerOutput) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface EngineRunResult {
|
||||
newSessionId?: string;
|
||||
lastAssistantUuid?: string;
|
||||
closedDuringTurn: boolean;
|
||||
}
|
||||
|
||||
export interface AgentEngine {
|
||||
readonly name: string;
|
||||
runTurn(input: EngineRunInput): Promise<EngineRunResult>;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* NanoClaw Agent Runner
|
||||
* Clawdie Agent Runner
|
||||
* Runs inside a container, receives config via stdin, outputs result to stdout
|
||||
*
|
||||
* Input protocol:
|
||||
|
|
@ -16,85 +16,14 @@
|
|||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
sessionId: string;
|
||||
fullPath: string;
|
||||
summary: string;
|
||||
firstPrompt: string;
|
||||
}
|
||||
|
||||
interface SessionsIndex {
|
||||
entries: SessionEntry[];
|
||||
}
|
||||
|
||||
interface SDKUserMessage {
|
||||
type: 'user';
|
||||
message: { role: 'user'; content: string };
|
||||
parent_tool_use_id: null;
|
||||
session_id: string;
|
||||
}
|
||||
import { createAgentEngine } from './engines/index.js';
|
||||
import { ContainerInput, ContainerOutput } from './engines/types.js';
|
||||
|
||||
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||
const IPC_POLL_MS = 500;
|
||||
|
||||
/**
|
||||
* Push-based async iterable for streaming user messages to the SDK.
|
||||
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
|
||||
*/
|
||||
class MessageStream {
|
||||
private queue: SDKUserMessage[] = [];
|
||||
private waiting: (() => void) | null = null;
|
||||
private done = false;
|
||||
|
||||
push(text: string): void {
|
||||
this.queue.push({
|
||||
type: 'user',
|
||||
message: { role: 'user', content: text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: '',
|
||||
});
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.done = true;
|
||||
this.waiting?.();
|
||||
}
|
||||
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
||||
while (true) {
|
||||
while (this.queue.length > 0) {
|
||||
yield this.queue.shift()!;
|
||||
}
|
||||
if (this.done) return;
|
||||
await new Promise<void>(r => { this.waiting = r; });
|
||||
this.waiting = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
|
|
@ -118,171 +47,6 @@ function log(message: string): void {
|
|||
console.error(`[agent-runner] ${message}`);
|
||||
}
|
||||
|
||||
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
|
||||
const projectDir = path.dirname(transcriptPath);
|
||||
const indexPath = path.join(projectDir, 'sessions-index.json');
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
log(`Sessions index not found at ${indexPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
const entry = index.entries.find(e => e.sessionId === sessionId);
|
||||
if (entry?.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive the full transcript to conversations/ before compaction.
|
||||
*/
|
||||
function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
const transcriptPath = preCompact.transcript_path;
|
||||
const sessionId = preCompact.session_id;
|
||||
|
||||
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) {
|
||||
log('No messages to archive');
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary = getSessionSummary(sessionId, transcriptPath);
|
||||
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
||||
|
||||
const conversationsDir = '/workspace/group/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
const filename = `${date}-${name}.md`;
|
||||
const filePath = path.join(conversationsDir, filename);
|
||||
|
||||
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
|
||||
fs.writeFileSync(filePath, markdown);
|
||||
|
||||
log(`Archived conversation to ${filePath}`);
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
// Secrets to strip from Bash tool subprocess environments.
|
||||
// These are needed by claude-code for API auth but should never
|
||||
// be visible to commands Kit runs.
|
||||
const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
function createSanitizeBashHook(): HookCallback {
|
||||
return async (input, _toolUseId, _context) => {
|
||||
const preInput = input as PreToolUseHookInput;
|
||||
const command = (preInput.tool_input as { command?: string })?.command;
|
||||
if (!command) return {};
|
||||
|
||||
const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
updatedInput: {
|
||||
...(preInput.tool_input as Record<string, unknown>),
|
||||
command: unsetPrefix + command,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeFilename(summary: string): string {
|
||||
return summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
}
|
||||
|
||||
function generateFallbackName(): string {
|
||||
const time = new Date();
|
||||
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface ParsedMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
function parseTranscript(content: string): ParsedMessage[] {
|
||||
const messages: ParsedMessage[] = [];
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'user' && entry.message?.content) {
|
||||
const text = typeof entry.message.content === 'string'
|
||||
? entry.message.content
|
||||
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
|
||||
if (text) messages.push({ role: 'user', content: text });
|
||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||
const textParts = entry.message.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text);
|
||||
const text = textParts.join('');
|
||||
if (text) messages.push({ role: 'assistant', content: text });
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
|
||||
const now = new Date();
|
||||
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${title || 'Conversation'}`);
|
||||
lines.push('');
|
||||
lines.push(`Archived: ${formatDateTime(now)}`);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
for (const msg of messages) {
|
||||
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
|
||||
const content = msg.content.length > 2000
|
||||
? msg.content.slice(0, 2000) + '...'
|
||||
: msg.content;
|
||||
lines.push(`**${sender}**: ${content}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for _close sentinel.
|
||||
*/
|
||||
|
|
@ -348,148 +112,6 @@ function waitForIpcMessage(): Promise<string | null> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single query and stream results via writeOutput.
|
||||
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
|
||||
* allowing agent teams subagents to run to completion.
|
||||
* Also pipes IPC messages into the stream during the query.
|
||||
*/
|
||||
async function runQuery(
|
||||
prompt: string,
|
||||
sessionId: string | undefined,
|
||||
mcpServerPath: string,
|
||||
containerInput: ContainerInput,
|
||||
sdkEnv: Record<string, string | undefined>,
|
||||
resumeAt?: string,
|
||||
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
|
||||
const stream = new MessageStream();
|
||||
stream.push(prompt);
|
||||
|
||||
// Poll IPC for follow-up messages and _close sentinel during the query
|
||||
let ipcPolling = true;
|
||||
let closedDuringQuery = false;
|
||||
const pollIpcDuringQuery = () => {
|
||||
if (!ipcPolling) return;
|
||||
if (shouldClose()) {
|
||||
log('Close sentinel detected during query, ending stream');
|
||||
closedDuringQuery = true;
|
||||
stream.end();
|
||||
ipcPolling = false;
|
||||
return;
|
||||
}
|
||||
const messages = drainIpcInput();
|
||||
for (const text of messages) {
|
||||
log(`Piping IPC message into active query (${text.length} chars)`);
|
||||
stream.push(text);
|
||||
}
|
||||
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
||||
};
|
||||
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
||||
|
||||
let newSessionId: string | undefined;
|
||||
let lastAssistantUuid: string | undefined;
|
||||
let messageCount = 0;
|
||||
let resultCount = 0;
|
||||
|
||||
// Load global AGENT.md as additional system context (shared across all groups)
|
||||
const globalClaudeMdPath = '/workspace/global/AGENT.md';
|
||||
let globalClaudeMd: string | undefined;
|
||||
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
|
||||
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
// Discover additional directories mounted at /workspace/extra/*
|
||||
// These are passed to the SDK so their AGENT.md files are loaded automatically
|
||||
const extraDirs: string[] = [];
|
||||
const extraBase = '/workspace/extra';
|
||||
if (fs.existsSync(extraBase)) {
|
||||
for (const entry of fs.readdirSync(extraBase)) {
|
||||
const fullPath = path.join(extraBase, entry);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
extraDirs.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extraDirs.length > 0) {
|
||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||
}
|
||||
|
||||
for await (const message of query({
|
||||
prompt: stream,
|
||||
options: {
|
||||
cwd: '/workspace/group',
|
||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||
resume: sessionId,
|
||||
resumeSessionAt: resumeAt,
|
||||
systemPrompt: globalClaudeMd
|
||||
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
|
||||
: undefined,
|
||||
allowedTools: [
|
||||
'Bash',
|
||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||
'WebSearch', 'WebFetch',
|
||||
'Task', 'TaskOutput', 'TaskStop',
|
||||
'TeamCreate', 'TeamDelete', 'SendMessage',
|
||||
'TodoWrite', 'ToolSearch', 'Skill',
|
||||
'NotebookEdit',
|
||||
'mcp__nanoclaw__*'
|
||||
],
|
||||
env: sdkEnv,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
mcpServers: {
|
||||
nanoclaw: {
|
||||
command: 'node',
|
||||
args: [mcpServerPath],
|
||||
env: {
|
||||
NANOCLAW_CHAT_JID: containerInput.chatJid,
|
||||
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
|
||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||
PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
|
||||
},
|
||||
}
|
||||
})) {
|
||||
messageCount++;
|
||||
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
|
||||
log(`[msg #${messageCount}] type=${msgType}`);
|
||||
|
||||
if (message.type === 'assistant' && 'uuid' in message) {
|
||||
lastAssistantUuid = (message as { uuid: string }).uuid;
|
||||
}
|
||||
|
||||
if (message.type === 'system' && message.subtype === 'init') {
|
||||
newSessionId = message.session_id;
|
||||
log(`Session initialized: ${newSessionId}`);
|
||||
}
|
||||
|
||||
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||
const tn = message as { task_id: string; status: string; summary: string };
|
||||
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
resultCount++;
|
||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
||||
writeOutput({
|
||||
status: 'success',
|
||||
result: textResult || null,
|
||||
newSessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ipcPolling = false;
|
||||
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
|
||||
return { newSessionId, lastAssistantUuid, closedDuringQuery };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let containerInput: ContainerInput;
|
||||
|
||||
|
|
@ -517,6 +139,7 @@ async function main(): Promise<void> {
|
|||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
||||
const engine = createAgentEngine();
|
||||
|
||||
let sessionId = containerInput.sessionId;
|
||||
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
||||
|
|
@ -541,18 +164,29 @@ async function main(): Promise<void> {
|
|||
while (true) {
|
||||
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
|
||||
|
||||
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
|
||||
if (queryResult.newSessionId) {
|
||||
sessionId = queryResult.newSessionId;
|
||||
const turnResult = await engine.runTurn({
|
||||
prompt,
|
||||
sessionId,
|
||||
resumeAt,
|
||||
containerInput,
|
||||
sdkEnv,
|
||||
mcpServerPath,
|
||||
shouldClose,
|
||||
drainIpcInput,
|
||||
emitOutput: writeOutput,
|
||||
log,
|
||||
});
|
||||
if (turnResult.newSessionId) {
|
||||
sessionId = turnResult.newSessionId;
|
||||
}
|
||||
if (queryResult.lastAssistantUuid) {
|
||||
resumeAt = queryResult.lastAssistantUuid;
|
||||
if (turnResult.lastAssistantUuid) {
|
||||
resumeAt = turnResult.lastAssistantUuid;
|
||||
}
|
||||
|
||||
// If _close was consumed during the query, exit immediately.
|
||||
// Don't emit a session-update marker (it would reset the host's
|
||||
// idle timer and cause a 30-min delay before the next _close).
|
||||
if (queryResult.closedDuringQuery) {
|
||||
if (turnResult.closedDuringTurn) {
|
||||
log('Close sentinel consumed during query, exiting');
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,21 +77,36 @@ describe('credentials detection', () => {
|
|||
const content =
|
||||
'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENROUTER_API_KEY)=/m.test(
|
||||
content,
|
||||
);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => {
|
||||
const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENROUTER_API_KEY)=/m.test(
|
||||
content,
|
||||
);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('detects OPENROUTER_API_KEY in env content', () => {
|
||||
const content = 'OPENROUTER_API_KEY=sk-or-test123';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENROUTER_API_KEY)=/m.test(
|
||||
content,
|
||||
);
|
||||
expect(hasCredentials).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no credentials', () => {
|
||||
const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo';
|
||||
const hasCredentials =
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content);
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENROUTER_API_KEY)=/m.test(
|
||||
content,
|
||||
);
|
||||
expect(hasCredentials).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,14 +49,14 @@ export async function run(_args: string[]): Promise<void> {
|
|||
} else if (mgr === 'systemd') {
|
||||
const prefix = isRoot() ? 'systemctl' : 'systemctl --user';
|
||||
try {
|
||||
execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' });
|
||||
execSync(`${prefix} is-active clawdie`, { stdio: 'ignore' });
|
||||
service = 'running';
|
||||
} catch {
|
||||
try {
|
||||
const output = execSync(`${prefix} list-unit-files`, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (output.includes('clawdie-cp')) {
|
||||
if (output.includes('clawdie.service')) {
|
||||
service = 'stopped';
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -65,7 +65,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||
}
|
||||
} else {
|
||||
// Check for nohup PID file
|
||||
const pidFile = path.join(projectRoot, 'nanoclaw.pid');
|
||||
const pidFile = path.join(projectRoot, 'clawdie.pid');
|
||||
if (fs.existsSync(pidFile)) {
|
||||
try {
|
||||
const pid = fs.readFileSync(pidFile, 'utf-8').trim();
|
||||
|
|
@ -99,7 +99,11 @@ export async function run(_args: string[]): Promise<void> {
|
|||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
const envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) {
|
||||
if (
|
||||
/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_OAUTH_TOKEN|OPENAI_API_KEY|AZURE_OPENAI_API_KEY|GEMINI_API_KEY|GROQ_API_KEY|CEREBRAS_API_KEY|XAI_API_KEY|OPENROUTER_API_KEY|AI_GATEWAY_API_KEY|ZAI_API_KEY|MISTRAL_API_KEY|MINIMAX_API_KEY|OPENCODE_API_KEY|KIMI_API_KEY|AWS_BEARER_TOKEN_BEDROCK|AWS_ACCESS_KEY_ID)=/m.test(
|
||||
envContent,
|
||||
)
|
||||
) {
|
||||
credentials = 'configured';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,28 @@ import { readEnvFile } from './env.js';
|
|||
// Read config values from .env (falls back to process.env).
|
||||
// Secrets are NOT read here — they stay on disk and are loaded only
|
||||
// where needed (jail-runner.ts) to avoid leaking to child processes.
|
||||
const envConfig = readEnvFile(['ASSISTANT_NAME', 'TELEGRAM_BOT_TOKEN']);
|
||||
const envConfig = readEnvFile([
|
||||
'ASSISTANT_NAME',
|
||||
'TELEGRAM_BOT_TOKEN',
|
||||
'AGENT_ENGINE',
|
||||
'PI_TUI_BIN',
|
||||
'PI_TUI_PROVIDER',
|
||||
'PI_TUI_MODEL',
|
||||
]);
|
||||
|
||||
export const ASSISTANT_NAME =
|
||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||
export const TELEGRAM_BOT_TOKEN =
|
||||
process.env.TELEGRAM_BOT_TOKEN || envConfig.TELEGRAM_BOT_TOKEN || '';
|
||||
export const AGENT_ENGINE =
|
||||
process.env.AGENT_ENGINE || envConfig.AGENT_ENGINE || 'pi-tui';
|
||||
export const PI_TUI_BIN = process.env.PI_TUI_BIN || envConfig.PI_TUI_BIN || 'pi';
|
||||
export const PI_TUI_PROVIDER =
|
||||
process.env.PI_TUI_PROVIDER || envConfig.PI_TUI_PROVIDER || 'openrouter';
|
||||
export const PI_TUI_MODEL =
|
||||
process.env.PI_TUI_MODEL ||
|
||||
envConfig.PI_TUI_MODEL ||
|
||||
'anthropic/claude-3.5-sonnet';
|
||||
export const POLL_INTERVAL = 2000;
|
||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,15 @@ const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
|||
|
||||
// Mock config
|
||||
vi.mock('./config.js', () => ({
|
||||
AGENT_ENGINE: 'claude',
|
||||
JAIL_MAX_OUTPUT_SIZE: 10485760,
|
||||
JAIL_TIMEOUT: 1800000, // 30min
|
||||
DATA_DIR: '/tmp/clawdie-cp-test-data',
|
||||
GROUPS_DIR: '/tmp/clawdie-cp-test-groups',
|
||||
IDLE_TIMEOUT: 1800000, // 30min
|
||||
PI_TUI_BIN: '',
|
||||
PI_TUI_PROVIDER: '',
|
||||
PI_TUI_MODEL: '',
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
|
|
|
|||
|
|
@ -7,11 +7,15 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
|
||||
import {
|
||||
AGENT_ENGINE,
|
||||
JAIL_MAX_OUTPUT_SIZE,
|
||||
JAIL_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
PI_TUI_BIN,
|
||||
PI_TUI_PROVIDER,
|
||||
PI_TUI_MODEL,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
|
|
@ -204,7 +208,29 @@ function buildJailMounts(group: RegisteredGroup, isMain: boolean): JailMount[] {
|
|||
* Secrets are never written to disk or mounted as files.
|
||||
*/
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
|
||||
return readEnvFile([
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_OAUTH_TOKEN',
|
||||
'OPENAI_API_KEY',
|
||||
'AZURE_OPENAI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GROQ_API_KEY',
|
||||
'CEREBRAS_API_KEY',
|
||||
'XAI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'AI_GATEWAY_API_KEY',
|
||||
'ZAI_API_KEY',
|
||||
'MISTRAL_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'OPENCODE_API_KEY',
|
||||
'KIMI_API_KEY',
|
||||
'AWS_PROFILE',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_BEARER_TOKEN_BEDROCK',
|
||||
'AWS_REGION',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Returns jexec args to run the agent inside the jail. */
|
||||
|
|
@ -255,6 +281,13 @@ export async function runJailAgent(
|
|||
|
||||
return new Promise((resolve) => {
|
||||
const jailProc = spawn('jexec', jailArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
AGENT_ENGINE,
|
||||
...(PI_TUI_BIN ? { PI_TUI_BIN } : {}),
|
||||
...(PI_TUI_PROVIDER ? { PI_TUI_PROVIDER } : {}),
|
||||
...(PI_TUI_MODEL ? { PI_TUI_MODEL } : {}),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue