history: Saturday, 2 August 2026 — Claude API + Claude Code providers for rojo-chat-panel

- Claude API provider (@anthropic-ai/sdk): streaming, separate session history,
  tool use support, rojo-settings-panel claude endpoint type
- Claude Code subprocess provider: --print --output-format stream-json --verbose,
  --session-id/--resume multi-turn, shell:false Windows fix,
  rojo-settings-panel claude-code endpoint type (no API key)
- Rojo local-mode fixes: detection, creation, system-prompt delivery
- Three subprocess bugs fixed: missing --verbose, stdin blocking, cmd.exe mangling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-08-02 12:32:11 +02:00
parent 87a42fd5a5
commit 6766f6d467
10 changed files with 903 additions and 41 deletions

201
package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "roject",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/claude-code": "^2.1.220",
"@anthropic-ai/sdk": "^0.115.0",
"@langchain/core": "^1.2.2",
"@langchain/openai": "^1.5.4",
"@types/cookie-parser": "^1.4.10",
@ -28,6 +30,163 @@
"typescript": "^5.3.3"
}
},
"node_modules/@anthropic-ai/claude-code": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.220.tgz",
"integrity": "sha512-ogBrvwkqF9f8okmnXKxmRNHuvtFxFEffe5pWdqOV3iQDxlUOKirFqnyWC7NGXXnDA4WkkbPH8pvSbwyCR2Auyw==",
"hasInstallScript": true,
"license": "SEE LICENSE IN README.md",
"bin": {
"claude": "bin/claude.exe"
},
"engines": {
"node": ">=22.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-code-darwin-arm64": "2.1.220",
"@anthropic-ai/claude-code-darwin-x64": "2.1.220",
"@anthropic-ai/claude-code-linux-arm64": "2.1.220",
"@anthropic-ai/claude-code-linux-arm64-musl": "2.1.220",
"@anthropic-ai/claude-code-linux-x64": "2.1.220",
"@anthropic-ai/claude-code-linux-x64-musl": "2.1.220",
"@anthropic-ai/claude-code-win32-arm64": "2.1.220",
"@anthropic-ai/claude-code-win32-x64": "2.1.220"
}
},
"node_modules/@anthropic-ai/claude-code-darwin-arm64": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-arm64/-/claude-code-darwin-arm64-2.1.220.tgz",
"integrity": "sha512-rmtd41Bf+n+YnhjSjtQ8WG5qy8KKogUp3YRfQrkLsTgPUD0H3j869rBInBJT3SHrKQ0hLghQLGM73CC1C+USLQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-code-darwin-x64": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-darwin-x64/-/claude-code-darwin-x64-2.1.220.tgz",
"integrity": "sha512-hbuoG+YCo37VzSKzKJ47ymRmt/YjASc3dRcsZtCcftLYdopv8KL889x/IbCl3cfp/VqV2rRDZ0f3aUDpHUFweQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@anthropic-ai/claude-code-linux-arm64": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64/-/claude-code-linux-arm64-2.1.220.tgz",
"integrity": "sha512-VHFI8mKruIntKn7eq81sbyS19/KWmQcmJQsS/C+j9M/E+w0s4UytgsL7DADPjBE/GByNiKoRtLYDMntCjRlOdA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-arm64-musl": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-arm64-musl/-/claude-code-linux-arm64-musl-2.1.220.tgz",
"integrity": "sha512-m37ALw8jcbSknuyG7xDQjGPY7Gth3eX8iFY1XFEWABVq1iUMVAUn96WC9eqwi8/JSqyG2t3oNRiqHdi2ZNKFGQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-x64": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64/-/claude-code-linux-x64-2.1.220.tgz",
"integrity": "sha512-3CGFCnI0gpgsqNeJruFALBDGJaKXOuok3alQEg56ty2yOPpIrOx/r2Y0+T4uhJl7kP5Hzw4IFkxo4DZKWvzQ7Q==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-linux-x64-musl": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-linux-x64-musl/-/claude-code-linux-x64-musl-2.1.220.tgz",
"integrity": "sha512-+QyT1KikOdMRKReWFaBYGsroYx2vEjjx54DwhMoC24oE1DxjC+SlKjeOTRXAKiu0fr0O549Lkhg2tuT5xtQpAQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@anthropic-ai/claude-code-win32-arm64": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-arm64/-/claude-code-win32-arm64-2.1.220.tgz",
"integrity": "sha512-APqZwFBn38DBUwB65uUTetW7lbtUqFfAfOWKvkmOyqFDswDEsInaINuIwqMCl44WYcch10SaHhEZdXJU9MG3aQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/claude-code-win32-x64": {
"version": "2.1.220",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code-win32-x64/-/claude-code-win32-x64-2.1.220.tgz",
"integrity": "sha512-UGrjH8cGhC6PzhTyZSdgf/RpKxpfk9XJZ/RT/wsG2AJg9yEJLjLg6/TrnlL8RFbEv6Zahu0Quytc02UOpA/GiA==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
@ -892,6 +1051,12 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -2907,6 +3072,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
@ -3616,6 +3787,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@ -5072,6 +5256,16 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/stat-mode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
@ -5353,6 +5547,12 @@
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@ -5728,6 +5928,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -10,6 +10,8 @@
"electron:dist": "npm run electron:build && electron-builder"
},
"dependencies": {
"@anthropic-ai/claude-code": "^2.1.220",
"@anthropic-ai/sdk": "^0.115.0",
"@langchain/core": "^1.2.2",
"@langchain/openai": "^1.5.4",
"@types/cookie-parser": "^1.4.10",

View File

@ -148,12 +148,26 @@ class RojoChatPanel extends HTMLElement
async _loadRojos(): Promise<void>
{
const projectId = Editor.get().projectId;
if ( !projectId ) return;
const editor = Editor.get();
let url: string;
if ( editor.localRoot )
{
url = `/api/local/rojos?root=${ encodeURIComponent( editor.localRoot ) }`;
}
else if ( editor.remoteProject )
{
url = `/api/remote/rojos/${ editor.remoteProject }/list`;
}
else if ( editor.projectId )
{
url = `/api/rojos/${ editor.projectId }/list`;
}
else { return; }
try
{
const res = await fetch( `/api/rojos/${ projectId }/list` );
const res = await fetch( url );
if ( !res.ok ) return;
this._rojos = await res.json() as RojoEntry[];
this._renderPicker();
@ -184,22 +198,39 @@ class RojoChatPanel extends HTMLElement
async _createRojo(): Promise<void>
{
const projectId = Editor.get().projectId;
if ( !projectId ) return;
const editor = Editor.get();
// Place new rojo in the same directory as the currently selected one,
// or directly in workspace/rojos if nothing is selected.
const dir = this._selectedPath
? parentDir( this._selectedPath )
: 'workspace/rojos';
let url: string;
let body: Record<string, string>;
if ( editor.localRoot )
{
url = '/api/local/rojos/create';
body = { root: editor.localRoot, parentDir: dir };
}
else if ( editor.remoteProject )
{
url = `/api/remote/rojos/${ editor.remoteProject }/create`;
body = { parentDir: dir };
}
else if ( editor.projectId )
{
url = `/api/rojos/${ editor.projectId }/create`;
body = { parentDir: dir };
}
else { return; }
try
{
const res = await fetch( `/api/rojos/${ projectId }/create`,
const res = await fetch( url,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( { parentDir: dir } ),
body: JSON.stringify( body ),
} );
if ( !res.ok ) return;
@ -209,7 +240,7 @@ class RojoChatPanel extends HTMLElement
this._selectedPath = newPath;
( this.querySelector( '.rcp-picker' ) as HTMLSelectElement ).value = newPath;
await Editor.get().openDocument( newPath );
await editor.openDocument( newPath );
}
catch {}
}
@ -267,8 +298,12 @@ class RojoChatPanel extends HTMLElement
if ( this._selectedPath )
{
body.projectId = Editor.get().projectId;
body.rojoPath = this._selectedPath;
const editor = Editor.get();
body.rojoPath = this._selectedPath;
if ( editor.localRoot ) { body.localRoot = editor.localRoot; }
else if ( editor.remoteProject ) { body.remoteProject = editor.remoteProject; }
else { body.projectId = editor.projectId; }
}
const response = await fetch( '/api/rojos/chat',

View File

@ -13,7 +13,7 @@ function inkscapeLabel( el: Element ): string
interface RojoEndpoint
{
type: 'external' | 'tunnel';
type: 'external' | 'tunnel' | 'claude' | 'claude-code';
url: string;
model: string;
apiKey: string;
@ -159,12 +159,16 @@ class RojoSettingsPanel extends HTMLElement
<select class="rsp-input" data-field="endpoint.type">
<option value="external">External / Local URL</option>
<option value="tunnel">Tunnel (tunnel.rokojori.com)</option>
<option value="claude">Claude API (Anthropic)</option>
<option value="claude-code">Claude Code (local, no key)</option>
</select>
</label>
<div class="rsp-endpoint-external">
<label class="rsp-label">Base URL
<input class="rsp-input" data-field="endpoint.url" type="text" placeholder="http://localhost:11434/v1">
</label>
<div class="rsp-url-row">
<label class="rsp-label">Base URL
<input class="rsp-input" data-field="endpoint.url" type="text" placeholder="http://localhost:11434/v1">
</label>
</div>
<label class="rsp-label">API Key
<input class="rsp-input" data-field="endpoint.apiKey" type="password" placeholder="sk-… (or leave blank for local)">
</label>
@ -180,8 +184,16 @@ class RojoSettingsPanel extends HTMLElement
<div class="rsp-tunnel-list"></div>
</div>
</div>
<label class="rsp-label">Model
<input class="rsp-input" data-field="endpoint.model" type="text" placeholder="gpt-4o / llama3 / gemma3 / …">
<div class="rsp-endpoint-claude-code" style="display:none">
<div class="rsp-claude-code-info">Uses the local Claude Code CLI no API key needed. Inherits your existing Claude Code login.</div>
</div>
<label class="rsp-label rsp-model-row">Model
<input class="rsp-input" data-field="endpoint.model" type="text" list="rsp-model-suggestions" placeholder="gpt-4o / llama3 / claude-opus-4-8 / …">
<datalist id="rsp-model-suggestions">
<option value="claude-opus-4-8">
<option value="claude-sonnet-4-6">
<option value="claude-haiku-4-5">
</datalist>
</label>
</div>
</div>
@ -337,7 +349,7 @@ class RojoSettingsPanel extends HTMLElement
description: this._getField( 'description' ),
systemPrompt: this._getField( 'systemPrompt' ),
endpoint: {
type: this._getField( 'endpoint.type' ) as 'external' | 'tunnel',
type: this._getField( 'endpoint.type' ) as 'external' | 'tunnel' | 'claude' | 'claude-code',
url: this._getField( 'endpoint.url' ),
apiKey: this._getField( 'endpoint.apiKey' ),
tunnelId: this._getField( 'endpoint.tunnelId' ),
@ -356,9 +368,15 @@ class RojoSettingsPanel extends HTMLElement
_updateEndpointVisibility(): void
{
const type = this._getField( 'endpoint.type' );
( this.querySelector( '.rsp-endpoint-external' ) as HTMLElement ).style.display = type === 'external' ? '' : 'none';
( this.querySelector( '.rsp-endpoint-tunnel' ) as HTMLElement ).style.display = type === 'tunnel' ? '' : 'none';
const type = this._getField( 'endpoint.type' );
const isCC = type === 'claude-code';
( this.querySelector( '.rsp-endpoint-external' ) as HTMLElement ).style.display = ( type === 'external' || type === 'claude' ) ? '' : 'none';
( this.querySelector( '.rsp-endpoint-tunnel' ) as HTMLElement ).style.display = type === 'tunnel' ? '' : 'none';
( this.querySelector( '.rsp-endpoint-claude-code' ) as HTMLElement ).style.display = isCC ? '' : 'none';
const urlRow = this.querySelector( '.rsp-url-row' ) as HTMLElement | null;
const modelRow = this.querySelector( '.rsp-model-row' ) as HTMLElement | null;
if ( urlRow ) urlRow.style.display = type === 'claude' ? 'none' : '';
if ( modelRow ) modelRow.style.display = isCC ? 'none' : '';
}
async _syncFilename(): Promise<void>

View File

@ -1,15 +1,20 @@
import Anthropic from "@anthropic-ai/sdk";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, AIMessage, SystemMessage, ToolMessage, BaseMessage } from "@langchain/core/messages";
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { RojoToolContext } from "./tools/RojoTool.js";
export interface AgentConfig
{
baseURL: string;
model: string;
apiKey: string;
headers?: Record<string, string>;
baseURL?: string;
model: string;
apiKey: string;
provider?: "openai" | "claude" | "claude-code";
headers?: Record<string, string>;
systemPrompt?: string;
tools?: object[];
tools?: object[];
}
export type AgentEvent =
@ -19,18 +24,50 @@ export type AgentEvent =
type ToolExecutor = ( name: string, args: Record<string, unknown>, ctx: RojoToolContext ) => Promise<unknown>;
type SessionData = { id: string; messages: BaseMessage[] };
type SessionData = { id: string; messages: BaseMessage[] };
type ClaudeSessionData = { id: string; messages: Anthropic.MessageParam[] };
const sessions = new Map<string, SessionData>();
const sessions = new Map<string, SessionData>();
const claudeSessions = new Map<string, ClaudeSessionData>();
const claudeCodeSessions = new Set<string>();
// ── Claude binary path ───────────────────────────────────────────────────────
function findClaudeBin(): string
{
const win = process.platform === "win32";
const direct = path.join( process.cwd(), "node_modules", "@anthropic-ai", "claude-code", "bin", win ? "claude.exe" : "claude" );
if ( fs.existsSync( direct ) ) return direct;
const wrapper = path.join( process.cwd(), "node_modules", ".bin", win ? "claude.cmd" : "claude" );
return wrapper;
}
const CLAUDE_BIN = findClaudeBin();
// ── Main entry ───────────────────────────────────────────────────────────────
export async function* getAgentStream(
id: string,
userMessage: string,
config: AgentConfig,
toolContext?: RojoToolContext,
id: string,
userMessage: string,
config: AgentConfig,
toolContext?: RojoToolContext,
toolExecutor?: ToolExecutor,
): AsyncGenerator<AgentEvent>
{
if ( config.provider === "claude" )
{
yield* getClaudeAgentStream( id, userMessage, config, toolContext, toolExecutor );
return;
}
if ( config.provider === "claude-code" )
{
yield* getClaudeCodeAgentStream( id, userMessage, config );
return;
}
// ── OpenAI-compatible path ─────────────────────────────────────────────────
const model = new ChatOpenAI(
{
model: config.model,
@ -119,6 +156,240 @@ export async function* getAgentStream(
}
}
// ── Claude API provider ──────────────────────────────────────────────────────
function toClaudeTool( t: any ): Anthropic.Tool
{
const fn = t.function ?? t;
return {
name: fn.name,
description: fn.description ?? "",
input_schema: fn.parameters as Anthropic.Tool[ "input_schema" ],
};
}
async function* getClaudeAgentStream(
id: string,
userMessage: string,
config: AgentConfig,
toolContext?: RojoToolContext,
toolExecutor?: ToolExecutor,
): AsyncGenerator<AgentEvent>
{
const client = new Anthropic( { apiKey: config.apiKey } );
if ( !claudeSessions.has( id ) )
{
claudeSessions.set( id, { id, messages: [] } );
}
const session = claudeSessions.get( id )!;
const claudeTools = ( config.tools ?? [] ).map( toClaudeTool );
session.messages.push( { role: "user", content: userMessage } );
while ( true )
{
const streamParams: Anthropic.MessageStreamParams = {
model: config.model,
max_tokens: 8192,
messages: session.messages,
...( config.systemPrompt?.trim() ? { system: config.systemPrompt } : {} ),
...( claudeTools.length > 0 ? { tools: claudeTools } : {} ),
};
const stream = client.messages.stream( streamParams );
for await ( const event of stream )
{
if ( event.type === "content_block_delta" && event.delta.type === "text_delta" )
{
yield { type: "text", text: event.delta.text };
}
}
const finalMsg = await stream.finalMessage();
const toolBlocks = finalMsg.content.filter( b => b.type === "tool_use" ) as Anthropic.ToolUseBlock[];
console.log( "[RojosAgent/claude] stop_reason:", finalMsg.stop_reason, "| tool_calls:", toolBlocks.length );
if ( toolBlocks.length > 0 && toolContext && toolExecutor )
{
session.messages.push( { role: "assistant", content: finalMsg.content } );
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for ( const tc of toolBlocks )
{
yield { type: "tool_call", name: tc.name, args: tc.input as Record<string, unknown> };
let result: unknown;
try
{
result = await toolExecutor( tc.name, tc.input as Record<string, unknown>, toolContext );
}
catch ( err )
{
result = { error: String( err ) };
}
const resultStr = typeof result === "string" ? result : JSON.stringify( result );
yield { type: "tool_result", name: tc.name, result: resultStr };
toolResults.push( { type: "tool_result", tool_use_id: tc.id, content: resultStr } );
}
session.messages.push( { role: "user", content: toolResults } );
}
else
{
const fullText = finalMsg.content
.filter( b => b.type === "text" )
.map( b => ( b as Anthropic.TextBlock ).text )
.join( "" );
session.messages.push( { role: "assistant", content: fullText } );
break;
}
}
}
// ── Claude Code provider (subprocess) ───────────────────────────────────────
async function* getClaudeCodeAgentStream(
id: string,
userMessage: string,
config: AgentConfig,
): AsyncGenerator<AgentEvent>
{
const isFirst = !claudeCodeSessions.has( id );
claudeCodeSessions.add( id );
const args: string[] = [
"--print",
"--output-format", "stream-json",
"--verbose",
"--dangerously-skip-permissions",
];
if ( isFirst )
{
args.push( "--session-id", id );
if ( config.systemPrompt?.trim() )
{
args.push( "--append-system-prompt", config.systemPrompt );
}
}
else
{
args.push( "--resume", id );
}
args.push( userMessage );
console.log( "[RojosAgent/claude-code] spawning:", CLAUDE_BIN, args.slice( 0, 6 ).join( " " ) );
yield* spawnJsonLines( CLAUDE_BIN, args );
}
async function* spawnJsonLines( bin: string, args: string[] ): AsyncGenerator<AgentEvent>
{
const queue: AgentEvent[] = [];
const waiters: Array<() => void> = [];
let done = false;
let spawnError: Error | null = null;
function push( ev: AgentEvent ): void
{
queue.push( ev );
if ( waiters.length ) waiters.shift()!();
}
function finish( err?: Error ): void
{
done = true;
spawnError = err ?? null;
while ( waiters.length ) waiters.shift()!();
}
const proc = spawn( bin, args, { cwd: process.cwd(), shell: false, stdio: [ "ignore", "pipe", "pipe" ] } );
let buf = "";
proc.stdout.on( "data", ( chunk: Buffer ) =>
{
buf += chunk.toString( "utf8" );
const lines = buf.split( "\n" );
buf = lines.pop() ?? "";
for ( const line of lines )
{
const t = line.trim();
if ( !t ) continue;
try
{
const msg = JSON.parse( t ) as any;
const ev = claudeCodeEventToAgentEvent( msg );
if ( ev ) push( ev );
}
catch {}
}
} );
proc.stderr.on( "data", ( chunk: Buffer ) =>
{
console.error( "[RojosAgent/claude-code] stderr:", chunk.toString( "utf8" ).trim() );
} );
proc.on( "error", ( err ) => finish( err ) );
proc.on( "close", ( code ) =>
{
if ( code !== 0 ) console.warn( "[RojosAgent/claude-code] exited with code", code );
finish();
} );
while ( true )
{
if ( queue.length > 0 )
{
yield queue.shift()!;
}
else if ( done )
{
break;
}
else
{
await new Promise<void>( resolve => waiters.push( resolve ) );
}
}
if ( spawnError ) throw spawnError;
}
function claudeCodeEventToAgentEvent( msg: any ): AgentEvent | null
{
if ( msg.type === "assistant" )
{
const content = msg.message?.content;
if ( !Array.isArray( content ) ) return null;
const text = content
.filter( ( b: any ) => b.type === "text" )
.map( ( b: any ) => b.text as string )
.join( "" );
return text ? { type: "text", text } : null;
}
return null;
}
// ── Utilities ────────────────────────────────────────────────────────────────
function extractText( content: unknown ): string
{
if ( typeof content === "string" ) return content;

View File

@ -2,6 +2,7 @@ import { Router } from 'express';
import { requireAuth } from '../../auth-connector/source/server/auth';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
const router = Router();
router.use( requireAuth );
@ -114,4 +115,125 @@ router.post( '/delete', ( req, res ) =>
res.json( { ok: true } );
} );
// ── Rojo helpers ─────────────────────────────────────────────────────────────
function pickRandom<T>( arr: T[] ): T { return arr[ Math.floor( Math.random() * arr.length ) ]; }
function capitalize( s: string ): string { return s[ 0 ].toUpperCase() + s.slice( 1 ); }
const ROJO_ADJECTIVES = [
"funky", "cool", "groovy", "wild", "sharp", "slick", "swift", "bold",
"bright", "calm", "daring", "eager", "fierce", "jolly", "lively",
"quirky", "smart", "vivid", "zesty", "sleek", "crisp", "nifty", "rad",
"sunny", "sassy", "snappy", "peppy", "zippy", "breezy", "mellow",
];
const ROJO_ROLES = [
"boss", "doctor", "player", "teacher", "hunter", "builder", "maker",
"dancer", "singer", "painter", "writer", "coder", "pilot", "chef",
"sailor", "rider", "climber", "dreamer", "wanderer", "scout", "keeper",
"helper", "guide", "mentor", "runner", "seeker", "ranger", "scholar",
];
const ROJO_NAMES = [
"Sofia", "Elena", "Lucas", "Mia", "Noah", "Emma", "Leon", "Lena",
"Felix", "Anna", "Max", "Clara", "Julian", "Sara", "Lars", "Nina",
"Tom", "Lara", "Erik", "Ida", "Hugo", "Vera", "Otto", "Maja",
"Kai", "Maya", "Arjun", "Zara", "Aisha", "Omar", "Nala", "Ravi",
"Yuki", "Kenji", "Amara", "Diego", "Mateo", "Jae", "Sora", "Kira",
"Rio", "Zion", "Nova", "Leila", "Cyrus", "Noa", "Bao", "Mila",
];
function generateLocalRojoName(): { display: string; slug: string }
{
const adj = pickRandom( ROJO_ADJECTIVES );
const role = pickRandom( ROJO_ROLES );
const name = pickRandom( ROJO_NAMES );
return {
display: `${ capitalize( adj ) } ${ capitalize( role ) } ${ name }`,
slug: `${ adj }-${ role }-${ name.toLowerCase() }`,
};
}
function scanLocalRojoFiles( dir: string, rootDir: string, out: string[] = [] ): string[]
{
if ( !fs.existsSync( dir ) ) return out;
for ( const entry of fs.readdirSync( dir, { withFileTypes: true } ) )
{
const full = path.join( dir, entry.name );
if ( entry.isDirectory() ) { scanLocalRojoFiles( full, rootDir, out ); }
else if ( entry.name.endsWith( '.rojo' ) )
{
out.push( path.relative( rootDir, full ).replace( /\\/g, '/' ) );
}
}
return out;
}
// ── Local rojo list ──────────────────────────────────────────────────────────
router.get( '/rojos', ( req, res ) =>
{
const root = req.query.root as string;
if ( !root ) { res.status( 400 ).json( { error: 'root required' } ); return; }
const resolvedRoot = path.resolve( root );
const rojoDir = path.join( resolvedRoot, 'workspace', 'rojos' );
const files = scanLocalRojoFiles( rojoDir, resolvedRoot );
const rojos = files.map( filePath =>
{
let id = '', name = filePath, description = '';
const full = safeResolve( root, filePath );
if ( full && fs.existsSync( full ) )
{
try
{
const s = JSON.parse( fs.readFileSync( full, 'utf8' ) );
id = s.id || '';
name = s.name || name;
description = s.description || '';
}
catch {}
}
return { id, name, description, path: filePath };
} );
res.json( rojos );
} );
// ── Local rojo create ────────────────────────────────────────────────────────
router.post( '/rojos/create', ( req, res ) =>
{
const { root, parentDir: parentDirArg } = req.body as { root: string; parentDir?: string };
if ( !root ) { res.status( 400 ).json( { error: 'root required' } ); return; }
const dir = parentDirArg || 'workspace/rojos';
const { display, slug } = generateLocalRojoName();
const id = crypto.randomUUID();
const filePath = `${ dir }/${ slug }.rojo`;
const full = safeResolve( root, filePath );
if ( !full ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
const defaults = JSON.stringify(
{
id,
name: display,
description: '',
systemPrompt: '',
endpoint: { type: 'external', url: '', model: '', apiKey: '', tunnelId: '' },
appearance: { colors: [], layers: [] },
}, null, 2 );
fs.mkdirSync( path.dirname( full ), { recursive: true } );
fs.writeFileSync( full, defaults, 'utf8' );
res.json( { id, path: filePath, name: display } );
} );
export default router;

View File

@ -192,12 +192,13 @@ router.post( "/:projectId/create", ( req, res ) =>
router.post( "/chat", async ( req, res ) =>
{
const { id, message, projectId, rojoPath } = req.body as
const { id, message, projectId, rojoPath, localRoot } = req.body as
{
id: string;
message: string;
projectId?: string;
rojoPath?: string;
id: string;
message: string;
projectId?: string;
rojoPath?: string;
localRoot?: string;
};
if ( !id || !message )
@ -214,9 +215,23 @@ router.post( "/chat", async ( req, res ) =>
tools: projectId ? RojoToolRegistry.definitions() : [],
};
if ( projectId && rojoPath )
if ( rojoPath )
{
const content = readProjectFile( projectId, rojoPath );
let content: string | null = null;
if ( localRoot )
{
const resolvedRoot = path.resolve( localRoot );
const full = path.resolve( path.join( resolvedRoot, rojoPath ) );
if ( full.startsWith( resolvedRoot + path.sep ) && fs.existsSync( full ) )
{
content = fs.readFileSync( full, "utf8" );
}
}
else if ( projectId )
{
content = readProjectFile( projectId, rojoPath );
}
if ( content )
{
@ -227,7 +242,17 @@ router.post( "/chat", async ( req, res ) =>
config.systemPrompt = settings.systemPrompt ?? "";
if ( ep.type === "tunnel" && ep.tunnelId )
if ( ep.type === "claude-code" )
{
config.provider = "claude-code";
}
else if ( ep.type === "claude" )
{
config.provider = "claude";
config.model = ep.model || "claude-opus-4-8";
config.apiKey = ep.apiKey || "";
}
else if ( ep.type === "tunnel" && ep.tunnelId )
{
const tunnelServer = process.env.TUNNEL_SERVER_URL || "https://tunnel.rokojori.com";
config.baseURL = `${ tunnelServer }/t/${ ep.tunnelId }/v1`;

View File

@ -52,6 +52,7 @@ var NAV_DATA = {
title: 'History',
path: 'history/index.html',
children: [
{ title: 'Saturday, 2 August 2026', path: 'history/2026/08-August/02-Saturday/index.html' },
{ title: 'Friday, 31 July 2026', path: 'history/2026/07-July/31-Friday/index.html' },
{ title: 'Thursday, 30 July 2026', path: 'history/2026/07-July/30-Thursday/index.html' },
{ title: 'Friday, 24 July 2026', path: 'history/2026/07-July/24-Friday/index.html' },

View File

@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saturday, 2 August 2026 — Roject</title>
<link rel="stylesheet" href="../../../../_assets_/styles.css">
<link rel="stylesheet" href="../../../../_assets_/nav.css">
</head>
<body>
<div class="page">
<header>
<p class="date">Saturday, 2 August 2026</p>
<h1>Session History</h1>
<p class="subtitle">Rojo bug fixes (local mode detection, creation, system prompt). Claude API provider and Claude Code subprocess provider for rojo-chat-panel.</p>
</header>
<section>
<h2>What we built</h2>
<div class="card">
<h3>Rojo local-mode bug fixes (three)</h3>
<p>
All three bugs shared the same root cause: <code>rojo-chat-panel</code> only checked
<code>editor.projectId</code>, silently ignoring <code>localRoot</code> and
<code>remoteProject</code> modes.
</p>
<ul style="margin-top:0.5rem;line-height:1.9">
<li><strong>Detection</strong><code>_loadRojos()</code> now uses a three-way branch
matching <code>Editor.ts</code>'s three mode fields, hitting
<code>/api/local/rojos?root=…</code> for local projects.</li>
<li><strong>Creation</strong><code>_createRojo()</code> received the same fix,
posting to <code>/api/local/rojos/create</code> with <code>{ root, parentDir }</code>.</li>
<li><strong>System prompt</strong><code>_send()</code> was sending
<code>body.projectId = ''</code> (falsy), so the server skipped reading the rojo file.
Fixed by sending <code>localRoot</code> in the body and branching server-side
with a path-containment security check.</li>
</ul>
</div>
<div class="card">
<h3>Claude API provider (<code>provider: "claude"</code>)</h3>
<p>
Installed <code>@anthropic-ai/sdk</code> and added a second LLM path to
<code>RojosAgent.ts</code>:
</p>
<ul style="margin-top:0.5rem;line-height:1.9">
<li><code>AgentConfig</code> extended with optional <code>provider?: "openai" | "claude" | "claude-code"</code>
and <code>baseURL</code> made optional.</li>
<li>Separate <code>claudeSessions</code> map (<code>Anthropic.MessageParam[]</code>)
keeps conversation history independently of the LangChain OpenAI sessions.</li>
<li>Streaming via <code>client.messages.stream()</code>; text deltas yielded in
real-time. After iteration, <code>stream.finalMessage()</code> is used to collect
tool use blocks for the agentic loop.</li>
<li>OpenAI-format tools converted to Claude format by <code>toClaudeTool()</code>
(wraps <code>parameters</code> as <code>input_schema</code>).</li>
<li>Route <code>rojos.ts</code>: <code>ep.type === "claude"</code> branch sets
<code>config.provider</code>, defaults model to <code>claude-opus-4-8</code>.</li>
</ul>
</div>
<div class="card">
<h3>Claude Code subprocess provider (<code>provider: "claude-code"</code>)</h3>
<p>
Adds a third provider path that spawns the local <code>claude.exe</code> binary
(already installed via <code>@anthropic-ai/claude-code</code>) as a subprocess.
No API key needed — reuses the existing Claude Code login from the system keychain.
</p>
<ul style="margin-top:0.5rem;line-height:1.9">
<li>CLI flags: <code>--print --output-format stream-json --verbose
--dangerously-skip-permissions</code>.</li>
<li>First message uses <code>--session-id &lt;conversationId&gt;</code> to create a
persistent session; subsequent messages use <code>--resume &lt;conversationId&gt;</code>
to continue it — full multi-turn conversation.</li>
<li>System prompt passed via <code>--append-system-prompt</code> to preserve Claude
Code's own built-in instructions.</li>
<li><code>spawnJsonLines()</code> is an async generator backed by an event queue;
stdout is parsed line-by-line as newline-delimited JSON.
<code>type === "assistant"</code> events are converted to <code>AgentEvent</code>.</li>
<li>Binary located at runtime via <code>findClaudeBin()</code> (checks
<code>node_modules/@anthropic-ai/claude-code/bin/claude.exe</code> first,
falls back to <code>.bin/claude.cmd</code>).</li>
</ul>
<p style="margin-top:0.75rem">
Three bugs fixed during initial testing:
</p>
<ul style="margin-top:0.5rem;line-height:1.9">
<li><strong>Missing <code>--verbose</code></strong> — required by the CLI when
<code>stream-json</code> output format is used; without it the process exits with
an error immediately.</li>
<li><strong>Stdin not closed</strong> — process waited 3 s for piped input before
starting. Fixed by <code>stdio: ["ignore", "pipe", "pipe"]</code>.</li>
<li><strong><code>shell: true</code> on Windows</strong> — cmd.exe mangled the user
message (split on spaces, <code>?</code> treated as wildcard), so Claude received
only a fragment. Fixed by <code>shell: false</code> with the direct <code>.exe</code>
path, letting Node.js call <code>CreateProcess()</code> directly.</li>
</ul>
</div>
<div class="card">
<h3>Rojo settings panel: <code>claude</code> and <code>claude-code</code> endpoint types</h3>
<p>
Two new options added to the endpoint type selector:
</p>
<ul style="margin-top:0.5rem;line-height:1.9">
<li><strong>Claude API (Anthropic)</strong> — shows the API key field; hides the Base URL
row (not needed). Model field stays visible with a <code>&lt;datalist&gt;</code>
suggesting <code>claude-opus-4-8</code>, <code>claude-sonnet-4-6</code>,
<code>claude-haiku-4-5</code>.</li>
<li><strong>Claude Code (local, no key)</strong> — hides the external section and model
row entirely; shows a brief info note explaining that the local Claude Code login
is used.</li>
</ul>
<p style="margin-top:0.75rem">
<code>_updateEndpointVisibility()</code> controls all show/hide logic including the
<code>.rsp-url-row</code> sub-element inside the external section.
</p>
</div>
</section>
<section>
<h2>Key decisions</h2>
<div class="card">
<p>
<strong>Separate session stores per provider.</strong>
The OpenAI path keeps <code>BaseMessage[]</code> (LangChain); the Claude API path
keeps <code>Anthropic.MessageParam[]</code>; the Claude Code path keeps only a
<code>Set&lt;string&gt;</code> of initialised session IDs (history lives in the
claude subprocess's own storage). Mixing them would require format translation on
every turn.
</p>
</div>
<div class="card">
<p>
<strong><code>--append-system-prompt</code> instead of <code>--system-prompt</code>.</strong>
<code>--system-prompt</code> replaces Claude Code's entire built-in prompt, which
would break the subprocess's tool-use instructions. <code>--append-system-prompt</code>
adds the rojo's persona on top without disturbing the base behaviour.
</p>
</div>
<div class="card">
<p>
<strong><code>shell: false</code> with direct <code>.exe</code> path on Windows.</strong>
Using <code>shell: true</code> routes through <code>cmd.exe</code>, which interprets
spaces as argument separators and <code>?</code> as a wildcard — making multi-word
user messages arrive garbled. Direct process creation passes each argv element as a
properly quoted string.
</p>
</div>
<div class="card">
<p>
<strong>Claude Code as a no-key Claude backend.</strong>
Because the subprocess inherits the OS user's existing Claude Code login (keychain /
<code>~/.claude/</code>), no Anthropic API key is required in the rojo settings.
The Claude Code subprocess can also use its own full tool set (Read, Bash, Grep, etc.),
making it more capable than the pure API path for code-aware tasks.
</p>
</div>
</section>
<footer>
Roject &mdash; session history
</footer>
</div>
<script>var NAV_ROOT = '../../../../';</script>
<script src="../../../../_assets_/nav-data.js"></script>
<script src="../../../../_assets_/nav.js"></script>
</body>
</html>

View File

@ -16,6 +16,16 @@
<p class="subtitle">Log of all working sessions, most recent first.</p>
</header>
<section>
<h2>2026 — August</h2>
<div class="card">
<h3><a href="2026/08-August/02-Saturday/index.html">Saturday, 2 August 2026</a></h3>
<p>Rojo local-mode bug fixes: detection, creation, and system-prompt delivery (all caused by the same missing localRoot branching). Claude API provider: <code>@anthropic-ai/sdk</code> streaming, separate conversation history, tool use support. Claude Code subprocess provider: no API key needed, <code>--print --output-format stream-json --verbose</code>, <code>--session-id</code>/<code>--resume</code> for multi-turn. Settings panel: <code>claude</code> and <code>claude-code</code> endpoint types. Three bugs fixed: missing <code>--verbose</code>, stdin blocking, and <code>shell: true</code> mangling messages on Windows.</p>
</div>
</section>
<section>
<h2>2026 — July</h2>