bloby-bot 0.33.2 → 0.36.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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"releaseNotes": [
|
|
5
5
|
"1. # voice note (PTT bubble)",
|
|
6
6
|
"2. # audio file + caption",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@anthropic-ai/claude-agent-sdk": "^0.2.112",
|
|
56
56
|
"@clack/prompts": "^1.1.0",
|
|
57
|
+
"@openai/codex": "^0.128.0",
|
|
57
58
|
"@streamdown/code": "^1.1.1",
|
|
58
59
|
"@tailwindcss/vite": "^4.2.0",
|
|
59
60
|
"@vitejs/plugin-react": "^6.0.1",
|
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
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
|
|
29
|
+
import { createRequire } from 'module';
|
|
29
30
|
import readline from 'readline';
|
|
30
31
|
import fs from 'fs';
|
|
31
32
|
import path from 'path';
|
|
@@ -34,7 +35,7 @@ import { WORKSPACE_DIR } from '../../shared/paths.js';
|
|
|
34
35
|
import type { SavedFile } from '../file-saver.js';
|
|
35
36
|
import { getCodexAccessToken } from '../../worker/codex-auth.js';
|
|
36
37
|
import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
|
|
37
|
-
import type { OnAgentMessage, RecentMessage, AgentAttachment } from './types.js';
|
|
38
|
+
import type { OnAgentMessage, RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js';
|
|
38
39
|
export type { RecentMessage, AgentAttachment };
|
|
39
40
|
|
|
40
41
|
/* ── Constants ─────────────────────────────────────────────────────────── */
|
|
@@ -43,6 +44,45 @@ const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
|
|
|
43
44
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
44
45
|
const VALID_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh']);
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the `codex` binary. We don't trust $PATH because Bloby may be
|
|
49
|
+
* installed globally without `@openai/codex` also globally available. Order:
|
|
50
|
+
* 1. BLOBY_CODEX_BIN env override (advanced users / dev)
|
|
51
|
+
* 2. The `bin` entry from the bundled `@openai/codex` npm package
|
|
52
|
+
* (this is what `npm install bloby-bot` will pull in)
|
|
53
|
+
* 3. Fall back to `codex` on PATH (works when the user installed codex CLI
|
|
54
|
+
* separately, e.g. via apt/brew).
|
|
55
|
+
*/
|
|
56
|
+
let cachedCodexBin: string | null = null;
|
|
57
|
+
function resolveCodexBin(): string {
|
|
58
|
+
if (cachedCodexBin) return cachedCodexBin;
|
|
59
|
+
|
|
60
|
+
if (process.env.BLOBY_CODEX_BIN) {
|
|
61
|
+
cachedCodexBin = process.env.BLOBY_CODEX_BIN;
|
|
62
|
+
return cachedCodexBin;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
67
|
+
const pkgPath = requireFromHere.resolve('@openai/codex/package.json');
|
|
68
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
69
|
+
const binEntry = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex;
|
|
70
|
+
if (binEntry) {
|
|
71
|
+
const resolved = path.join(path.dirname(pkgPath), binEntry);
|
|
72
|
+
if (fs.existsSync(resolved)) {
|
|
73
|
+
cachedCodexBin = resolved;
|
|
74
|
+
log.info(`[codex] using bundled binary: ${resolved}`);
|
|
75
|
+
return cachedCodexBin;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// @openai/codex not installed as a dep — fall through.
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
cachedCodexBin = 'codex';
|
|
83
|
+
return cachedCodexBin;
|
|
84
|
+
}
|
|
85
|
+
|
|
46
86
|
/* ── Prompt-assembly helpers (duplicated from claude.ts to keep that file
|
|
47
87
|
* untouched per the project rule) ───────────────────────────────────── */
|
|
48
88
|
|
|
@@ -125,7 +165,7 @@ class CodexRpc {
|
|
|
125
165
|
private stderrBuf = '';
|
|
126
166
|
|
|
127
167
|
start(): void {
|
|
128
|
-
this.proc = spawn(
|
|
168
|
+
this.proc = spawn(resolveCodexBin(), ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
129
169
|
const rl = readline.createInterface({ input: this.proc.stdout });
|
|
130
170
|
rl.on('line', (line) => this.onLine(line));
|
|
131
171
|
|
|
@@ -170,6 +210,17 @@ class CodexRpc {
|
|
|
170
210
|
log.warn(`[codex-rpc] malformed JSON from server: ${line.slice(0, 200)}`);
|
|
171
211
|
return;
|
|
172
212
|
}
|
|
213
|
+
|
|
214
|
+
// Server-initiated REQUEST (has both id AND method) — must reply or the
|
|
215
|
+
// server hangs forever. Approval requests get auto-accepted to match our
|
|
216
|
+
// bypass-permissions posture; anything else gets a method-not-found
|
|
217
|
+
// error reply so we never silently stall.
|
|
218
|
+
if (typeof msg.id === 'number' && typeof msg.method === 'string') {
|
|
219
|
+
this.handleServerRequest(msg);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// RESPONSE to a request we sent.
|
|
173
224
|
if (typeof msg.id === 'number') {
|
|
174
225
|
const pending = this.pending.get(msg.id);
|
|
175
226
|
if (!pending) return;
|
|
@@ -179,11 +230,36 @@ class CodexRpc {
|
|
|
179
230
|
else pending.resolve(msg.result);
|
|
180
231
|
return;
|
|
181
232
|
}
|
|
233
|
+
|
|
234
|
+
// NOTIFICATION (no id).
|
|
182
235
|
if (typeof msg.method === 'string') {
|
|
183
236
|
this.notificationHandler({ method: msg.method, params: msg.params });
|
|
184
237
|
}
|
|
185
238
|
}
|
|
186
239
|
|
|
240
|
+
private handleServerRequest(msg: { id: number; method: string; params?: any }): void {
|
|
241
|
+
const isApproval = msg.method.endsWith('/requestApproval');
|
|
242
|
+
if (isApproval) {
|
|
243
|
+
log.info(`[codex-rpc] auto-accepting server request: ${msg.method}`);
|
|
244
|
+
this.respond(msg.id, 'acceptForSession');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying with error`);
|
|
248
|
+
this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private respond(id: number, result: any): void {
|
|
252
|
+
if (this.closed || !this.proc) return;
|
|
253
|
+
try { this.proc.stdin.write(JSON.stringify({ id, result }) + '\n'); }
|
|
254
|
+
catch (err: any) { log.warn(`[codex-rpc] respond failed: ${err.message}`); }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private respondError(id: number, code: number, message: string): void {
|
|
258
|
+
if (this.closed || !this.proc) return;
|
|
259
|
+
try { this.proc.stdin.write(JSON.stringify({ id, error: { code, message } }) + '\n'); }
|
|
260
|
+
catch (err: any) { log.warn(`[codex-rpc] respondError failed: ${err.message}`); }
|
|
261
|
+
}
|
|
262
|
+
|
|
187
263
|
request<T = any>(method: string, params?: any, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
|
|
188
264
|
if (this.closed || !this.proc) return Promise.reject(new Error('RPC connection closed'));
|
|
189
265
|
const id = this.nextId++;
|
|
@@ -486,6 +562,11 @@ async function spawnAndInitialize(
|
|
|
486
562
|
cwd: WORKSPACE_DIR,
|
|
487
563
|
model: modelId,
|
|
488
564
|
baseInstructions,
|
|
565
|
+
// Bloby's posture matches Claude's bypassPermissions — the bot is
|
|
566
|
+
// running on the user's own machine with their full consent. Skip the
|
|
567
|
+
// approval prompts and give it write access to the workspace + beyond.
|
|
568
|
+
approvalPolicy: 'never',
|
|
569
|
+
sandbox: 'danger-full-access',
|
|
489
570
|
});
|
|
490
571
|
conv.threadId = startResult.thread.id;
|
|
491
572
|
conversations.set(conversationId, conv);
|
|
@@ -589,3 +670,147 @@ export async function startBlobyAgentQuery(
|
|
|
589
670
|
export function stopBlobyAgentQuery(conversationId: string): void {
|
|
590
671
|
endConversation(conversationId);
|
|
591
672
|
}
|
|
673
|
+
|
|
674
|
+
// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* One-shot Codex query that spawns its own short-lived app-server, runs a
|
|
678
|
+
* single turn, returns the accumulated response, and tears down. Mirrors
|
|
679
|
+
* what `agent-api.ts` previously did directly with the Claude SDK.
|
|
680
|
+
*
|
|
681
|
+
* `sessionId` carries a Codex `threadId` — when present we issue a
|
|
682
|
+
* `thread/resume` instead of `thread/start` to preserve server-side context.
|
|
683
|
+
*/
|
|
684
|
+
export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
|
|
685
|
+
const token = await getCodexAccessToken();
|
|
686
|
+
if (!token) {
|
|
687
|
+
return { ok: false, error: 'Codex credentials not found or expired. Re-authenticate via the dashboard.' };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Pull the active model + parse effort suffix from the user's config —
|
|
691
|
+
// agent-api callers don't get to pick.
|
|
692
|
+
let model = 'gpt-5.5';
|
|
693
|
+
let effort: string | undefined;
|
|
694
|
+
try {
|
|
695
|
+
const { loadConfig: loadCfg } = await import('../../shared/config.js');
|
|
696
|
+
const cfg = loadCfg();
|
|
697
|
+
if (cfg.ai?.model) {
|
|
698
|
+
const parsed = parseModelString(cfg.ai.model);
|
|
699
|
+
model = parsed.id;
|
|
700
|
+
effort = parsed.effort;
|
|
701
|
+
}
|
|
702
|
+
} catch {}
|
|
703
|
+
|
|
704
|
+
const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);
|
|
705
|
+
|
|
706
|
+
const rpc = new CodexRpc();
|
|
707
|
+
rpc.start();
|
|
708
|
+
|
|
709
|
+
let fullText = '';
|
|
710
|
+
const usedTools = new Set<string>();
|
|
711
|
+
let usedFileTools = false;
|
|
712
|
+
let resolvedThreadId = req.sessionId || '';
|
|
713
|
+
let resolveTurn: (() => void) | null = null;
|
|
714
|
+
let turnError: string | null = null;
|
|
715
|
+
const turnDone = new Promise<void>((r) => { resolveTurn = r; });
|
|
716
|
+
|
|
717
|
+
rpc.onNotification((n) => {
|
|
718
|
+
const p = n.params || {};
|
|
719
|
+
switch (n.method) {
|
|
720
|
+
case 'item/agentMessage/delta': {
|
|
721
|
+
if (typeof p.delta === 'string') fullText += p.delta;
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
case 'item/started': {
|
|
725
|
+
const item = p.item || {};
|
|
726
|
+
if (item.type === 'commandExecution') usedTools.add('shell');
|
|
727
|
+
else if (item.type === 'mcpToolCall') usedTools.add(item.toolName || item.name || 'mcp_tool');
|
|
728
|
+
else if (item.type === 'fileChange') { usedTools.add('file_change'); usedFileTools = true; }
|
|
729
|
+
else if (item.type === 'webSearch') usedTools.add('web_search');
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
case 'item/completed': {
|
|
733
|
+
const item = p.item || {};
|
|
734
|
+
if (item.type === 'fileChange') usedFileTools = true;
|
|
735
|
+
if (item.type === 'agentMessage' && !fullText) {
|
|
736
|
+
const text = (item.content || []).map((c: any) => c.text || '').join('') || item.text || '';
|
|
737
|
+
if (text) fullText = text;
|
|
738
|
+
}
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
741
|
+
case 'turn/completed': {
|
|
742
|
+
const status = p.turn?.status || 'completed';
|
|
743
|
+
if (status === 'failed' || status === 'systemError') {
|
|
744
|
+
turnError = p.turn?.error?.message || 'Codex turn failed.';
|
|
745
|
+
}
|
|
746
|
+
resolveTurn?.();
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
case 'error': {
|
|
750
|
+
turnError = p.error?.message || 'Codex error';
|
|
751
|
+
resolveTurn?.();
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
const timeoutHandle = setTimeout(() => {
|
|
758
|
+
if (!turnError) turnError = `Query timed out after ${timeout}ms.`;
|
|
759
|
+
resolveTurn?.();
|
|
760
|
+
}, timeout);
|
|
761
|
+
|
|
762
|
+
try {
|
|
763
|
+
log.info(`[codex/agent-api] Query: msg="${req.message.slice(0, 80)}..." model=${model} resume=${req.sessionId || 'none'}`);
|
|
764
|
+
await rpc.request('initialize', { clientInfo: CLIENT_INFO });
|
|
765
|
+
rpc.notify('initialized', {});
|
|
766
|
+
|
|
767
|
+
if (req.sessionId) {
|
|
768
|
+
// Resume an existing thread (if codex still has it). Caller must accept
|
|
769
|
+
// failure here — we fall back to a fresh thread.
|
|
770
|
+
try {
|
|
771
|
+
const r = await rpc.request<{ thread: { id: string } }>('thread/resume', { threadId: req.sessionId });
|
|
772
|
+
resolvedThreadId = r.thread.id;
|
|
773
|
+
} catch (err: any) {
|
|
774
|
+
log.warn(`[codex/agent-api] thread/resume failed (${err.message}); starting fresh thread`);
|
|
775
|
+
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
776
|
+
cwd: WORKSPACE_DIR,
|
|
777
|
+
model,
|
|
778
|
+
...(req.systemPrompt ? { baseInstructions: req.systemPrompt } : {}),
|
|
779
|
+
approvalPolicy: 'never',
|
|
780
|
+
sandbox: 'danger-full-access',
|
|
781
|
+
});
|
|
782
|
+
resolvedThreadId = r.thread.id;
|
|
783
|
+
}
|
|
784
|
+
} else {
|
|
785
|
+
const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
|
|
786
|
+
cwd: WORKSPACE_DIR,
|
|
787
|
+
model,
|
|
788
|
+
...(req.systemPrompt ? { baseInstructions: req.systemPrompt } : {}),
|
|
789
|
+
approvalPolicy: 'never',
|
|
790
|
+
sandbox: 'danger-full-access',
|
|
791
|
+
});
|
|
792
|
+
resolvedThreadId = r.thread.id;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const turnParams: Record<string, any> = {
|
|
796
|
+
threadId: resolvedThreadId,
|
|
797
|
+
input: [{ type: 'text', text: req.message }],
|
|
798
|
+
};
|
|
799
|
+
if (effort) turnParams.effort = effort;
|
|
800
|
+
await rpc.request('turn/start', turnParams);
|
|
801
|
+
|
|
802
|
+
await turnDone;
|
|
803
|
+
|
|
804
|
+
if (turnError) {
|
|
805
|
+
return { ok: false, error: turnError, sessionId: resolvedThreadId, toolsUsed: Array.from(usedTools) };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
log.info(`[codex/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], thread=${resolvedThreadId}`);
|
|
809
|
+
return { ok: true, response: fullText, sessionId: resolvedThreadId, toolsUsed: Array.from(usedTools), usedFileTools };
|
|
810
|
+
} catch (err: any) {
|
|
811
|
+
return { ok: false, error: err?.message || String(err), sessionId: resolvedThreadId };
|
|
812
|
+
} finally {
|
|
813
|
+
clearTimeout(timeoutHandle);
|
|
814
|
+
rpc.close();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
@@ -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
|
}
|