jeo-code 0.7.32 → 0.7.35

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/src/agent/plan.ts CHANGED
@@ -13,6 +13,11 @@ export const StepSchema = z.object({
13
13
  name: z.string(),
14
14
  /** Optional subagent role for this step (executor/planner/architect/critic). */
15
15
  role: z.string().optional(),
16
+ /** Optional opt-in marker for a CONTIGUOUS run of steps `jeo team` may execute
17
+ * concurrently (git-worktree isolated) instead of strictly serially. Steps
18
+ * sharing the same non-empty value must be mutually consecutive — validated
19
+ * on `PlanSchema` below, since contiguity is a plan-wide property. */
20
+ parallel_group: z.string().optional(),
16
21
  }).passthrough().superRefine((step, ctx) => {
17
22
  for (const key of UNSUPPORTED_DEP_KEYS) {
18
23
  if (key in step) {
@@ -27,7 +32,28 @@ export const StepSchema = z.object({
27
32
  export const PlanSchema = z.object({
28
33
  name: z.string().optional(),
29
34
  steps: z.array(StepSchema).min(1),
30
- }).passthrough();
35
+ }).passthrough().superRefine((plan, ctx) => {
36
+ // `parallel_group` steps must form a CONTIGUOUS run: once a group value is
37
+ // seen and then interrupted by a different (or absent) group, that value must
38
+ // never reappear later — `jeo team` dispatches a group as one concurrent batch
39
+ // located at a single position in the array, so a split group has no coherent
40
+ // execution position.
41
+ const closedGroups = new Set<string>();
42
+ let prevGroup: string | undefined;
43
+ for (const step of plan.steps) {
44
+ const group = step.parallel_group?.trim() || undefined;
45
+ if (group && group !== prevGroup && closedGroups.has(group)) {
46
+ ctx.addIssue({
47
+ code: z.ZodIssueCode.custom,
48
+ message: `plan step "${step.name}" reuses parallel_group "${group}" after a non-"${group}" step interrupted it — parallel_group steps must be contiguous.`,
49
+ });
50
+ }
51
+ if (prevGroup !== undefined && prevGroup !== group) {
52
+ closedGroups.add(prevGroup);
53
+ }
54
+ prevGroup = group;
55
+ }
56
+ });
31
57
 
32
58
  /**
33
59
  * Tolerate common planning-model deviations so a valid-enough plan still executes:
@@ -252,7 +252,7 @@ export function bashCommandAllowed(command: string, prefixes: string[]): boolean
252
252
  */
253
253
  export function subagentToolset(role: SubagentRole): Record<string, ToolHandler> {
254
254
  if (role.readOnly) {
255
- const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug"]);
255
+ const MUTATING = new Set(["write", "edit", "bash", "mkdir", "delete", "ast_edit", "computer", "lsp_rename", "debug", "browser"]);
256
256
  const ro: Record<string, ToolHandler> = {};
257
257
  for (const [name, handler] of Object.entries(DEFAULT_TOOLS)) {
258
258
  if (MUTATING.has(name)) continue;
@@ -187,13 +187,171 @@ export function fenceSubagentReport(detail: string): string {
187
187
  ].join("\n");
188
188
  }
189
189
 
190
+ /**
191
+ * Build a `task` ToolHandler bound to a config + (optional) abort signal. The
192
+ * handler accepts `{ role?, task | prompt | assignment, context? }`.
193
+ */
194
+ export interface RunSubagentOptions {
195
+ /** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback. */
196
+ config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">;
197
+ /** Forwarded to the subagent's own runAgentLoop so Ctrl-C cancels nested work too. */
198
+ signal?: AbortSignal;
199
+ /** Optional live sink (e.g. plain-stream rendering of nested progress). */
200
+ onEvent?: (ev: TaskSubEvent) => void;
201
+ /** Mid-turn steering drain — see TaskToolOptions.steer for the exact contract. */
202
+ steer?: () => string[];
203
+ /** 1-based position within a fan-out/parallel batch, for event tagging. */
204
+ slot?: { index: number; total: number };
205
+ /** Pre-loaded project context to avoid re-scanning AGENTS.md per batch item. */
206
+ projectContext?: Awaited<ReturnType<typeof loadProjectContext>>;
207
+ }
208
+
209
+ /** `runSubagentOnce`'s result — a superset of `ToolResult` exposing the raw
210
+ * (unwrapped, unfenced) done reason and mutation-audit counters so a caller with
211
+ * its OWN pass/fail policy on top (e.g. `jeo team`'s role-gate-verdict parsing and
212
+ * `--strict-mutations`) doesn't have to re-derive them by parsing the formatted
213
+ * report text. `task`/`subagent` tool callers only ever need `success`/`output`/
214
+ * `error`, which stay structurally identical to plain `ToolResult`. */
215
+ export interface SubagentRunResult extends ToolResult {
216
+ /** Raw subagent done reason, before contract validation or fencing. */
217
+ doneReason: string;
218
+ /** True when the subagent actually called `done` (vs. exhausting its step
219
+ * budget or being force-stopped by an engine guard, e.g. a repeat-call loop). */
220
+ done: boolean;
221
+ /** Whether the done reason satisfied the role's required-marker contract. */
222
+ contractOk: boolean;
223
+ /** Required markers missing from the done reason, when `contractOk` is false. */
224
+ missingMarkers?: string[];
225
+ /** Successful write/edit/mkdir/delete calls observed (mutation audit evidence). */
226
+ fileMutations: number;
227
+ /** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
228
+ bashRuns: number;
229
+ }
230
+
231
+ /**
232
+ * Run ONE subagent role to completion against `taskText`/`context` and format its
233
+ * report (fenced against prompt injection). This is the single execution core
234
+ * shared by the `task` tool's single/fan-out/detached paths AND `jeo team`'s plan
235
+ * executor (`src/commands/team.ts`) — there is exactly one way a jeo-code subagent
236
+ * runs, whether it was launched interactively, in a batch, detached, or as a team
237
+ * plan step.
238
+ */
239
+ export async function runSubagentOnce(
240
+ role: ReturnType<typeof getSubagentRole> & {},
241
+ taskText: string,
242
+ context: string,
243
+ cwd: string,
244
+ opts: RunSubagentOptions,
245
+ ): Promise<SubagentRunResult> {
246
+ const { steer, slot, projectContext: preloadedContext, signal } = opts;
247
+ // Tag every live event with its fan-out slot so a parent monitor can tell
248
+ // task 1 from task 3 when several same-role subagents stream concurrently.
249
+ const emit = (ev: TaskSubEvent) =>
250
+ opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
251
+ const model = resolveSubagentModel(role.id, opts.config);
252
+ const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
253
+ // gjc parity: a role may pin its own reasoning budget; absent = inherit the
254
+ // session/global thinking level (the "(inherit)" row in the picker).
255
+ const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
256
+ const projectContext = preloadedContext ?? await loadProjectContext(cwd);
257
+ const memorySection = await memoryPromptSection(cwd, taskText);
258
+ const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
259
+ const history: Message[] = [
260
+ { role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
261
+ { role: "user", content: `${taskText}${context}` },
262
+ ];
263
+
264
+ const trace: string[] = [];
265
+ let lastTarget = "";
266
+ let currentStep = 0;
267
+ // Round-8 (architect ref 7-Round7Workflow): count the subagent's SUCCESSFUL
268
+ // calls so the parent can audit a "Changed Files:" claim against observed
269
+ // reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
270
+ // bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
271
+ // bash CAN mutate, so the audit message distinguishes the two cases.
272
+ let fileMutations = 0;
273
+ let bashRuns = 0;
274
+ emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
275
+ const result = await runAgentLoop(history, {
276
+ cwd,
277
+ model,
278
+ maxSteps,
279
+ maxTokens: resolveMaxOutputTokens(model, thinking),
280
+ // Per-run prompt-cache key: the subagent replays its own growing history each
281
+ // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
282
+ sessionKey: crypto.randomUUID(),
283
+ // Bounded delegation: a subagent's step contract stays exact — the parent
284
+ // owns any retry/extension decision, so the gjc retry flow is disabled here.
285
+ budget: { maxExtensions: 0 },
286
+ signal,
287
+ steer,
288
+ tools: subagentToolset(role),
289
+ events: {
290
+ onStep: n => { currentStep = n; },
291
+ onAssistant: (_raw, invocation) => {
292
+ if (invocation && invocation.tool && invocation.tool !== "done") {
293
+ lastTarget = toolTarget(invocation.tool, invocation.arguments);
294
+ trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
295
+ emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
296
+ }
297
+ },
298
+ onToolResult: (tool, success, output) => {
299
+ if (success) {
300
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
301
+ else if (tool === "bash") bashRuns++;
302
+ }
303
+ const label = lastTarget || tool;
304
+ const summary = firstUsefulLine(output);
305
+ const suffix = summary ? ` — ${summary}` : "";
306
+ trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
307
+ emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
308
+ lastTarget = "";
309
+ },
310
+ // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
311
+ // parent's monitor shows WHY a subagent is pausing instead of going silent.
312
+ onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
313
+ // Mid-turn steering reached this subagent: surface it as a live beat so the
314
+ // parent's monitor shows the redirect instead of an unexplained behavior change.
315
+ onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
316
+ },
317
+ });
318
+ const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
319
+ const validation = validateSubagentDoneReason(role, reason);
320
+ const complete = result.done && validation.ok;
321
+ const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
322
+ emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
323
+ const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
324
+ const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
325
+ const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
326
+ // Parent-side audit: a mutating role that "completed" without a successful file
327
+ // mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
328
+ // bash is tracked separately: it CAN mutate, so an only-bash run downgrades to
329
+ // "verify independently" instead of the stronger UNVERIFIED.
330
+ const audit = complete && !role.readOnly && fileMutations === 0
331
+ ? bashRuns === 0
332
+ ? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
333
+ : `\n[parent audit] No successful write/edit was observed (only bash ran); bash may or may not have mutated files — verify any "Changed Files:" claims above independently.`
334
+ : "";
335
+ return {
336
+ success: complete,
337
+ output: `${header}${body}\n\nResult:\n${fenceSubagentReport(detail)}${audit}`,
338
+ doneReason: reason,
339
+ done: result.done,
340
+ contractOk: validation.ok,
341
+ missingMarkers: validation.ok ? undefined : validation.missing,
342
+ fileMutations,
343
+ bashRuns,
344
+ };
345
+ }
346
+
190
347
  /**
191
348
  * Build a `task` ToolHandler bound to a config + (optional) abort signal. The
192
349
  * handler accepts `{ role?, task | prompt | assignment, context? }`.
193
350
  */
194
351
  export function createTaskTool(opts: TaskToolOptions): ToolHandler {
195
- /** Run ONE subagent to completion and format its result (the original single-task path). */
196
- const runOne = async (
352
+ /** Run ONE subagent via the shared execution core, binding this tool instance's
353
+ * config/signal/onEvent (the original single-task path). */
354
+ const runOne = (
197
355
  role: ReturnType<typeof getSubagentRole> & {},
198
356
  taskText: string,
199
357
  context: string,
@@ -206,98 +364,15 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
206
364
  * is cancellable independently of the parent turn. */
207
365
  signal?: AbortSignal;
208
366
  } = {},
209
- ): Promise<ToolResult> => {
210
- const { steer, slot, projectContext: preloadedContext, signal: signalOverride } = extra;
211
- // Tag every live event with its fan-out slot so a parent monitor can tell
212
- // task 1 from task 3 when several same-role subagents stream concurrently.
213
- const emit = (ev: TaskSubEvent) =>
214
- opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
215
- const model = resolveSubagentModel(role.id, opts.config);
216
- const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
217
- // gjc parity: a role may pin its own reasoning budget; absent = inherit the
218
- // session/global thinking level (the "(inherit)" row in the picker).
219
- const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
220
- const projectContext = preloadedContext ?? await loadProjectContext(cwd);
221
- const memorySection = await memoryPromptSection(cwd, taskText);
222
- const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
223
- const history: Message[] = [
224
- { role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
225
- { role: "user", content: `${taskText}${context}` },
226
- ];
227
-
228
- const trace: string[] = [];
229
- let lastTarget = "";
230
- let currentStep = 0;
231
- // Round-8 (architect ref 7-Round7Workflow): count the subagent's SUCCESSFUL
232
- // calls so the parent can audit a "Changed Files:" claim against observed
233
- // reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
234
- // bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
235
- // bash CAN mutate, so the audit message distinguishes the two cases.
236
- let fileMutations = 0;
237
- let bashRuns = 0;
238
- emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
239
- const result = await runAgentLoop(history, {
240
- cwd,
241
- model,
242
- maxSteps,
243
- maxTokens: resolveMaxOutputTokens(model, thinking),
244
- // Per-run prompt-cache key: the subagent replays its own growing history each
245
- // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
246
- sessionKey: crypto.randomUUID(),
247
- // Bounded delegation: a subagent's step contract stays exact — the parent
248
- // owns any retry/extension decision, so the gjc retry flow is disabled here.
249
- budget: { maxExtensions: 0 },
250
- signal: signalOverride ?? opts.signal,
251
- steer,
252
- tools: subagentToolset(role),
253
- events: {
254
- onStep: n => { currentStep = n; },
255
- onAssistant: (_raw, invocation) => {
256
- if (invocation && invocation.tool && invocation.tool !== "done") {
257
- lastTarget = toolTarget(invocation.tool, invocation.arguments);
258
- trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
259
- emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
260
- }
261
- },
262
- onToolResult: (tool, success, output) => {
263
- if (success) {
264
- if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
265
- else if (tool === "bash") bashRuns++;
266
- }
267
- const label = lastTarget || tool;
268
- const summary = firstUsefulLine(output);
269
- const suffix = summary ? ` — ${summary}` : "";
270
- trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
271
- emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
272
- lastTarget = "";
273
- },
274
- // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
275
- // parent's monitor shows WHY a subagent is pausing instead of going silent.
276
- onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
277
- // Mid-turn steering reached this subagent: surface it as a live beat so the
278
- // parent's monitor shows the redirect instead of an unexplained behavior change.
279
- onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
280
- },
367
+ ): Promise<ToolResult> =>
368
+ runSubagentOnce(role, taskText, context, cwd, {
369
+ config: opts.config,
370
+ signal: extra.signal ?? opts.signal,
371
+ onEvent: opts.onEvent,
372
+ steer: extra.steer,
373
+ slot: extra.slot,
374
+ projectContext: extra.projectContext,
281
375
  });
282
- const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
283
- const validation = validateSubagentDoneReason(role, reason);
284
- const complete = result.done && validation.ok;
285
- const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
286
- emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
287
- const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
288
- const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
289
- const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
290
- // Parent-side audit: a mutating role that "completed" without a successful file
291
- // mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
292
- // bash is tracked separately: it CAN mutate, so an only-bash run downgrades to
293
- // "verify independently" instead of the stronger UNVERIFIED.
294
- const audit = complete && !role.readOnly && fileMutations === 0
295
- ? bashRuns === 0
296
- ? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
297
- : `\n[parent audit] No successful write/edit was observed (only bash ran); bash may or may not have mutated files — verify any "Changed Files:" claims above independently.`
298
- : "";
299
- return { success: complete, output: `${header}${body}\n\nResult:\n${fenceSubagentReport(detail)}${audit}` };
300
- };
301
376
 
302
377
  return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
303
378
  const roleArg = typeof args.role === "string" ? args.role.trim() : "";
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
5
  import { ProviderHttpError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
 
8
9
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
9
10
 
@@ -306,7 +307,7 @@ export function anthropicPayload(
306
307
  if (stream) payload.stream = true;
307
308
  const system = anthropicSystemBlocks(systemPrompt, model, credential, payload, options.baseUrl);
308
309
  if (system) payload.system = system;
309
- return JSON.stringify(payload);
310
+ return JSON.stringify(sanitizeJsonStrings(payload));
310
311
  }
311
312
 
312
313
  export function anthropicRequest(
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
5
  import { providerHttpError } from "./errors";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
  import { geminiThinkingConfig, getGeminiCliHeaders, type GeminiThinkingConfig } from "./gemini";
8
9
 
9
10
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
@@ -204,14 +205,14 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
204
205
  request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
205
206
  }
206
207
 
207
- const body = JSON.stringify({
208
+ const body = JSON.stringify(sanitizeJsonStrings({
208
209
  project,
209
210
  model,
210
211
  request,
211
212
  requestType: "agent",
212
213
  userAgent: "antigravity",
213
214
  requestId: `agent-${randomUUID()}`,
214
- });
215
+ }));
215
216
  return {
216
217
  url: `${endpoint}/v1internal:streamGenerateContent?alt=sse`,
217
218
  headers: {
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
4
4
  import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
5
5
  import { jeoEnv } from "../../util/env";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
 
8
9
  /** Gemini 2.5+/latest models think by default and BILL thought tokens against
9
10
  * `maxOutputTokens` — a small-budget call can burn its entire budget on thoughts
@@ -205,7 +206,7 @@ export function geminiRequest(messages: Message[], options: CallOptions, credent
205
206
  const headers: Record<string, string> = oauth
206
207
  ? { "content-type": "application/json", authorization: `Bearer ${oauth}` }
207
208
  : { "content-type": "application/json" };
208
- return { url, headers, body: JSON.stringify(payload) };
209
+ return { url, headers, body: JSON.stringify(sanitizeJsonStrings(payload)) };
209
210
  }
210
211
 
211
212
  const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
@@ -236,7 +237,7 @@ export function geminiCliRequest(messages: Message[], options: CallOptions, acce
236
237
  accept: "text/event-stream",
237
238
  ...getGeminiCliHeaders(geminiModel),
238
239
  },
239
- body: JSON.stringify({ project: projectId, model: geminiModel, request: payload }),
240
+ body: JSON.stringify(sanitizeJsonStrings({ project: projectId, model: geminiModel, request: payload })),
240
241
  };
241
242
  }
242
243
 
@@ -2,6 +2,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
2
2
  import { readLines } from "../sse";
3
3
  import { providerHttpError } from "./errors";
4
4
  import { createThinkSplitter } from "../think-tags";
5
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
5
6
 
6
7
  /**
7
8
  * Resolve the Ollama base URL. `OLLAMA_HOST` is documented as a bare host:port
@@ -52,7 +53,7 @@ function ollamaRequest(messages: Message[], options: CallOptions, stream: boolea
52
53
  };
53
54
  if (options.jsonMode) payload.format = "json";
54
55
  const base = normalizeOllamaBaseUrl(options.baseUrl);
55
- return { url: `${base}/api/chat`, body: JSON.stringify(payload) };
56
+ return { url: `${base}/api/chat`, body: JSON.stringify(sanitizeJsonStrings(payload)) };
56
57
  }
57
58
 
58
59
  /** Round-5 #1: surface done_reason when a 200 carries no text (uniform contract). */
@@ -15,6 +15,7 @@ import type { CallOptions, Message } from "../types";
15
15
  import { readSse } from "../sse";
16
16
  import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
17
17
  import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
18
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
18
19
  import os from "node:os";
19
20
  import pkg from "../../../package.json";
20
21
 
@@ -141,7 +142,7 @@ export function codexResponsesRequest(
141
142
  return {
142
143
  url: `${base}/responses`,
143
144
  headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream", "user-agent": codexUserAgent() },
144
- body: JSON.stringify(payload),
145
+ body: JSON.stringify(sanitizeJsonStrings(payload)),
145
146
  };
146
147
  }
147
148
  const accountId = extractChatgptAccountId(token);
@@ -161,7 +162,7 @@ export function codexResponsesRequest(
161
162
  headers.conversation_id = promptCacheKey;
162
163
  headers["x-client-request-id"] = promptCacheKey;
163
164
  }
164
- return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(payload) };
165
+ return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(sanitizeJsonStrings(payload)) };
165
166
  }
166
167
 
167
168
  /** gjc parity (normalizeOpenAIResponsesPromptCacheKey): well-formed unicode, ≤64 chars
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
4
4
  import { providerHttpError } from "./errors";
5
5
  import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
  import { createThinkSplitter, stripLeakedReasoningTags } from "../think-tags";
8
9
 
9
10
  /** True for OpenAI reasoning models (o-series + gpt-5+ family). Digit-count agnostic
@@ -96,7 +97,7 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
96
97
  return {
97
98
  url: `${base}/chat/completions`,
98
99
  headers: { "content-type": "application/json", Authorization: `Bearer ${bearerFor(credential)}` },
99
- body: JSON.stringify(payload),
100
+ body: JSON.stringify(sanitizeJsonStrings(payload)),
100
101
  };
101
102
  }
102
103
 
@@ -28,7 +28,7 @@ Concrete implementations of `jeo` subcommands (e.g., launch, setup, team, ultrag
28
28
  | `skills.ts` | Brief description of purpose |
29
29
  | `state.ts` | Brief description of purpose |
30
30
  | `status.ts` | Brief description of purpose |
31
- | `team.ts` | Multi-agent coordination command |
31
+ | `team.ts` | `jeo team` — executes an approved ralplan plan step-by-step via the SAME `runSubagentOnce` core the `task`/`subagent` tools use; a contiguous run of steps sharing a plan `parallel_group` runs concurrently in isolated git worktrees (`runParallelGroup`), merged back in order (gjc `team` concurrency parity) |
32
32
  | `ultragoal.ts` | Brief description of purpose |
33
33
  | `update.ts` | Brief description of purpose |
34
34
 
@@ -525,7 +525,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
525
525
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
526
526
  const contextFiles = await loadProjectContext(cwd);
527
527
 
528
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug"]);
528
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
529
529
  let allowedTools = new Set(KNOWN_TOOLS);
530
530
 
531
531
  if (flags.noTools) {
@@ -880,10 +880,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
880
880
  // Refresh injected OKF memory with THIS turn's query before the model call so
881
881
  // failure-first ranking fires and same-session dead ends resurface.
882
882
  await refreshSessionMemory(userInput);
883
- const compRes = await maybeCompact(history, {
884
- model: activeModel,
885
- contextTokens,
883
+ // Esc-cancellable turn-boundary compaction preflight: a slow/hung summarization
884
+ // LLM call must not be un-cancellable just because it runs before the main
885
+ // per-turn abort harness (created below) exists yet. A short-lived harness scoped
886
+ // to just this call gives Esc/Ctrl+C the same abort path without wiring the full
887
+ // mid-turn steering machinery this early (which needs turn-scoped vars not
888
+ // constructed until after compaction/history mutation settles).
889
+ const compactHarness = createInFlightAbortHarness({
890
+ captureEsc: !!tui,
891
+ onAbortNotice: msg => {
892
+ if (tui) tui.events().onNotice?.(msg);
893
+ else console.log(msg);
894
+ },
895
+ onHardExit: () => {
896
+ if (tui) tui.finish("Cancelled.", { ok: false });
897
+ process.exit(130);
898
+ },
886
899
  });
900
+ let compRes: Awaited<ReturnType<typeof maybeCompact>>;
901
+ try {
902
+ compRes = await maybeCompact(history, {
903
+ model: activeModel,
904
+ contextTokens,
905
+ signal: compactHarness.controller.signal,
906
+ });
907
+ } finally {
908
+ compactHarness.dispose();
909
+ }
887
910
  if (compRes.error) {
888
911
  throw new Error(compRes.error);
889
912
  }
@@ -3587,6 +3610,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3587
3610
  }
3588
3611
  continue;
3589
3612
  }
3613
+ if (input === "/wiki" || input.startsWith("/wiki ")) {
3614
+ const decision = decideWikiSlash(
3615
+ input,
3616
+ resolveWikiRoot(await readGlobalConfig()),
3617
+ !!jeoEnv("WIKI_ROOT"),
3618
+ );
3619
+ for (const line of decision.lines) console.log(line);
3620
+ if (decision.kind === "clear") {
3621
+ await saveConfigPatch(() => ({ wikiRoot: undefined }));
3622
+ delete process.env.JEO_WIKI_ROOT;
3623
+ sessionWikiRoot = undefined;
3624
+ await refreshSessionMemory("");
3625
+ } else if (decision.kind === "set") {
3626
+ await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
3627
+ process.env.JEO_WIKI_ROOT = decision.root;
3628
+ sessionWikiRoot = decision.root;
3629
+ await refreshSessionMemory("");
3630
+ }
3631
+ continue;
3632
+ }
3590
3633
  if (input === "/evolve") {
3591
3634
  await runEvolveSimulation(animateAsciiArt);
3592
3635
  continue;
@@ -142,7 +142,17 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
142
142
  ` - name: "<imperative task, e.g. Implement reverse() in src/reverse.ts>"\n` +
143
143
  ` role: executor # one of: executor | planner | architect | critic\n` +
144
144
  ` target: "<primary file path>"\n` +
145
- `Provide 3-8 concrete, ordered steps. Output ONLY the YAML.`;
145
+ ` parallel_group: "<optional short id, e.g. g1>"\n` +
146
+ `Provide 3-8 concrete, ordered steps. Output ONLY the YAML.\n\n` +
147
+ `parallel_group is OPTIONAL and OFF by default — omit it unless you are certain. ` +
148
+ `'jeo team' executes steps strictly in array order, EXCEPT a contiguous (adjacent, ` +
149
+ `no other step in between) run of steps sharing the SAME non-empty parallel_group value, ` +
150
+ `which it runs CONCURRENTLY in isolated git worktrees and merges back. Only mark steps ` +
151
+ `parallel_group when they are TRULY independent: each touches DISJOINT files with no step ` +
152
+ `reading or building on another's output. Never mark two steps parallel if one depends on, ` +
153
+ `reads, or extends a file another writes. When unsure, leave parallel_group out — a plain ` +
154
+ `serial step is always correct; a wrongly-parallel step risks a merge conflict that halts ` +
155
+ `execution.`;
146
156
 
147
157
  const PLANNER = `You are the PLANNER. From the crystallized spec, sequence the work into a logical, outcome-based progression of concrete, ordered tasks.\n` + SCHEMA_SPEC;
148
158
  const ARCHITECT = `You are the ARCHITECT. Review the Planner's draft for technical feasibility, correct file targets, directory structure, and any missing setup/wiring/test steps. Return an improved plan (same shape).\n` + SCHEMA_SPEC;