bloby-bot 0.33.2 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.33.2",
3
+ "version": "0.35.0",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -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
+ }
@@ -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
  }