bloby-bot 0.33.1 → 0.35.0
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/package.json
CHANGED
package/supervisor/agent-api.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent API — exposes the
|
|
2
|
+
* Agent API — exposes the active agent harness to workspace code.
|
|
3
3
|
*
|
|
4
4
|
* Single endpoint: POST /api/agent/query
|
|
5
5
|
*
|
|
6
6
|
* If `systemPromptPath` is provided → read it from workspace, use as systemPrompt.
|
|
7
|
-
* If omitted →
|
|
7
|
+
* If omitted → harness picks its own default coding-agent prompt
|
|
8
|
+
* (Claude → `claude_code` preset; Codex → its built-in agent prompt).
|
|
8
9
|
*
|
|
9
|
-
* Supports session persistence via `sessionId` (pass the one returned from a
|
|
10
|
+
* Supports session persistence via `sessionId` (pass the one returned from a
|
|
11
|
+
* previous call). Provider-specific: Claude uses SDK session_id, Codex uses
|
|
12
|
+
* threadId — but the value is opaque to the caller.
|
|
10
13
|
*
|
|
11
14
|
* Auth: per-session secret injected into the workspace backend as BLOBY_AGENT_SECRET.
|
|
12
15
|
* Safety: localhost-only, path traversal prevention, concurrency + rate limits, timeouts.
|
|
@@ -14,10 +17,8 @@
|
|
|
14
17
|
|
|
15
18
|
import fs from 'fs';
|
|
16
19
|
import path from 'path';
|
|
17
|
-
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
18
20
|
import { WORKSPACE_DIR } from '../shared/paths.js';
|
|
19
|
-
import {
|
|
20
|
-
import { getClaudeAccessToken } from '../worker/claude-auth.js';
|
|
21
|
+
import { runAgentQuery } from './bloby-agent.js';
|
|
21
22
|
|
|
22
23
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -25,7 +26,7 @@ export interface AgentQueryRequest {
|
|
|
25
26
|
message: string;
|
|
26
27
|
systemPromptPath?: string; // relative to workspace, e.g. "skills/my-skill/prompt.txt"
|
|
27
28
|
sessionId?: string; // resume a previous session
|
|
28
|
-
maxTurns?: number; // default 25, max 50
|
|
29
|
+
maxTurns?: number; // default 25, max 50 (Claude only)
|
|
29
30
|
timeout?: number; // ms, default 120_000, max 300_000
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -48,14 +49,11 @@ let activeQueries = 0;
|
|
|
48
49
|
const requestTimestamps: number[] = [];
|
|
49
50
|
|
|
50
51
|
function checkRateLimit(): string | null {
|
|
51
|
-
// Concurrency check
|
|
52
52
|
if (activeQueries >= MAX_CONCURRENT) {
|
|
53
53
|
return `Too many concurrent queries (max ${MAX_CONCURRENT}). Try again shortly.`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
// Rate limit check
|
|
57
56
|
const now = Date.now();
|
|
58
|
-
// Prune old timestamps
|
|
59
57
|
while (requestTimestamps.length && requestTimestamps[0]! < now - RATE_LIMIT_WINDOW) {
|
|
60
58
|
requestTimestamps.shift();
|
|
61
59
|
}
|
|
@@ -68,11 +66,10 @@ function checkRateLimit(): string | null {
|
|
|
68
66
|
|
|
69
67
|
// ── Path Validation ────────────────────────────────────────────────────────
|
|
70
68
|
|
|
71
|
-
function resolveSystemPromptPath(relPath: string): {
|
|
69
|
+
function resolveSystemPromptPath(relPath: string): { content: string } | { error: string } {
|
|
72
70
|
const resolved = path.resolve(WORKSPACE_DIR, relPath);
|
|
73
71
|
const workspaceBoundary = WORKSPACE_DIR + path.sep;
|
|
74
72
|
|
|
75
|
-
// Must be inside workspace (not parent traversal)
|
|
76
73
|
if (!resolved.startsWith(workspaceBoundary) && resolved !== WORKSPACE_DIR) {
|
|
77
74
|
return { error: 'System prompt path must be within the workspace directory.' };
|
|
78
75
|
}
|
|
@@ -81,144 +78,45 @@ function resolveSystemPromptPath(relPath: string): { path: string } | { error: s
|
|
|
81
78
|
return { error: `System prompt file not found: ${relPath}` };
|
|
82
79
|
}
|
|
83
80
|
|
|
84
|
-
|
|
81
|
+
try {
|
|
82
|
+
const content = fs.readFileSync(resolved, 'utf-8').trim();
|
|
83
|
+
if (!content) return { error: 'System prompt file is empty.' };
|
|
84
|
+
return { content };
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
return { error: `Failed to read system prompt: ${err.message}` };
|
|
87
|
+
}
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
// ── Main Query Handler ─────────────────────────────────────────────────────
|
|
88
91
|
|
|
89
92
|
export async function handleAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResponse> {
|
|
90
|
-
// ── Validate inputs ──
|
|
91
93
|
if (!req.message || typeof req.message !== 'string') {
|
|
92
94
|
return { ok: false, error: 'Missing or invalid "message" field.' };
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
const maxTurns = Math.min(Math.max(req.maxTurns || 25, 1), 50);
|
|
96
|
-
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
97
|
-
|
|
98
|
-
// ── Rate limit ──
|
|
99
97
|
const rateLimitError = checkRateLimit();
|
|
100
|
-
if (rateLimitError) {
|
|
101
|
-
return { ok: false, error: rateLimitError };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── Resolve system prompt ──
|
|
105
|
-
let systemPrompt: string | { type: 'preset'; preset: 'claude_code' };
|
|
98
|
+
if (rateLimitError) return { ok: false, error: rateLimitError };
|
|
106
99
|
|
|
100
|
+
let systemPrompt: string | undefined;
|
|
107
101
|
if (req.systemPromptPath) {
|
|
108
102
|
const result = resolveSystemPromptPath(req.systemPromptPath);
|
|
109
|
-
if ('error' in result) {
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
try {
|
|
113
|
-
const content = fs.readFileSync(result.path, 'utf-8').trim();
|
|
114
|
-
if (!content) {
|
|
115
|
-
return { ok: false, error: 'System prompt file is empty.' };
|
|
116
|
-
}
|
|
117
|
-
systemPrompt = content;
|
|
118
|
-
} catch (err: any) {
|
|
119
|
-
return { ok: false, error: `Failed to read system prompt: ${err.message}` };
|
|
120
|
-
}
|
|
121
|
-
} else {
|
|
122
|
-
systemPrompt = { type: 'preset', preset: 'claude_code' };
|
|
103
|
+
if ('error' in result) return { ok: false, error: result.error };
|
|
104
|
+
systemPrompt = result.content;
|
|
123
105
|
}
|
|
124
106
|
|
|
125
|
-
// ── OAuth token ──
|
|
126
|
-
const oauthToken = await getClaudeAccessToken();
|
|
127
|
-
if (!oauthToken) {
|
|
128
|
-
return { ok: false, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── Execute query ──
|
|
132
107
|
activeQueries++;
|
|
133
108
|
requestTimestamps.push(Date.now());
|
|
134
109
|
|
|
135
|
-
const abortController = new AbortController();
|
|
136
|
-
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
|
|
137
|
-
|
|
138
|
-
let fullText = '';
|
|
139
|
-
const usedTools = new Set<string>();
|
|
140
|
-
let sessionId: string | undefined;
|
|
141
|
-
let stderrBuf = '';
|
|
142
|
-
|
|
143
110
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
permissionMode: 'bypassPermissions',
|
|
151
|
-
allowDangerouslySkipPermissions: true,
|
|
152
|
-
maxTurns,
|
|
153
|
-
abortController,
|
|
154
|
-
systemPrompt: systemPrompt as any,
|
|
155
|
-
...(req.sessionId ? { resume: req.sessionId } : {}),
|
|
156
|
-
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
157
|
-
env: {
|
|
158
|
-
...process.env as Record<string, string>,
|
|
159
|
-
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
160
|
-
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
161
|
-
},
|
|
162
|
-
},
|
|
111
|
+
const result = await runAgentQuery({
|
|
112
|
+
message: req.message,
|
|
113
|
+
systemPrompt,
|
|
114
|
+
sessionId: req.sessionId,
|
|
115
|
+
maxTurns: req.maxTurns,
|
|
116
|
+
timeout: req.timeout,
|
|
163
117
|
});
|
|
164
|
-
|
|
165
|
-
for await (const msg of claudeQuery) {
|
|
166
|
-
if (abortController.signal.aborted) break;
|
|
167
|
-
|
|
168
|
-
switch (msg.type) {
|
|
169
|
-
case 'assistant': {
|
|
170
|
-
const assistantMsg = (msg as any).message;
|
|
171
|
-
if (!assistantMsg?.content) break;
|
|
172
|
-
for (const block of assistantMsg.content) {
|
|
173
|
-
if (block.type === 'text' && block.text) {
|
|
174
|
-
if (fullText && !fullText.endsWith('\n')) fullText += '\n\n';
|
|
175
|
-
fullText += block.text;
|
|
176
|
-
} else if (block.type === 'tool_use') {
|
|
177
|
-
usedTools.add(block.name);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
break;
|
|
181
|
-
}
|
|
182
|
-
case 'result': {
|
|
183
|
-
// Extract session_id from result for persistence
|
|
184
|
-
sessionId = (msg as any).session_id;
|
|
185
|
-
|
|
186
|
-
if (!fullText && (msg as any).subtype?.startsWith('error')) {
|
|
187
|
-
const errors = (msg as any).errors?.join('; ') || 'Agent query failed';
|
|
188
|
-
return {
|
|
189
|
-
ok: false,
|
|
190
|
-
error: errors,
|
|
191
|
-
sessionId,
|
|
192
|
-
toolsUsed: Array.from(usedTools),
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const FILE_TOOLS = ['Write', 'Edit'];
|
|
201
|
-
const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
|
|
202
|
-
|
|
203
|
-
log.info(`[agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`);
|
|
204
|
-
|
|
205
|
-
return {
|
|
206
|
-
ok: true,
|
|
207
|
-
response: fullText,
|
|
208
|
-
sessionId,
|
|
209
|
-
toolsUsed: Array.from(usedTools),
|
|
210
|
-
usedFileTools,
|
|
211
|
-
};
|
|
212
|
-
} catch (err: any) {
|
|
213
|
-
if (abortController.signal.aborted) {
|
|
214
|
-
return { ok: false, error: 'Query timed out.', sessionId };
|
|
215
|
-
}
|
|
216
|
-
const detail = stderrBuf.trim();
|
|
217
|
-
const errMsg = detail ? `${err.message}\n\n${detail}` : err.message;
|
|
218
|
-
log.warn(`[agent-api] Error: ${errMsg}`);
|
|
219
|
-
return { ok: false, error: errMsg, sessionId };
|
|
118
|
+
return result;
|
|
220
119
|
} finally {
|
|
221
|
-
clearTimeout(timeoutHandle);
|
|
222
120
|
activeQueries--;
|
|
223
121
|
}
|
|
224
122
|
}
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
|
|
18
18
|
import * as claude from './harnesses/claude.js';
|
|
19
19
|
import * as codex from './harnesses/codex.js';
|
|
20
|
-
import type { Harness, OnAgentMessage, RecentMessage, AgentAttachment } from './harnesses/types.js';
|
|
20
|
+
import type { Harness, OnAgentMessage, RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './harnesses/types.js';
|
|
21
21
|
import type { SavedFile } from './file-saver.js';
|
|
22
22
|
import { loadConfig } from '../shared/config.js';
|
|
23
23
|
|
|
24
|
-
export type { RecentMessage, AgentAttachment };
|
|
24
|
+
export type { RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult };
|
|
25
25
|
|
|
26
26
|
const HARNESSES: Record<string, Harness> = {
|
|
27
27
|
anthropic: claude,
|
|
@@ -123,3 +123,9 @@ export function startBlobyAgentQuery(
|
|
|
123
123
|
export function stopBlobyAgentQuery(conversationId: string): void {
|
|
124
124
|
for (const h of Object.values(HARNESSES)) h.stopBlobyAgentQuery(conversationId);
|
|
125
125
|
}
|
|
126
|
+
|
|
127
|
+
/* ── Workspace agent endpoint ──────────────────────────────────────────── */
|
|
128
|
+
|
|
129
|
+
export function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
|
|
130
|
+
return activeHarness().runAgentQuery(req);
|
|
131
|
+
}
|
|
@@ -23,7 +23,7 @@ import { preWarm, claimWarmup, discardWarmup } from '../cli-warmup.js';
|
|
|
23
23
|
|
|
24
24
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
25
25
|
|
|
26
|
-
import type { RecentMessage, AgentAttachment } from './types.js';
|
|
26
|
+
import type { RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js';
|
|
27
27
|
export type { RecentMessage, AgentAttachment };
|
|
28
28
|
|
|
29
29
|
// ── Async Queue ────────────────────────────────────────────────────────────
|
|
@@ -682,3 +682,95 @@ export function stopBlobyAgentQuery(conversationId: string): void {
|
|
|
682
682
|
activeQueries.delete(conversationId);
|
|
683
683
|
}
|
|
684
684
|
}
|
|
685
|
+
|
|
686
|
+
// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────
|
|
687
|
+
|
|
688
|
+
export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
|
|
689
|
+
const oauthToken = await getClaudeAccessToken();
|
|
690
|
+
if (!oauthToken) {
|
|
691
|
+
return { ok: false, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const maxTurns = Math.min(Math.max(req.maxTurns || 25, 1), 50);
|
|
695
|
+
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
696
|
+
|
|
697
|
+
// Empty/missing systemPrompt → fall back to Claude's built-in `claude_code`
|
|
698
|
+
// preset (its native coding-agent prompt + tools).
|
|
699
|
+
const systemPrompt: string | { type: 'preset'; preset: 'claude_code' } =
|
|
700
|
+
req.systemPrompt ? req.systemPrompt : { type: 'preset', preset: 'claude_code' };
|
|
701
|
+
|
|
702
|
+
const abortController = new AbortController();
|
|
703
|
+
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
|
|
704
|
+
|
|
705
|
+
let fullText = '';
|
|
706
|
+
const usedTools = new Set<string>();
|
|
707
|
+
let sessionId: string | undefined;
|
|
708
|
+
let stderrBuf = '';
|
|
709
|
+
|
|
710
|
+
try {
|
|
711
|
+
log.info(`[claude/agent-api] Query: msg="${req.message.slice(0, 80)}..." maxTurns=${maxTurns} timeout=${timeout}ms resume=${req.sessionId || 'none'}`);
|
|
712
|
+
|
|
713
|
+
const claudeQuery = query({
|
|
714
|
+
prompt: req.message,
|
|
715
|
+
options: {
|
|
716
|
+
cwd: WORKSPACE_DIR,
|
|
717
|
+
permissionMode: 'bypassPermissions',
|
|
718
|
+
allowDangerouslySkipPermissions: true,
|
|
719
|
+
maxTurns,
|
|
720
|
+
abortController,
|
|
721
|
+
systemPrompt: systemPrompt as any,
|
|
722
|
+
...(req.sessionId ? { resume: req.sessionId } : {}),
|
|
723
|
+
stderr: (chunk: string) => { stderrBuf += chunk; },
|
|
724
|
+
env: {
|
|
725
|
+
...process.env as Record<string, string>,
|
|
726
|
+
CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
|
|
727
|
+
CLAUDE_CODE_BUBBLEWRAP: '1',
|
|
728
|
+
},
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
for await (const msg of claudeQuery) {
|
|
733
|
+
if (abortController.signal.aborted) break;
|
|
734
|
+
|
|
735
|
+
switch (msg.type) {
|
|
736
|
+
case 'assistant': {
|
|
737
|
+
const assistantMsg = (msg as any).message;
|
|
738
|
+
if (!assistantMsg?.content) break;
|
|
739
|
+
for (const block of assistantMsg.content) {
|
|
740
|
+
if (block.type === 'text' && block.text) {
|
|
741
|
+
if (fullText && !fullText.endsWith('\n')) fullText += '\n\n';
|
|
742
|
+
fullText += block.text;
|
|
743
|
+
} else if (block.type === 'tool_use') {
|
|
744
|
+
usedTools.add(block.name);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
case 'result': {
|
|
750
|
+
sessionId = (msg as any).session_id;
|
|
751
|
+
if (!fullText && (msg as any).subtype?.startsWith('error')) {
|
|
752
|
+
return {
|
|
753
|
+
ok: false,
|
|
754
|
+
error: (msg as any).errors?.join('; ') || 'Agent query failed',
|
|
755
|
+
sessionId,
|
|
756
|
+
toolsUsed: Array.from(usedTools),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const usedFileTools = ['Write', 'Edit'].some((t) => usedTools.has(t));
|
|
765
|
+
log.info(`[claude/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`);
|
|
766
|
+
return { ok: true, response: fullText, sessionId, toolsUsed: Array.from(usedTools), usedFileTools };
|
|
767
|
+
} catch (err: any) {
|
|
768
|
+
if (abortController.signal.aborted) return { ok: false, error: 'Query timed out.', sessionId };
|
|
769
|
+
const detail = stderrBuf.trim();
|
|
770
|
+
const errMsg = detail ? `${err.message}\n\n${detail}` : err.message;
|
|
771
|
+
log.warn(`[claude/agent-api] Error: ${errMsg}`);
|
|
772
|
+
return { ok: false, error: errMsg, sessionId };
|
|
773
|
+
} finally {
|
|
774
|
+
clearTimeout(timeoutHandle);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
@@ -34,7 +34,7 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
|
34
34
|
import type { SavedFile } from '../file-saver.js';
|
|
35
35
|
import { getCodexAccessToken } from '../../worker/codex-auth.js';
|
|
36
36
|
import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
|
|
37
|
-
import type { OnAgentMessage, RecentMessage, AgentAttachment } from './types.js';
|
|
37
|
+
import type { OnAgentMessage, RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js';
|
|
38
38
|
export type { RecentMessage, AgentAttachment };
|
|
39
39
|
|
|
40
40
|
/* ── Constants ─────────────────────────────────────────────────────────── */
|
|
@@ -170,6 +170,17 @@ class CodexRpc {
|
|
|
170
170
|
log.warn(`[codex-rpc] malformed JSON from server: ${line.slice(0, 200)}`);
|
|
171
171
|
return;
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
// Server-initiated REQUEST (has both id AND method) — must reply or the
|
|
175
|
+
// server hangs forever. Approval requests get auto-accepted to match our
|
|
176
|
+
// bypass-permissions posture; anything else gets a method-not-found
|
|
177
|
+
// error reply so we never silently stall.
|
|
178
|
+
if (typeof msg.id === 'number' && typeof msg.method === 'string') {
|
|
179
|
+
this.handleServerRequest(msg);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// RESPONSE to a request we sent.
|
|
173
184
|
if (typeof msg.id === 'number') {
|
|
174
185
|
const pending = this.pending.get(msg.id);
|
|
175
186
|
if (!pending) return;
|
|
@@ -179,11 +190,36 @@ class CodexRpc {
|
|
|
179
190
|
else pending.resolve(msg.result);
|
|
180
191
|
return;
|
|
181
192
|
}
|
|
193
|
+
|
|
194
|
+
// NOTIFICATION (no id).
|
|
182
195
|
if (typeof msg.method === 'string') {
|
|
183
196
|
this.notificationHandler({ method: msg.method, params: msg.params });
|
|
184
197
|
}
|
|
185
198
|
}
|
|
186
199
|
|
|
200
|
+
private handleServerRequest(msg: { id: number; method: string; params?: any }): void {
|
|
201
|
+
const isApproval = msg.method.endsWith('/requestApproval');
|
|
202
|
+
if (isApproval) {
|
|
203
|
+
log.info(`[codex-rpc] auto-accepting server request: ${msg.method}`);
|
|
204
|
+
this.respond(msg.id, 'acceptForSession');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying with error`);
|
|
208
|
+
this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private respond(id: number, result: any): void {
|
|
212
|
+
if (this.closed || !this.proc) return;
|
|
213
|
+
try { this.proc.stdin.write(JSON.stringify({ id, result }) + '\n'); }
|
|
214
|
+
catch (err: any) { log.warn(`[codex-rpc] respond failed: ${err.message}`); }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private respondError(id: number, code: number, message: string): void {
|
|
218
|
+
if (this.closed || !this.proc) return;
|
|
219
|
+
try { this.proc.stdin.write(JSON.stringify({ id, error: { code, message } }) + '\n'); }
|
|
220
|
+
catch (err: any) { log.warn(`[codex-rpc] respondError failed: ${err.message}`); }
|
|
221
|
+
}
|
|
222
|
+
|
|
187
223
|
request<T = any>(method: string, params?: any, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
|
188
224
|
if (this.closed || !this.proc) return Promise.reject(new Error('RPC connection closed'));
|
|
189
225
|
const id = this.nextId++;
|
|
@@ -486,6 +522,11 @@ async function spawnAndInitialize(
|
|
|
486
522
|
cwd: WORKSPACE_DIR,
|
|
487
523
|
model: modelId,
|
|
488
524
|
baseInstructions,
|
|
525
|
+
// Bloby's posture matches Claude's bypassPermissions — the bot is
|
|
526
|
+
// running on the user's own machine with their full consent. Skip the
|
|
527
|
+
// approval prompts and give it write access to the workspace + beyond.
|
|
528
|
+
approvalPolicy: 'never',
|
|
529
|
+
sandbox: 'danger-full-access',
|
|
489
530
|
});
|
|
490
531
|
conv.threadId = startResult.thread.id;
|
|
491
532
|
conversations.set(conversationId, conv);
|
|
@@ -589,3 +630,147 @@ export async function startBlobyAgentQuery(
|
|
|
589
630
|
export function stopBlobyAgentQuery(conversationId: string): void {
|
|
590
631
|
endConversation(conversationId);
|
|
591
632
|
}
|
|
633
|
+
|
|
634
|
+
// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* One-shot Codex query that spawns its own short-lived app-server, runs a
|
|
638
|
+
* single turn, returns the accumulated response, and tears down. Mirrors
|
|
639
|
+
* what `agent-api.ts` previously did directly with the Claude SDK.
|
|
640
|
+
*
|
|
641
|
+
* `sessionId` carries a Codex `threadId` — when present we issue a
|
|
642
|
+
* `thread/resume` instead of `thread/start` to preserve server-side context.
|
|
643
|
+
*/
|
|
644
|
+
export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
|
|
645
|
+
const token = await getCodexAccessToken();
|
|
646
|
+
if (!token) {
|
|
647
|
+
return { ok: false, error: 'Codex credentials not found or expired. Re-authenticate via the dashboard.' };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Pull the active model + parse effort suffix from the user's config —
|
|
651
|
+
// agent-api callers don't get to pick.
|
|
652
|
+
let model = 'gpt-5.5';
|
|
653
|
+
let effort: string | undefined;
|
|
654
|
+
try {
|
|
655
|
+
const { loadConfig: loadCfg } = await import('../../shared/config.js');
|
|
656
|
+
const cfg = loadCfg();
|
|
657
|
+
if (cfg.ai?.model) {
|
|
658
|
+
const parsed = parseModelString(cfg.ai.model);
|
|
659
|
+
model = parsed.id;
|
|
660
|
+
effort = parsed.effort;
|
|
661
|
+
}
|
|
662
|
+
} catch {}
|
|
663
|
+
|
|
664
|
+
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
665
|
+
|
|
666
|
+
const rpc = new CodexRpc();
|
|
667
|
+
rpc.start();
|
|
668
|
+
|
|
669
|
+
let fullText = '';
|
|
670
|
+
const usedTools = new Set<string>();
|
|
671
|
+
let usedFileTools = false;
|
|
672
|
+
let resolvedThreadId = req.sessionId || '';
|
|
673
|
+
let resolveTurn: (() => void) | null = null;
|
|
674
|
+
let turnError: string | null = null;
|
|
675
|
+
const turnDone = new Promise<void>((r) => { resolveTurn = r; });
|
|
676
|
+
|
|
677
|
+
rpc.onNotification((n) => {
|
|
678
|
+
const p = n.params || {};
|
|
679
|
+
switch (n.method) {
|
|
680
|
+
case 'item/agentMessage/delta': {
|
|
681
|
+
if (typeof p.delta === 'string') fullText += p.delta;
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
case 'item/started': {
|
|
685
|
+
const item = p.item || {};
|
|
686
|
+
if (item.type === 'commandExecution') usedTools.add('shell');
|
|
687
|
+
else if (item.type === 'mcpToolCall') usedTools.add(item.toolName || item.name || 'mcp_tool');
|
|
688
|
+
else if (item.type === 'fileChange') { usedTools.add('file_change'); usedFileTools = true; }
|
|
689
|
+
else if (item.type === 'webSearch') usedTools.add('web_search');
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
case 'item/completed': {
|
|
693
|
+
const item = p.item || {};
|
|
694
|
+
if (item.type === 'fileChange') usedFileTools = true;
|
|
695
|
+
if (item.type === 'agentMessage' && !fullText) {
|
|
696
|
+
const text = (item.content || []).map((c: any) => c.text || '').join('') || item.text || '';
|
|
697
|
+
if (text) fullText = text;
|
|
698
|
+
}
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
case 'turn/completed': {
|
|
702
|
+
const status = p.turn?.status || 'completed';
|
|
703
|
+
if (status === 'failed' || status === 'systemError') {
|
|
704
|
+
turnError = p.turn?.error?.message || 'Codex turn failed.';
|
|
705
|
+
}
|
|
706
|
+
resolveTurn?.();
|
|
707
|
+
break;
|
|
708
|
+
}
|
|
709
|
+
case 'error': {
|
|
710
|
+
turnError = p.error?.message || 'Codex error';
|
|
711
|
+
resolveTurn?.();
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const timeoutHandle = setTimeout(() => {
|
|
718
|
+
if (!turnError) turnError = `Query timed out after ${timeout}ms.`;
|
|
719
|
+
resolveTurn?.();
|
|
720
|
+
}, timeout);
|
|
721
|
+
|
|
722
|
+
try {
|
|
723
|
+
log.info(`[codex/agent-api] Query: msg="${req.message.slice(0, 80)}..." model=${model} resume=${req.sessionId || 'none'}`);
|
|
724
|
+
await rpc.request('initialize', { clientInfo: CLIENT_INFO });
|
|
725
|
+
rpc.notify('initialized', {});
|
|
726
|
+
|
|
727
|
+
if (req.sessionId) {
|
|
728
|
+
// Resume an existing thread (if codex still has it). Caller must accept
|
|
729
|
+
// failure here — we fall back to a fresh thread.
|
|
730
|
+
try {
|
|
731
|
+
const r = await rpc.request<{ thread: { id: string } }>('thread/resume', { threadId: req.sessionId });
|
|
732
|
+
resolvedThreadId = r.thread.id;
|
|
733
|
+
} catch (err: any) {
|
|
734
|
+
log.warn(`[codex/agent-api] thread/resume failed (${err.message}); starting fresh thread`);
|
|
735
|
+
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
736
|
+
cwd: WORKSPACE_DIR,
|
|
737
|
+
model,
|
|
738
|
+
...(req.systemPrompt ? { baseInstructions: req.systemPrompt } : {}),
|
|
739
|
+
approvalPolicy: 'never',
|
|
740
|
+
sandbox: 'danger-full-access',
|
|
741
|
+
});
|
|
742
|
+
resolvedThreadId = r.thread.id;
|
|
743
|
+
}
|
|
744
|
+
} else {
|
|
745
|
+
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
746
|
+
cwd: WORKSPACE_DIR,
|
|
747
|
+
model,
|
|
748
|
+
...(req.systemPrompt ? { baseInstructions: req.systemPrompt } : {}),
|
|
749
|
+
approvalPolicy: 'never',
|
|
750
|
+
sandbox: 'danger-full-access',
|
|
751
|
+
});
|
|
752
|
+
resolvedThreadId = r.thread.id;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const turnParams: Record<string, any> = {
|
|
756
|
+
threadId: resolvedThreadId,
|
|
757
|
+
input: [{ type: 'text', text: req.message }],
|
|
758
|
+
};
|
|
759
|
+
if (effort) turnParams.effort = effort;
|
|
760
|
+
await rpc.request('turn/start', turnParams);
|
|
761
|
+
|
|
762
|
+
await turnDone;
|
|
763
|
+
|
|
764
|
+
if (turnError) {
|
|
765
|
+
return { ok: false, error: turnError, sessionId: resolvedThreadId, toolsUsed: Array.from(usedTools) };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
log.info(`[codex/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], thread=${resolvedThreadId}`);
|
|
769
|
+
return { ok: true, response: fullText, sessionId: resolvedThreadId, toolsUsed: Array.from(usedTools), usedFileTools };
|
|
770
|
+
} catch (err: any) {
|
|
771
|
+
return { ok: false, error: err?.message || String(err), sessionId: resolvedThreadId };
|
|
772
|
+
} finally {
|
|
773
|
+
clearTimeout(timeoutHandle);
|
|
774
|
+
rpc.close();
|
|
775
|
+
}
|
|
776
|
+
}
|
|
@@ -78,4 +78,29 @@ export interface Harness {
|
|
|
78
78
|
): Promise<void>;
|
|
79
79
|
|
|
80
80
|
stopBlobyAgentQuery(conversationId: string): void;
|
|
81
|
+
|
|
82
|
+
/* ── Workspace agent endpoint (POST /api/agent/query) ── */
|
|
83
|
+
runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AgentQueryRequest {
|
|
87
|
+
message: string;
|
|
88
|
+
/** Already-resolved system prompt content (not a path). Empty/omitted → use the harness's default coding-agent prompt. */
|
|
89
|
+
systemPrompt?: string;
|
|
90
|
+
/** Provider-specific session id to resume (Claude: SDK session_id; Codex: threadId). */
|
|
91
|
+
sessionId?: string;
|
|
92
|
+
/** Max turns (Claude only — Codex has no equivalent). */
|
|
93
|
+
maxTurns?: number;
|
|
94
|
+
/** Hard timeout in ms. */
|
|
95
|
+
timeout?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface AgentQueryResult {
|
|
99
|
+
ok: boolean;
|
|
100
|
+
response?: string;
|
|
101
|
+
/** Provider-specific session id the caller can pass back to resume this conversation. */
|
|
102
|
+
sessionId?: string;
|
|
103
|
+
toolsUsed?: string[];
|
|
104
|
+
usedFileTools?: boolean;
|
|
105
|
+
error?: string;
|
|
81
106
|
}
|
package/supervisor/index.ts
CHANGED
|
@@ -1305,8 +1305,11 @@ ${!connected ? `<script>
|
|
|
1305
1305
|
|
|
1306
1306
|
log.info(`[bloby] provider=${freshConfig.ai.provider}, model=${freshConfig.ai.model}`);
|
|
1307
1307
|
|
|
1308
|
-
// Route
|
|
1309
|
-
|
|
1308
|
+
// Route through the agent harness for any provider that has one
|
|
1309
|
+
// (Anthropic → Claude SDK, OpenAI → Codex app-server). The dispatcher
|
|
1310
|
+
// in bloby-agent.ts picks the right harness; both use OAuth tokens
|
|
1311
|
+
// from their own credentials files, not config.ai.apiKey.
|
|
1312
|
+
if (freshConfig.ai.provider === 'anthropic' || freshConfig.ai.provider === 'openai') {
|
|
1310
1313
|
// Server-side persistence: create or reuse DB conversation, save user message
|
|
1311
1314
|
(async () => {
|
|
1312
1315
|
// Save attachments to disk (before try so it's accessible in startBlobyAgentQuery below)
|