@rozek/nanoclaw 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.json +1 -0
- package/.claude/skills/add-compact/SKILL.md +135 -0
- package/.claude/skills/add-discord/SKILL.md +203 -0
- package/.claude/skills/add-gmail/SKILL.md +220 -0
- package/.claude/skills/add-image-vision/SKILL.md +94 -0
- package/.claude/skills/add-ollama-tool/SKILL.md +153 -0
- package/.claude/skills/add-parallel/SKILL.md +290 -0
- package/.claude/skills/add-pdf-reader/SKILL.md +104 -0
- package/.claude/skills/add-reactions/SKILL.md +117 -0
- package/.claude/skills/add-slack/SKILL.md +207 -0
- package/.claude/skills/add-telegram/SKILL.md +222 -0
- package/.claude/skills/add-telegram-swarm/SKILL.md +384 -0
- package/.claude/skills/add-voice-transcription/SKILL.md +148 -0
- package/.claude/skills/add-whatsapp/SKILL.md +372 -0
- package/.claude/skills/convert-to-apple-container/SKILL.md +175 -0
- package/.claude/skills/customize/SKILL.md +110 -0
- package/.claude/skills/debug/SKILL.md +349 -0
- package/.claude/skills/get-qodo-rules/SKILL.md +122 -0
- package/.claude/skills/get-qodo-rules/references/output-format.md +41 -0
- package/.claude/skills/get-qodo-rules/references/pagination.md +33 -0
- package/.claude/skills/get-qodo-rules/references/repository-scope.md +26 -0
- package/.claude/skills/qodo-pr-resolver/SKILL.md +326 -0
- package/.claude/skills/qodo-pr-resolver/resources/providers.md +329 -0
- package/.claude/skills/setup/SKILL.md +218 -0
- package/.claude/skills/update-nanoclaw/SKILL.md +235 -0
- package/.claude/skills/update-skills/SKILL.md +130 -0
- package/.claude/skills/use-local-whisper/SKILL.md +152 -0
- package/.claude/skills/x-integration/SKILL.md +417 -0
- package/.claude/skills/x-integration/agent.ts +243 -0
- package/.claude/skills/x-integration/host.ts +159 -0
- package/.claude/skills/x-integration/lib/browser.ts +148 -0
- package/.claude/skills/x-integration/lib/config.ts +62 -0
- package/.claude/skills/x-integration/scripts/like.ts +56 -0
- package/.claude/skills/x-integration/scripts/post.ts +66 -0
- package/.claude/skills/x-integration/scripts/quote.ts +80 -0
- package/.claude/skills/x-integration/scripts/reply.ts +74 -0
- package/.claude/skills/x-integration/scripts/retweet.ts +62 -0
- package/.claude/skills/x-integration/scripts/setup.ts +87 -0
- package/.env.example +1 -0
- package/.github/CODEOWNERS +10 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/workflows/bump-version.yml +32 -0
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/merge-forward-skills.yml +160 -0
- package/.github/workflows/update-tokens.yml +42 -0
- package/.husky/pre-commit +1 -0
- package/.mcp.json +3 -0
- package/.nvmrc +1 -0
- package/.prettierrc +3 -0
- package/CHANGELOG.md +8 -0
- package/CLAUDE.md +64 -0
- package/CONTRIBUTING.md +23 -0
- package/CONTRIBUTORS.md +15 -0
- package/LICENSE +21 -0
- package/NanoClaw_with_Web-Support.md +325 -0
- package/README.md +261 -0
- package/README_zh.md +200 -0
- package/assets/nanoclaw-favicon.png +0 -0
- package/assets/nanoclaw-icon.png +0 -0
- package/assets/nanoclaw-logo-dark.png +0 -0
- package/assets/nanoclaw-logo.png +0 -0
- package/assets/nanoclaw-profile.jpeg +0 -0
- package/assets/nanoclaw-sales.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/config-examples/mount-allowlist.json +25 -0
- package/container/Dockerfile +70 -0
- package/container/agent-runner/package.json +21 -0
- package/container/agent-runner/src/index.ts +774 -0
- package/container/agent-runner/src/ipc-mcp-stdio.ts +338 -0
- package/container/agent-runner/tsconfig.json +15 -0
- package/container/build.sh +23 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/capabilities/SKILL.md +100 -0
- package/container/skills/cwd/SKILL.md +32 -0
- package/container/skills/pwd/SKILL.md +19 -0
- package/container/skills/status/SKILL.md +104 -0
- package/dist/channels/index.d.ts +2 -0
- package/dist/channels/index.d.ts.map +1 -0
- package/dist/channels/index.js +10 -0
- package/dist/channels/index.js.map +1 -0
- package/dist/channels/registry.d.ts +13 -0
- package/dist/channels/registry.d.ts.map +1 -0
- package/dist/channels/registry.js +11 -0
- package/dist/channels/registry.js.map +1 -0
- package/dist/channels/registry.test.d.ts +2 -0
- package/dist/channels/registry.test.d.ts.map +1 -0
- package/dist/channels/registry.test.js +32 -0
- package/dist/channels/registry.test.js.map +1 -0
- package/dist/channels/web.d.ts +2 -0
- package/dist/channels/web.d.ts.map +1 -0
- package/dist/channels/web.js +1843 -0
- package/dist/channels/web.js.map +1 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +182 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -0
- package/dist/container-runner.d.ts +44 -0
- package/dist/container-runner.d.ts.map +1 -0
- package/dist/container-runner.js +511 -0
- package/dist/container-runner.js.map +1 -0
- package/dist/container-runner.test.d.ts +2 -0
- package/dist/container-runner.test.d.ts.map +1 -0
- package/dist/container-runner.test.js +150 -0
- package/dist/container-runner.test.js.map +1 -0
- package/dist/container-runtime.d.ts +22 -0
- package/dist/container-runtime.d.ts.map +1 -0
- package/dist/container-runtime.js +96 -0
- package/dist/container-runtime.js.map +1 -0
- package/dist/container-runtime.test.d.ts +2 -0
- package/dist/container-runtime.test.d.ts.map +1 -0
- package/dist/container-runtime.test.js +93 -0
- package/dist/container-runtime.test.js.map +1 -0
- package/dist/credential-proxy.d.ts +21 -0
- package/dist/credential-proxy.d.ts.map +1 -0
- package/dist/credential-proxy.js +95 -0
- package/dist/credential-proxy.js.map +1 -0
- package/dist/credential-proxy.test.d.ts +2 -0
- package/dist/credential-proxy.test.d.ts.map +1 -0
- package/dist/credential-proxy.test.js +134 -0
- package/dist/credential-proxy.test.js.map +1 -0
- package/dist/db.d.ts +115 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +549 -0
- package/dist/db.js.map +1 -0
- package/dist/db.test.d.ts +2 -0
- package/dist/db.test.d.ts.map +1 -0
- package/dist/db.test.js +360 -0
- package/dist/db.test.js.map +1 -0
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +42 -0
- package/dist/env.js.map +1 -0
- package/dist/formatting.test.d.ts +2 -0
- package/dist/formatting.test.d.ts.map +1 -0
- package/dist/formatting.test.js +183 -0
- package/dist/formatting.test.js.map +1 -0
- package/dist/group-folder.d.ts +5 -0
- package/dist/group-folder.d.ts.map +1 -0
- package/dist/group-folder.js +44 -0
- package/dist/group-folder.js.map +1 -0
- package/dist/group-folder.test.d.ts +2 -0
- package/dist/group-folder.test.d.ts.map +1 -0
- package/dist/group-folder.test.js +29 -0
- package/dist/group-folder.test.js.map +1 -0
- package/dist/group-queue.d.ts +40 -0
- package/dist/group-queue.d.ts.map +1 -0
- package/dist/group-queue.js +276 -0
- package/dist/group-queue.js.map +1 -0
- package/dist/group-queue.test.d.ts +2 -0
- package/dist/group-queue.test.d.ts.map +1 -0
- package/dist/group-queue.test.js +341 -0
- package/dist/group-queue.test.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +592 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc-auth.test.d.ts +2 -0
- package/dist/ipc-auth.test.d.ts.map +1 -0
- package/dist/ipc-auth.test.js +434 -0
- package/dist/ipc-auth.test.js.map +1 -0
- package/dist/ipc.d.ts +32 -0
- package/dist/ipc.d.ts.map +1 -0
- package/dist/ipc.js +311 -0
- package/dist/ipc.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -0
- package/dist/mount-security.d.ts +34 -0
- package/dist/mount-security.d.ts.map +1 -0
- package/dist/mount-security.js +325 -0
- package/dist/mount-security.js.map +1 -0
- package/dist/remote-control.d.ts +32 -0
- package/dist/remote-control.d.ts.map +1 -0
- package/dist/remote-control.js +185 -0
- package/dist/remote-control.js.map +1 -0
- package/dist/remote-control.test.d.ts +2 -0
- package/dist/remote-control.test.d.ts.map +1 -0
- package/dist/remote-control.test.js +321 -0
- package/dist/remote-control.test.js.map +1 -0
- package/dist/router.d.ts +8 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +37 -0
- package/dist/router.js.map +1 -0
- package/dist/routing.test.d.ts +2 -0
- package/dist/routing.test.d.ts.map +1 -0
- package/dist/routing.test.js +81 -0
- package/dist/routing.test.js.map +1 -0
- package/dist/sender-allowlist.d.ts +14 -0
- package/dist/sender-allowlist.d.ts.map +1 -0
- package/dist/sender-allowlist.js +79 -0
- package/dist/sender-allowlist.js.map +1 -0
- package/dist/sender-allowlist.test.d.ts +2 -0
- package/dist/sender-allowlist.test.d.ts.map +1 -0
- package/dist/sender-allowlist.test.js +186 -0
- package/dist/sender-allowlist.test.js.map +1 -0
- package/dist/session-commands.d.ts +47 -0
- package/dist/session-commands.d.ts.map +1 -0
- package/dist/session-commands.js +104 -0
- package/dist/session-commands.js.map +1 -0
- package/dist/session-commands.test.d.ts +2 -0
- package/dist/session-commands.test.d.ts.map +1 -0
- package/dist/session-commands.test.js +194 -0
- package/dist/session-commands.test.js.map +1 -0
- package/dist/task-scheduler.d.ts +22 -0
- package/dist/task-scheduler.d.ts.map +1 -0
- package/dist/task-scheduler.js +241 -0
- package/dist/task-scheduler.js.map +1 -0
- package/dist/task-scheduler.test.d.ts +2 -0
- package/dist/task-scheduler.test.d.ts.map +1 -0
- package/dist/task-scheduler.test.js +107 -0
- package/dist/task-scheduler.test.js.map +1 -0
- package/dist/timezone.d.ts +6 -0
- package/dist/timezone.d.ts.map +1 -0
- package/dist/timezone.js +17 -0
- package/dist/timezone.js.map +1 -0
- package/dist/timezone.test.d.ts +2 -0
- package/dist/timezone.test.d.ts.map +1 -0
- package/dist/timezone.test.js +23 -0
- package/dist/timezone.test.js.map +1 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/APPLE-CONTAINER-NETWORKING.md +90 -0
- package/docs/DEBUG_CHECKLIST.md +143 -0
- package/docs/REQUIREMENTS.md +196 -0
- package/docs/SDK_DEEP_DIVE.md +643 -0
- package/docs/SECURITY.md +122 -0
- package/docs/SPEC.md +785 -0
- package/docs/docker-sandboxes.md +359 -0
- package/docs/nanoclaw-architecture-final.md +1063 -0
- package/docs/nanorepo-architecture.md +168 -0
- package/docs/skills-as-branches.md +662 -0
- package/groups/global/CLAUDE.md +58 -0
- package/groups/main/CLAUDE.md +246 -0
- package/launchd/com.nanoclaw.plist +32 -0
- package/package.json +45 -0
- package/repo-tokens/README.md +113 -0
- package/repo-tokens/action.yml +186 -0
- package/repo-tokens/badge.svg +23 -0
- package/repo-tokens/examples/green.svg +14 -0
- package/repo-tokens/examples/red.svg +14 -0
- package/repo-tokens/examples/yellow-green.svg +14 -0
- package/repo-tokens/examples/yellow.svg +14 -0
- package/scripts/run-migrations.ts +105 -0
- package/setup/container.ts +144 -0
- package/setup/environment.test.ts +121 -0
- package/setup/environment.ts +94 -0
- package/setup/groups.ts +229 -0
- package/setup/index.ts +58 -0
- package/setup/mounts.ts +115 -0
- package/setup/platform.test.ts +120 -0
- package/setup/platform.ts +132 -0
- package/setup/register.test.ts +257 -0
- package/setup/register.ts +177 -0
- package/setup/service.test.ts +187 -0
- package/setup/service.ts +362 -0
- package/setup/status.ts +16 -0
- package/setup/verify.ts +192 -0
- package/setup.sh +161 -0
- package/src/channels/index.ts +15 -0
- package/src/channels/registry.test.ts +42 -0
- package/src/channels/registry.ts +32 -0
- package/src/channels/web.ts +1931 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +73 -0
- package/src/container-runner.test.ts +210 -0
- package/src/container-runner.ts +768 -0
- package/src/container-runtime.test.ts +149 -0
- package/src/container-runtime.ts +127 -0
- package/src/credential-proxy.test.ts +192 -0
- package/src/credential-proxy.ts +125 -0
- package/src/db.test.ts +484 -0
- package/src/db.ts +803 -0
- package/src/env.ts +42 -0
- package/src/formatting.test.ts +256 -0
- package/src/group-folder.test.ts +43 -0
- package/src/group-folder.ts +44 -0
- package/src/group-queue.test.ts +484 -0
- package/src/group-queue.ts +379 -0
- package/src/index.ts +832 -0
- package/src/ipc-auth.test.ts +679 -0
- package/src/ipc.ts +461 -0
- package/src/logger.ts +16 -0
- package/src/mount-security.ts +419 -0
- package/src/remote-control.test.ts +397 -0
- package/src/remote-control.ts +224 -0
- package/src/router.ts +52 -0
- package/src/routing.test.ts +170 -0
- package/src/sender-allowlist.test.ts +216 -0
- package/src/sender-allowlist.ts +128 -0
- package/src/session-commands.test.ts +247 -0
- package/src/session-commands.ts +163 -0
- package/src/task-scheduler.test.ts +129 -0
- package/src/task-scheduler.ts +328 -0
- package/src/timezone.test.ts +29 -0
- package/src/timezone.ts +16 -0
- package/src/types.ts +109 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +7 -0
- package/vitest.skills.config.ts +7 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NanoClaw Agent Runner
|
|
3
|
+
* Runs inside a container, receives config via stdin, outputs result to stdout
|
|
4
|
+
*
|
|
5
|
+
* Input protocol:
|
|
6
|
+
* Stdin: Full ContainerInput JSON (read until EOF, like before)
|
|
7
|
+
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
|
|
8
|
+
* Files: {type:"message", text:"..."}.json — polled and consumed
|
|
9
|
+
* Sentinel: /workspace/ipc/input/_close — signals session end
|
|
10
|
+
*
|
|
11
|
+
* Stdout protocol:
|
|
12
|
+
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
|
13
|
+
* Multiple results may be emitted (one per agent teams result).
|
|
14
|
+
* Final marker after loop ends signals completion.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
|
|
22
|
+
interface ContainerInput {
|
|
23
|
+
prompt: string;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
groupFolder: string;
|
|
26
|
+
chatJid: string;
|
|
27
|
+
isMain: boolean;
|
|
28
|
+
isScheduledTask?: boolean;
|
|
29
|
+
assistantName?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ContainerOutput {
|
|
33
|
+
status: 'success' | 'error';
|
|
34
|
+
result: string | null;
|
|
35
|
+
newSessionId?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface SessionEntry {
|
|
40
|
+
sessionId: string;
|
|
41
|
+
fullPath: string;
|
|
42
|
+
summary: string;
|
|
43
|
+
firstPrompt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface SessionsIndex {
|
|
47
|
+
entries: SessionEntry[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SDKUserMessage {
|
|
51
|
+
type: 'user';
|
|
52
|
+
message: { role: 'user'; content: string };
|
|
53
|
+
parent_tool_use_id: null;
|
|
54
|
+
session_id: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const IPC_INPUT_DIR = '/workspace/ipc/input';
|
|
58
|
+
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
|
59
|
+
const IPC_POLL_MS = 500;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Push-based async iterable for streaming user messages to the SDK.
|
|
63
|
+
* Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
|
|
64
|
+
*/
|
|
65
|
+
class MessageStream {
|
|
66
|
+
private queue: SDKUserMessage[] = [];
|
|
67
|
+
private waiting: (() => void) | null = null;
|
|
68
|
+
private done = false;
|
|
69
|
+
|
|
70
|
+
push(text: string): void {
|
|
71
|
+
this.queue.push({
|
|
72
|
+
type: 'user',
|
|
73
|
+
message: { role: 'user', content: text },
|
|
74
|
+
parent_tool_use_id: null,
|
|
75
|
+
session_id: '',
|
|
76
|
+
});
|
|
77
|
+
this.waiting?.();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
end(): void {
|
|
81
|
+
this.done = true;
|
|
82
|
+
this.waiting?.();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
|
86
|
+
while (true) {
|
|
87
|
+
while (this.queue.length > 0) {
|
|
88
|
+
yield this.queue.shift()!;
|
|
89
|
+
}
|
|
90
|
+
if (this.done) return;
|
|
91
|
+
await new Promise<void>(r => { this.waiting = r; });
|
|
92
|
+
this.waiting = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function readStdin(): Promise<string> {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
let data = '';
|
|
100
|
+
process.stdin.setEncoding('utf8');
|
|
101
|
+
process.stdin.on('data', chunk => { data += chunk; });
|
|
102
|
+
process.stdin.on('end', () => resolve(data));
|
|
103
|
+
process.stdin.on('error', reject);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
|
108
|
+
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
|
109
|
+
const STATUS_START_MARKER = '---NANOCLAW_STATUS_START---';
|
|
110
|
+
const STATUS_END_MARKER = '---NANOCLAW_STATUS_END---';
|
|
111
|
+
|
|
112
|
+
function writeOutput(output: ContainerOutput): void {
|
|
113
|
+
console.log(OUTPUT_START_MARKER);
|
|
114
|
+
console.log(JSON.stringify(output));
|
|
115
|
+
console.log(OUTPUT_END_MARKER);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeStatus(tool: string, inputSnippet?: string): void {
|
|
119
|
+
try {
|
|
120
|
+
const payload: { tool: string; input?: string } = { tool };
|
|
121
|
+
if (inputSnippet) payload.input = inputSnippet.slice(0, 200);
|
|
122
|
+
console.log(STATUS_START_MARKER);
|
|
123
|
+
console.log(JSON.stringify(payload));
|
|
124
|
+
console.log(STATUS_END_MARKER);
|
|
125
|
+
} catch { /* non-critical */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Extract a short readable snippet from a tool's input object. */
|
|
129
|
+
function toolInputSnippet(input: unknown): string | undefined {
|
|
130
|
+
if (!input || typeof input !== 'object') return undefined;
|
|
131
|
+
const obj = input as Record<string, unknown>;
|
|
132
|
+
for (const key of ['command', 'prompt', 'query', 'url', 'path', 'file_path', 'pattern', 'text']) {
|
|
133
|
+
if (typeof obj[key] === 'string' && obj[key]) {
|
|
134
|
+
return (obj[key] as string).split('\n')[0].slice(0, 120);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const val of Object.values(obj)) {
|
|
138
|
+
if (typeof val === 'string' && val) return val.split('\n')[0].slice(0, 120);
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function log(message: string): void {
|
|
144
|
+
console.error(`[agent-runner] ${message}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// External MCP servers — loaded from /workspace/group/MCP-Servers/*.json
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
interface McpServerConfig {
|
|
152
|
+
command?: string;
|
|
153
|
+
args?: string[];
|
|
154
|
+
env?: Record<string, string>;
|
|
155
|
+
url?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Extract a human-readable message from an unknown thrown value. */
|
|
159
|
+
function getErrorMessage(err: unknown): string {
|
|
160
|
+
return err instanceof Error ? err.message : String(err);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Scan /workspace/group/MCP-Servers/ for *.json files.
|
|
165
|
+
* Each valid file becomes an additional MCP server in the next query.
|
|
166
|
+
* Invalid / unparseable files are logged and skipped.
|
|
167
|
+
*/
|
|
168
|
+
function loadExternalMcpServers(): Record<string, McpServerConfig> {
|
|
169
|
+
const serversDir = '/workspace/group/MCP-Servers';
|
|
170
|
+
const result: Record<string, McpServerConfig> = {};
|
|
171
|
+
|
|
172
|
+
let files: string[];
|
|
173
|
+
try {
|
|
174
|
+
files = fs.readdirSync(serversDir).filter(f => f.endsWith('.json'));
|
|
175
|
+
} catch {
|
|
176
|
+
return result; // directory doesn't exist or isn't readable
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
const filePath = path.join(serversDir, file);
|
|
181
|
+
try {
|
|
182
|
+
const config = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
183
|
+
if (typeof config !== 'object' || config === null) {
|
|
184
|
+
log(`MCP-Servers/${file}: not an object, skipping`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (!config.command && !config.url) {
|
|
188
|
+
log(`MCP-Servers/${file}: missing 'command' or 'url', skipping`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const name = path.basename(file, '.json');
|
|
192
|
+
result[name] = config;
|
|
193
|
+
log(`External MCP server loaded: ${name}`);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
log(`MCP-Servers/${file}: ${getErrorMessage(err)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Reserved directories — injected into every query's system prompt
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
const RESERVED_DIRS_DOC = `## Reserved Workspace Directories
|
|
207
|
+
|
|
208
|
+
Your group workspace at \`/workspace/group/\` contains these special directories:
|
|
209
|
+
|
|
210
|
+
- **\`Tools/\`** — Custom MCP tools. Each subdirectory with \`TOOL.md\` + \`TOOL.js\` is registered as a callable tool. Create new subdirectories here to extend your capabilities.
|
|
211
|
+
- **\`Skills/\`** — Skill definitions (\`.md\` files). Check this folder proactively and apply matching skills automatically when a user request fits.
|
|
212
|
+
- **\`MCP-Servers/\`** — External MCP server configs (\`.json\` files, each with \`command\` for stdio or \`url\` for HTTP/SSE). Changes are picked up on the next message.
|
|
213
|
+
- **\`conversations/\`** — Archived conversation history. Search here to recall context from previous sessions.`;
|
|
214
|
+
|
|
215
|
+
function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
|
|
216
|
+
const projectDir = path.dirname(transcriptPath);
|
|
217
|
+
const indexPath = path.join(projectDir, 'sessions-index.json');
|
|
218
|
+
|
|
219
|
+
if (!fs.existsSync(indexPath)) {
|
|
220
|
+
log(`Sessions index not found at ${indexPath}`);
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
226
|
+
const entry = index.entries.find(e => e.sessionId === sessionId);
|
|
227
|
+
if (entry?.summary) {
|
|
228
|
+
return entry.summary;
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
log(`Failed to read sessions index: ${getErrorMessage(err)}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Archive the full transcript to conversations/ before compaction.
|
|
239
|
+
*/
|
|
240
|
+
function createPreCompactHook(assistantName?: string): HookCallback {
|
|
241
|
+
return async (input, _toolUseId, _context) => {
|
|
242
|
+
const preCompact = input as PreCompactHookInput;
|
|
243
|
+
const transcriptPath = preCompact.transcript_path;
|
|
244
|
+
const sessionId = preCompact.session_id;
|
|
245
|
+
|
|
246
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
247
|
+
log('No transcript found for archiving');
|
|
248
|
+
return {};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
|
253
|
+
const messages = parseTranscript(content);
|
|
254
|
+
|
|
255
|
+
if (messages.length === 0) {
|
|
256
|
+
log('No messages to archive');
|
|
257
|
+
return {};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const summary = getSessionSummary(sessionId, transcriptPath);
|
|
261
|
+
const name = summary ? sanitizeFilename(summary) : generateFallbackName();
|
|
262
|
+
|
|
263
|
+
const conversationsDir = '/workspace/group/conversations';
|
|
264
|
+
fs.mkdirSync(conversationsDir, { recursive: true });
|
|
265
|
+
|
|
266
|
+
const date = new Date().toISOString().split('T')[0];
|
|
267
|
+
const filename = `${date}-${name}.md`;
|
|
268
|
+
const filePath = path.join(conversationsDir, filename);
|
|
269
|
+
|
|
270
|
+
const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
|
|
271
|
+
fs.writeFileSync(filePath, markdown);
|
|
272
|
+
|
|
273
|
+
log(`Archived conversation to ${filePath}`);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log(`Failed to archive transcript: ${getErrorMessage(err)}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return {};
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function sanitizeFilename(summary: string): string {
|
|
283
|
+
return summary
|
|
284
|
+
.toLowerCase()
|
|
285
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
286
|
+
.replace(/^-+|-+$/g, '')
|
|
287
|
+
.slice(0, 50);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function generateFallbackName(): string {
|
|
291
|
+
const time = new Date();
|
|
292
|
+
return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
interface ParsedMessage {
|
|
296
|
+
role: 'user' | 'assistant';
|
|
297
|
+
content: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseTranscript(content: string): ParsedMessage[] {
|
|
301
|
+
const messages: ParsedMessage[] = [];
|
|
302
|
+
|
|
303
|
+
for (const line of content.split('\n')) {
|
|
304
|
+
if (!line.trim()) continue;
|
|
305
|
+
try {
|
|
306
|
+
const entry = JSON.parse(line);
|
|
307
|
+
if (entry.type === 'user' && entry.message?.content) {
|
|
308
|
+
const text = typeof entry.message.content === 'string'
|
|
309
|
+
? entry.message.content
|
|
310
|
+
: entry.message.content.map((c: { text?: string }) => c.text || '').join('');
|
|
311
|
+
if (text) messages.push({ role: 'user', content: text });
|
|
312
|
+
} else if (entry.type === 'assistant' && entry.message?.content) {
|
|
313
|
+
const textParts = entry.message.content
|
|
314
|
+
.filter((c: { type: string }) => c.type === 'text')
|
|
315
|
+
.map((c: { text: string }) => c.text);
|
|
316
|
+
const text = textParts.join('');
|
|
317
|
+
if (text) messages.push({ role: 'assistant', content: text });
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return messages;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
|
|
327
|
+
const now = new Date();
|
|
328
|
+
const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
|
|
329
|
+
month: 'short',
|
|
330
|
+
day: 'numeric',
|
|
331
|
+
hour: 'numeric',
|
|
332
|
+
minute: '2-digit',
|
|
333
|
+
hour12: true
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const lines: string[] = [];
|
|
337
|
+
lines.push(`# ${title || 'Conversation'}`);
|
|
338
|
+
lines.push('');
|
|
339
|
+
lines.push(`Archived: ${formatDateTime(now)}`);
|
|
340
|
+
lines.push('');
|
|
341
|
+
lines.push('---');
|
|
342
|
+
lines.push('');
|
|
343
|
+
|
|
344
|
+
for (const msg of messages) {
|
|
345
|
+
const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
|
|
346
|
+
const content = msg.content.length > 2000
|
|
347
|
+
? msg.content.slice(0, 2000) + '...'
|
|
348
|
+
: msg.content;
|
|
349
|
+
lines.push(`**${sender}**: ${content}`);
|
|
350
|
+
lines.push('');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Check for _close sentinel.
|
|
358
|
+
*/
|
|
359
|
+
function shouldClose(): boolean {
|
|
360
|
+
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
|
|
361
|
+
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Drain all pending IPC input messages.
|
|
369
|
+
* Returns messages found, or empty array.
|
|
370
|
+
*/
|
|
371
|
+
function drainIpcInput(): string[] {
|
|
372
|
+
try {
|
|
373
|
+
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
|
374
|
+
const files = fs.readdirSync(IPC_INPUT_DIR)
|
|
375
|
+
.filter(f => f.endsWith('.json'))
|
|
376
|
+
.sort();
|
|
377
|
+
|
|
378
|
+
const messages: string[] = [];
|
|
379
|
+
for (const file of files) {
|
|
380
|
+
const filePath = path.join(IPC_INPUT_DIR, file);
|
|
381
|
+
try {
|
|
382
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
383
|
+
fs.unlinkSync(filePath);
|
|
384
|
+
if (data.type === 'message' && data.text) {
|
|
385
|
+
messages.push(data.text);
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
log(`Failed to process input file ${file}: ${getErrorMessage(err)}`);
|
|
389
|
+
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return messages;
|
|
393
|
+
} catch (err) {
|
|
394
|
+
log(`IPC drain error: ${getErrorMessage(err)}`);
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Wait for a new IPC message or _close sentinel.
|
|
401
|
+
* Returns the messages as a single string, or null if _close.
|
|
402
|
+
*/
|
|
403
|
+
function waitForIpcMessage(): Promise<string | null> {
|
|
404
|
+
return new Promise((resolve) => {
|
|
405
|
+
const poll = () => {
|
|
406
|
+
if (shouldClose()) {
|
|
407
|
+
resolve(null);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const messages = drainIpcInput();
|
|
411
|
+
if (messages.length > 0) {
|
|
412
|
+
resolve(messages.join('\n'));
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
setTimeout(poll, IPC_POLL_MS);
|
|
416
|
+
};
|
|
417
|
+
poll();
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Run a single query and stream results via writeOutput.
|
|
423
|
+
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
|
|
424
|
+
* allowing agent teams subagents to run to completion.
|
|
425
|
+
* Also pipes IPC messages into the stream during the query.
|
|
426
|
+
*/
|
|
427
|
+
async function runQuery(
|
|
428
|
+
prompt: string,
|
|
429
|
+
sessionId: string | undefined,
|
|
430
|
+
mcpServerPath: string,
|
|
431
|
+
containerInput: ContainerInput,
|
|
432
|
+
sdkEnv: Record<string, string | undefined>,
|
|
433
|
+
externalMcpServers: Record<string, McpServerConfig>,
|
|
434
|
+
resumeAt?: string,
|
|
435
|
+
): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
|
|
436
|
+
const stream = new MessageStream();
|
|
437
|
+
stream.push(prompt);
|
|
438
|
+
|
|
439
|
+
// Poll IPC for follow-up messages and _close sentinel during the query
|
|
440
|
+
let ipcPolling = true;
|
|
441
|
+
let closedDuringQuery = false;
|
|
442
|
+
const pollIpcDuringQuery = () => {
|
|
443
|
+
if (!ipcPolling) return;
|
|
444
|
+
if (shouldClose()) {
|
|
445
|
+
log('Close sentinel detected during query, ending stream');
|
|
446
|
+
closedDuringQuery = true;
|
|
447
|
+
stream.end();
|
|
448
|
+
ipcPolling = false;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const messages = drainIpcInput();
|
|
452
|
+
for (const text of messages) {
|
|
453
|
+
log(`Piping IPC message into active query (${text.length} chars)`);
|
|
454
|
+
stream.push(text);
|
|
455
|
+
}
|
|
456
|
+
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
|
457
|
+
};
|
|
458
|
+
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
|
|
459
|
+
|
|
460
|
+
let newSessionId: string | undefined;
|
|
461
|
+
let lastAssistantUuid: string | undefined;
|
|
462
|
+
let messageCount = 0;
|
|
463
|
+
let resultCount = 0;
|
|
464
|
+
|
|
465
|
+
// Load global CLAUDE.md as additional system context (shared across all groups)
|
|
466
|
+
const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
|
|
467
|
+
let globalClaudeMd: string | undefined;
|
|
468
|
+
if (!containerInput.isMain) {
|
|
469
|
+
try { globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); } catch { /* file absent */ }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Build system prompt append: global CLAUDE.md + reserved directories doc
|
|
473
|
+
const systemPromptParts: string[] = [];
|
|
474
|
+
if (globalClaudeMd) systemPromptParts.push(globalClaudeMd);
|
|
475
|
+
systemPromptParts.push(RESERVED_DIRS_DOC);
|
|
476
|
+
const systemPromptAppend = systemPromptParts.join('\n\n');
|
|
477
|
+
|
|
478
|
+
// Discover additional directories mounted at /workspace/extra/*
|
|
479
|
+
// These are passed to the SDK so their CLAUDE.md files are loaded automatically
|
|
480
|
+
const extraDirs: string[] = [];
|
|
481
|
+
const extraBase = '/workspace/extra';
|
|
482
|
+
try {
|
|
483
|
+
for (const entry of fs.readdirSync(extraBase, { withFileTypes: true })) {
|
|
484
|
+
if (entry.isDirectory()) {
|
|
485
|
+
extraDirs.push(path.join(extraBase, entry.name));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
} catch { /* directory absent */ }
|
|
489
|
+
if (extraDirs.length > 0) {
|
|
490
|
+
log(`Additional directories: ${extraDirs.join(', ')}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
for await (const message of query({
|
|
495
|
+
prompt: stream,
|
|
496
|
+
options: {
|
|
497
|
+
cwd: '/workspace/group',
|
|
498
|
+
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
|
499
|
+
resume: sessionId,
|
|
500
|
+
resumeSessionAt: resumeAt,
|
|
501
|
+
systemPrompt: { type: 'preset' as const, preset: 'claude_code' as const, append: systemPromptAppend },
|
|
502
|
+
allowedTools: [
|
|
503
|
+
'Bash',
|
|
504
|
+
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
|
505
|
+
'WebSearch', 'WebFetch',
|
|
506
|
+
'Task', 'TaskOutput', 'TaskStop',
|
|
507
|
+
'TeamCreate', 'TeamDelete', 'SendMessage',
|
|
508
|
+
'TodoWrite', 'ToolSearch', 'Skill',
|
|
509
|
+
'NotebookEdit',
|
|
510
|
+
'mcp__nanoclaw__*',
|
|
511
|
+
'mcp__*',
|
|
512
|
+
],
|
|
513
|
+
env: sdkEnv,
|
|
514
|
+
permissionMode: 'bypassPermissions',
|
|
515
|
+
allowDangerouslySkipPermissions: true,
|
|
516
|
+
settingSources: ['project', 'user'],
|
|
517
|
+
mcpServers: {
|
|
518
|
+
nanoclaw: {
|
|
519
|
+
command: 'node',
|
|
520
|
+
args: [mcpServerPath],
|
|
521
|
+
env: {
|
|
522
|
+
NANOCLAW_CHAT_JID: containerInput.chatJid,
|
|
523
|
+
NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
|
|
524
|
+
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
...externalMcpServers,
|
|
528
|
+
},
|
|
529
|
+
hooks: {
|
|
530
|
+
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
|
531
|
+
},
|
|
532
|
+
}
|
|
533
|
+
})) {
|
|
534
|
+
messageCount++;
|
|
535
|
+
const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
|
|
536
|
+
log(`[msg #${messageCount}] type=${msgType}`);
|
|
537
|
+
|
|
538
|
+
if (message.type === 'assistant') {
|
|
539
|
+
if ('uuid' in message) lastAssistantUuid = (message as { uuid: string }).uuid;
|
|
540
|
+
// Emit STATUS for each tool_use / thinking block so the host can display progress
|
|
541
|
+
type ContentBlock = { type: string; name?: string; input?: unknown; thinking?: string };
|
|
542
|
+
const blocks: ContentBlock[] = (message as { message?: { content?: ContentBlock[] } }).message?.content ?? [];
|
|
543
|
+
for (const block of blocks) {
|
|
544
|
+
if (block.type === 'tool_use' && block.name) {
|
|
545
|
+
writeStatus(block.name, toolInputSnippet(block.input));
|
|
546
|
+
} else if (block.type === 'thinking') {
|
|
547
|
+
writeStatus('thinking');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
553
|
+
newSessionId = message.session_id;
|
|
554
|
+
log(`Session initialized: ${newSessionId}`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
|
558
|
+
const tn = message as { task_id: string; status: string; summary: string };
|
|
559
|
+
log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (message.type === 'result') {
|
|
563
|
+
resultCount++;
|
|
564
|
+
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
|
565
|
+
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
|
566
|
+
writeOutput({
|
|
567
|
+
status: 'success',
|
|
568
|
+
result: textResult || null,
|
|
569
|
+
newSessionId
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
} finally {
|
|
574
|
+
ipcPolling = false; // always stop polling, even if query() throws
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
|
|
578
|
+
return { newSessionId, lastAssistantUuid, closedDuringQuery };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function main(): Promise<void> {
|
|
582
|
+
let containerInput: ContainerInput;
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const stdinData = await readStdin();
|
|
586
|
+
containerInput = JSON.parse(stdinData);
|
|
587
|
+
try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
|
|
588
|
+
log(`Received input for group: ${containerInput.groupFolder}`);
|
|
589
|
+
} catch (err) {
|
|
590
|
+
writeOutput({
|
|
591
|
+
status: 'error',
|
|
592
|
+
result: null,
|
|
593
|
+
error: `Failed to parse input: ${getErrorMessage(err)}`
|
|
594
|
+
});
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL.
|
|
599
|
+
// No real secrets exist in the container environment.
|
|
600
|
+
const sdkEnv: Record<string, string | undefined> = { ...process.env };
|
|
601
|
+
|
|
602
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
603
|
+
const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
|
|
604
|
+
|
|
605
|
+
let sessionId = containerInput.sessionId;
|
|
606
|
+
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
|
|
607
|
+
|
|
608
|
+
// Clean up stale _close sentinel from previous container runs
|
|
609
|
+
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
|
|
610
|
+
|
|
611
|
+
// Build initial prompt (drain any pending IPC messages too)
|
|
612
|
+
let prompt = containerInput.prompt;
|
|
613
|
+
if (containerInput.isScheduledTask) {
|
|
614
|
+
prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
|
|
615
|
+
}
|
|
616
|
+
const pending = drainIpcInput();
|
|
617
|
+
if (pending.length > 0) {
|
|
618
|
+
log(`Draining ${pending.length} pending IPC messages into initial prompt`);
|
|
619
|
+
prompt += '\n' + pending.join('\n');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// --- Slash command handling ---
|
|
623
|
+
// Only known session slash commands are handled here. This prevents
|
|
624
|
+
// accidental interception of user prompts that happen to start with '/'.
|
|
625
|
+
const KNOWN_SESSION_COMMANDS = new Set(['/compact']);
|
|
626
|
+
const trimmedPrompt = prompt.trim();
|
|
627
|
+
const isSessionSlashCommand = KNOWN_SESSION_COMMANDS.has(trimmedPrompt);
|
|
628
|
+
|
|
629
|
+
if (isSessionSlashCommand) {
|
|
630
|
+
log(`Handling session command: ${trimmedPrompt}`);
|
|
631
|
+
let slashSessionId: string | undefined;
|
|
632
|
+
let compactBoundarySeen = false;
|
|
633
|
+
let hadError = false;
|
|
634
|
+
let resultEmitted = false;
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
for await (const message of query({
|
|
638
|
+
prompt: trimmedPrompt,
|
|
639
|
+
options: {
|
|
640
|
+
cwd: '/workspace/group',
|
|
641
|
+
resume: sessionId,
|
|
642
|
+
systemPrompt: undefined,
|
|
643
|
+
allowedTools: [],
|
|
644
|
+
env: sdkEnv,
|
|
645
|
+
permissionMode: 'bypassPermissions' as const,
|
|
646
|
+
allowDangerouslySkipPermissions: true,
|
|
647
|
+
settingSources: ['project', 'user'] as const,
|
|
648
|
+
hooks: {
|
|
649
|
+
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
})) {
|
|
653
|
+
const msgType = message.type === 'system'
|
|
654
|
+
? `system/${(message as { subtype?: string }).subtype}`
|
|
655
|
+
: message.type;
|
|
656
|
+
log(`[slash-cmd] type=${msgType}`);
|
|
657
|
+
|
|
658
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
659
|
+
slashSessionId = message.session_id;
|
|
660
|
+
log(`Session after slash command: ${slashSessionId}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Observe compact_boundary to confirm compaction completed
|
|
664
|
+
if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
|
665
|
+
compactBoundarySeen = true;
|
|
666
|
+
log('Compact boundary observed — compaction completed');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (message.type === 'result') {
|
|
670
|
+
const resultSubtype = (message as { subtype?: string }).subtype;
|
|
671
|
+
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
|
672
|
+
|
|
673
|
+
if (resultSubtype?.startsWith('error')) {
|
|
674
|
+
hadError = true;
|
|
675
|
+
writeOutput({
|
|
676
|
+
status: 'error',
|
|
677
|
+
result: null,
|
|
678
|
+
error: textResult || 'Session command failed.',
|
|
679
|
+
newSessionId: slashSessionId,
|
|
680
|
+
});
|
|
681
|
+
} else {
|
|
682
|
+
writeOutput({
|
|
683
|
+
status: 'success',
|
|
684
|
+
result: textResult || 'Conversation compacted.',
|
|
685
|
+
newSessionId: slashSessionId,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
resultEmitted = true;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
} catch (err) {
|
|
692
|
+
hadError = true;
|
|
693
|
+
const errorMsg = getErrorMessage(err);
|
|
694
|
+
log(`Slash command error: ${errorMsg}`);
|
|
695
|
+
writeOutput({ status: 'error', result: null, error: errorMsg });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
log(`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`);
|
|
699
|
+
|
|
700
|
+
// Warn if compact_boundary was never observed — compaction may not have occurred
|
|
701
|
+
if (!hadError && !compactBoundarySeen) {
|
|
702
|
+
log('WARNING: compact_boundary was not observed. Compaction may not have completed.');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Only emit final session marker if no result was emitted yet and no error occurred
|
|
706
|
+
if (!resultEmitted && !hadError) {
|
|
707
|
+
writeOutput({
|
|
708
|
+
status: 'success',
|
|
709
|
+
result: compactBoundarySeen
|
|
710
|
+
? 'Conversation compacted.'
|
|
711
|
+
: 'Compaction requested but compact_boundary was not observed.',
|
|
712
|
+
newSessionId: slashSessionId,
|
|
713
|
+
});
|
|
714
|
+
} else if (!hadError) {
|
|
715
|
+
// Emit session-only marker so host updates session tracking
|
|
716
|
+
writeOutput({ status: 'success', result: null, newSessionId: slashSessionId });
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
// --- End slash command handling ---
|
|
721
|
+
|
|
722
|
+
// Query loop: run query → wait for IPC message → run new query → repeat
|
|
723
|
+
let resumeAt: string | undefined;
|
|
724
|
+
try {
|
|
725
|
+
while (true) {
|
|
726
|
+
// Re-scan MCP-Servers/ before each query so additions/removals take effect immediately
|
|
727
|
+
const externalMcpServers = loadExternalMcpServers();
|
|
728
|
+
log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'}, externalMcpServers: ${Object.keys(externalMcpServers).join(', ') || 'none'})...`);
|
|
729
|
+
|
|
730
|
+
const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, externalMcpServers, resumeAt);
|
|
731
|
+
if (queryResult.newSessionId) {
|
|
732
|
+
sessionId = queryResult.newSessionId;
|
|
733
|
+
}
|
|
734
|
+
if (queryResult.lastAssistantUuid) {
|
|
735
|
+
resumeAt = queryResult.lastAssistantUuid;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// If _close was consumed during the query, exit immediately.
|
|
739
|
+
// Don't emit a session-update marker (it would reset the host's
|
|
740
|
+
// idle timer and cause a 30-min delay before the next _close).
|
|
741
|
+
if (queryResult.closedDuringQuery) {
|
|
742
|
+
log('Close sentinel consumed during query, exiting');
|
|
743
|
+
break;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Emit session update so host can track it
|
|
747
|
+
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
|
|
748
|
+
|
|
749
|
+
log('Query ended, waiting for next IPC message...');
|
|
750
|
+
|
|
751
|
+
// Wait for the next message or _close sentinel
|
|
752
|
+
const nextMessage = await waitForIpcMessage();
|
|
753
|
+
if (nextMessage === null) {
|
|
754
|
+
log('Close sentinel received, exiting');
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
log(`Got new message (${nextMessage.length} chars), starting new query`);
|
|
759
|
+
prompt = nextMessage;
|
|
760
|
+
}
|
|
761
|
+
} catch (err) {
|
|
762
|
+
const errorMessage = getErrorMessage(err);
|
|
763
|
+
log(`Agent error: ${errorMessage}`);
|
|
764
|
+
writeOutput({
|
|
765
|
+
status: 'error',
|
|
766
|
+
result: null,
|
|
767
|
+
newSessionId: sessionId,
|
|
768
|
+
error: errorMessage
|
|
769
|
+
});
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
main();
|