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.33.2",
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",
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Agent API — exposes the Claude Agent SDK to workspace code.
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 → use the `claude_code` preset (built-in Claude Code tools + prompt).
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 previous call).
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 { log } from '../shared/logger.js';
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): { path: string } | { error: 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
- return { path: resolved };
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
- return { ok: false, error: result.error };
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
- log.info(`[agent-api] Query: msg="${req.message.slice(0, 80)}..." maxTurns=${maxTurns} timeout=${timeout}ms resume=${req.sessionId || 'none'}`);
145
-
146
- const claudeQuery = query({
147
- prompt: req.message,
148
- options: {
149
- cwd: WORKSPACE_DIR,
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('codex', ['app-server'], { stdio: ['pipe', 'pipe', 'pipe'] });
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
  }