cozyclay 3.1.2 → 3.1.4

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/bin/agent/agent-runner.mjs +160 -151
  4. package/bin/agent/agent-stream.mjs +86 -0
  5. package/bin/agent/anthropic-subscription-events.mjs +120 -0
  6. package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
  7. package/bin/agent/anthropic-subscription-stream.mjs +251 -0
  8. package/bin/agent/anthropic-subscription.mjs +73 -0
  9. package/bin/agent/claude-code-executable.mjs +93 -0
  10. package/bin/agent/cliproxy-provider.mjs +25 -11
  11. package/bin/agent/model-roles.mjs +112 -0
  12. package/bin/agent/openai-compatible-provider.mjs +95 -0
  13. package/bin/agent/pi-tools.mjs +19 -10
  14. package/bin/agent/provider-login.mjs +3 -1
  15. package/bin/agent/provider-routes.mjs +2 -0
  16. package/bin/agent/providers.mjs +35 -130
  17. package/bin/cozyclay.mjs +2 -48
  18. package/bin/star-prompt.mjs +123 -0
  19. package/dist/app/index.html +5 -5
  20. package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
  21. package/dist/assets/app-BdRlMf4J.css +1 -0
  22. package/dist/assets/app-KXdgBC-S.js +5144 -0
  23. package/dist/assets/{motion3-compile-dgGhrvPK.js → motion3-compile-DDG3wklD.js} +1 -1
  24. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
  25. package/dist/assets/task-board-DFfNa0xg.js +15 -0
  26. package/dist/assets/{workflow-C87p6E1E.js → workflow-BMJGd_gZ.js} +1 -1
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/workflow/index.html +3 -3
  29. package/package.json +6 -4
  30. package/src/App.jsx +28 -28
  31. package/src/agent-image-client.js +2 -2
  32. package/src/asset-pane.jsx +14 -14
  33. package/src/character-kind.js +13 -12
  34. package/src/commands/project.js +2 -1
  35. package/src/commands/shot.js +1 -1
  36. package/src/commands/view.js +2 -2
  37. package/src/domains/cast.js +15 -15
  38. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  39. package/src/domains/motion.js +11 -11
  40. package/src/domains/objects.js +20 -30
  41. package/src/domains/scenes.js +14 -21
  42. package/src/domains/shots.js +3 -3
  43. package/src/first-success-guide.jsx +19 -3
  44. package/src/hierarchy-panel.jsx +6 -7
  45. package/src/locale-zh/app.js +124 -0
  46. package/src/locale-zh/domains.js +36 -0
  47. package/src/locale-zh/labels.js +148 -0
  48. package/src/locale-zh/panels.js +225 -0
  49. package/src/locale-zh/shell.js +230 -0
  50. package/src/locale-zh/timeline.js +145 -0
  51. package/src/locale-zh/workflow.js +104 -0
  52. package/src/locale-zh/workspace.js +190 -0
  53. package/src/locale-zh.js +21 -0
  54. package/src/locale.js +16 -6
  55. package/src/main.jsx +1 -1
  56. package/src/motion/beat-board-model.js +4 -4
  57. package/src/motion/beat-board.jsx +1 -1
  58. package/src/motion/ik-pick.js +24 -0
  59. package/src/motion/take-segments.jsx +5 -5
  60. package/src/motion/timeline.jsx +30 -40
  61. package/src/motion/waypoints.js +6 -6
  62. package/src/object-catalog.jsx +4 -5
  63. package/src/panels/CameraPanel.jsx +3 -3
  64. package/src/panels/HdrEnvironmentControls.jsx +2 -1
  65. package/src/panels/ObjectTransformPanel.jsx +19 -29
  66. package/src/panels/PoseImagePanel.jsx +8 -8
  67. package/src/panels/PosePanel.jsx +2 -2
  68. package/src/panels/ReferenceImageField.jsx +2 -2
  69. package/src/panels/RigControlPanel.jsx +2 -2
  70. package/src/panels/SculptPanel.jsx +9 -9
  71. package/src/panels/SubjectBox.jsx +3 -3
  72. package/src/panels/SubjectsPanel.jsx +1 -1
  73. package/src/posestudio.jsx +10 -8
  74. package/src/project-browser.css +7 -6
  75. package/src/project-browser.jsx +7 -6
  76. package/src/project-open-file.js +19 -0
  77. package/src/qa-label.js +1 -1
  78. package/src/range-pin-panel.jsx +14 -14
  79. package/src/resource-status.jsx +3 -3
  80. package/src/result-modal.jsx +10 -10
  81. package/src/settings-menu.jsx +4 -1
  82. package/src/shell/BoardSlot.jsx +5 -5
  83. package/src/shell/CompositionGuideButton.jsx +1 -1
  84. package/src/shell/GhostsMenuGroup.jsx +1 -1
  85. package/src/shell/StatusBar.jsx +4 -4
  86. package/src/shell/ViewportToolbar.jsx +3 -3
  87. package/src/shell/add-camera-shot.js +2 -2
  88. package/src/shell/details-selection.js +1 -1
  89. package/src/shell/preference-controls.jsx +2 -1
  90. package/src/shot-guides.js +5 -5
  91. package/src/stage-hdr.js +3 -3
  92. package/src/stage-labels-ko.js +2 -2
  93. package/src/stage-startup-defaults.js +3 -3
  94. package/src/studio-actions.js +2 -2
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles.css +1 -1
  97. package/src/use-case-question.jsx +18 -18
  98. package/src/workflow/AgentPanel.jsx +4 -4
  99. package/src/workflow/AgentPlanCard.jsx +1 -1
  100. package/src/workflow/AgentProgressCard.jsx +4 -4
  101. package/src/workflow/AgentTurnSteps.jsx +1 -1
  102. package/src/workflow/LoginPanel.jsx +1 -1
  103. package/src/workflow/TaskBoard.jsx +1 -1
  104. package/src/workflow/TaskInline.jsx +1 -1
  105. package/src/workflow/agent-client.js +1 -1
  106. package/src/workflow/agent-turn.js +5 -5
  107. package/src/workflow/task-board.js +4 -4
  108. package/tools/loc-baseline.json +2 -2
  109. package/tools/run-tests.mjs +5 -5
  110. package/dist/assets/TaskBoard-GR8xZm5c.js +0 -1
  111. package/dist/assets/app-Bhe0TpGz.css +0 -1
  112. package/dist/assets/app-C4QTcDcb.js +0 -5144
  113. package/dist/assets/scene-asset-sync-qoxcdrhj.js +0 -13
  114. package/dist/assets/task-board-BqReHzOi.js +0 -15
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 3.1.4 - 2026-10-24
6
+
7
+ Starring CozyClay from the terminal takes one key when the GitHub CLI is signed in.
8
+
9
+ CLI
10
+
11
+ - The one-time star question on exit stars NomaDamas/CozyClay through `gh`
12
+ when `gh` is installed and signed in: answer `y` and it is done, with no
13
+ browser. An account that already starred the repo is not asked. Without
14
+ `gh` the question opens the repo page as before, and `--no-star`,
15
+ `COZYCLAY_NO_STAR` and CI still turn it off.
16
+
17
+ ## 3.1.3 - 2026-10-24
18
+
19
+ More ways to connect a model (Claude Pro/Max, OpenAI-compatible servers, MiniMax M3.1 Flash), a Simplified Chinese UI, and easier project opening.
20
+
21
+ Agent
22
+
23
+ - Sign in with a Claude Pro/Max subscription and run Claude models in the
24
+ agent panel without an API key. Turns go through the official Claude Code
25
+ binary.
26
+ - Connect an OpenAI-compatible server (LM Studio, Ollama) with its address
27
+ and an optional key. The model list comes from the server.
28
+ - MiniMax M3 and M3.1 Flash, on both the global and the China endpoint.
29
+ - The agent runs on pi 1.4.0, which adds the newer Claude, GPT and Gemini
30
+ models to the picker. A turn stopped mid-tool keeps its prompt after a
31
+ reload.
32
+
33
+ Studio
34
+
35
+ - Simplified Chinese (简体中文) as an opt-in UI language in Settings.
36
+ - An Open project card on the start screen. A browser that refuses to read
37
+ the picked file falls back to a file input, a slow pick no longer times
38
+ out, and a failed open shows its message above the start screen.
39
+ - IK handles answer a press on their drawn sphere, and rotation rings win
40
+ over an arrow's invisible hit volume.
41
+
5
42
  ## 3.1.2 - 2026-10-23
6
43
 
7
44
  The Studio agent repairs motion faster and ends with a take, exports can go to a folder you choose, and a round of fixes for the live hub, MCP, prompts and docs.
package/README.md CHANGED
@@ -78,7 +78,7 @@ The same scene can be driven from three places, live, while you watch the viewpo
78
78
 
79
79
  | Surface | Start | For |
80
80
  | --- | --- | --- |
81
- | **Agent panel** | `Cmd/Ctrl+B` in the studio | Chat in the studio. Sign in with ChatGPT or use your own key (Anthropic, OpenAI, Gemini, OpenRouter, CLIProxyAPI). |
81
+ | **Agent panel** | `Cmd/Ctrl+B` in the studio | Chat in the studio. Sign in with ChatGPT or use your own key (Anthropic, OpenAI, Gemini, MiniMax, OpenRouter, CLIProxyAPI). |
82
82
  | **MCP server** | `npx -y cozyclay mcp` | Claude Desktop, Claude Code, Cursor or any MCP client. |
83
83
  | **Terminal** | `cclay live status --wait` | Coding agents that drive a running studio, JSON in and out. |
84
84
 
@@ -2,15 +2,15 @@ import { summariseCanvasResult } from "./agent-tools.mjs";
2
2
  import { createReasoningFrames } from "./turn-frames.mjs";
3
3
  import { sanitizeUpstreamDetail } from "./agent-routes.mjs";
4
4
  import { budgetStudioRequest } from "./studio-context-budget.mjs";
5
+ import { createRetryingStream } from "./agent-stream.mjs";
5
6
 
6
7
  const DEFAULT_MODEL = "gpt-6-astra";
7
8
 
8
- // Bounded provider retries (issue #379): the harness's own `retry` option
9
- // drives the retry loop (pi classifies 429/5xx/network as transient via
10
- // `isRetryableAssistantError`); this is just the budget. `maxRetryDelayMs`
11
- // caps how long a single request sleeps for a provider-requested Retry-After
12
- // before pi's own SDK-level retry gives up and surfaces the error to us.
13
- const RETRY_POLICY = { enabled: true, maxRetries: 2, baseDelayMs: 1000 };
9
+ // Bounded provider retries (issue #379) run in agent-stream.mjs, around each
10
+ // request (pi classifies 429/5xx/network as transient via
11
+ // `isRetryableAssistantError`). `maxRetryDelayMs` caps how long a single
12
+ // request sleeps for a provider-requested Retry-After before pi's own
13
+ // SDK-level retry gives up and surfaces the error to us.
14
14
  const RETRY_MAX_DELAY_MS = 20000;
15
15
 
16
16
  const AUTH_ERROR_PATTERN = /\b(401|403)\b|unauthorized|forbidden/i;
@@ -163,11 +163,11 @@ function withoutInspectContext(part) {
163
163
  }
164
164
 
165
165
  /** The Studio per-request context diet (#716), applied to the provider view
166
- * only (pi's transform_context): every <studio-context> but the latest is
166
+ * only (the Agent's transformContext): every <studio-context> but the latest is
167
167
  * replaced by a superseded marker, every inspect_studio result but the most
168
168
  * recent tool result drops its re-embedded `context`, and the latest
169
169
  * context's static actionIndex moves to the end of the system prompt. The
170
- * stored lane messages are never touched; edited messages are new objects. */
170
+ * stored transcript is never touched; edited messages are new objects. */
171
171
  function studioContextDiet({ messages, systemPrompt }) {
172
172
  let latestContext = -1;
173
173
  let latestToolResult = -1;
@@ -240,21 +240,20 @@ function studioObservationMessage(observation) {
240
240
  * The runner is deliberately the only module in the bin/agent chain that
241
241
  * knows about pi. Keep these imports lazy: the package-isolation checks start
242
242
  * bin/cozyclay.mjs without node_modules installed.
243
+ *
244
+ * One session is one pi-agent-core `Agent`: its transcript (the system prompt
245
+ * and tool declarations ride in its system messages), its model and thinking
246
+ * level, and its steering queue. Each turn is one `agent.prompt()`.
243
247
  */
244
- export function createAgentRunner({ models: suppliedModels, sessionStore, tools = [], systemPrompt = "", clock = () => performance.now(), fauxProvider, onQuota, codexBaseUrl, cliproxyBaseUrl, auth, credentials, keys, env, compaction = { enabled: false }, pi: injectedPi } = {}) {
248
+ export function createAgentRunner({ models: suppliedModels, sessionStore, tools = [], systemPrompt = "", clock = () => performance.now(), fauxProvider, onQuota, codexBaseUrl, cliproxyBaseUrl, auth, credentials, keys, env } = {}) {
245
249
  const openSessions = new Map();
246
250
  let models = suppliedModels;
247
- let piModules = injectedPi;
251
+ let piModules;
248
252
 
249
253
  const loadPi = async () => {
250
254
  if (piModules) return piModules;
251
- const [{ AgentHarness }, context, sessionModule, ai] = await Promise.all([
252
- import("@earendil-works/pi-agent-core"),
253
- import("@earendil-works/pi-agent-core/harness/context"),
254
- import("@earendil-works/pi-agent-core/harness/session"),
255
- import("@earendil-works/pi-ai"),
256
- ]);
257
- piModules = { AgentHarness, ...context, ...sessionModule, ...ai };
255
+ const [{ Agent }, ai] = await Promise.all([import("@earendil-works/pi-agent-core"), import("@earendil-works/pi-ai")]);
256
+ piModules = { Agent, ai };
258
257
  return piModules;
259
258
  };
260
259
 
@@ -273,52 +272,53 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
273
272
 
274
273
  const openSession = async (sessionId, { surface = "workflow" } = {}) => {
275
274
  if (openSessions.has(sessionId)) return openSessions.get(sessionId).public;
276
- const { pi } = await ensureModels();
277
- const context = pi.BACKGROUND_CONTEXT;
278
- const repo = new pi.MemorySessionRepo();
279
- const durable = await repo.create({ id: sessionId }, context);
275
+ await ensureModels();
280
276
  const state = {
281
277
  sessionId,
282
278
  surface,
283
- repo,
284
- durable,
285
- harness: null,
286
- lane: null,
287
- context,
279
+ agent: null,
288
280
  active: null,
289
281
  persisted: 0,
290
282
  lastInput: null,
291
283
  unsubscribers: [],
292
284
  };
285
+ // The conversation: the transcript minus its system messages, which carry the prompt and the tool declarations.
286
+ const conversation = () => (state.agent?.state.messages ?? []).filter((message) => message.role !== "system");
293
287
 
294
288
  // An aborted turn's assistant message must never reach sessionStore, even
295
289
  // when the generation itself finished normally before the cancellation
296
290
  // registered (pi only stops the run from starting a FURTHER turn; it does
297
- // not retroactively un-stream an already-committed message, so `run_end`'s
298
- // own `status` arrives too late — by then `turn_end` has already flushed
291
+ // not retroactively un-stream an already-committed message, so the run's
292
+ // own outcome arrives too late — by then `turn_end` has already flushed
299
293
  // the completed turn through this same `persist`). The one reliable
300
- // abort signal is therefore "did THIS session's `abort()` run during the
301
- // still-active turn", tracked synchronously on `state.active` the instant
302
- // `publicSession.abort` is called (see below) — not any one event's shape.
294
+ // abort signal is therefore "was THIS still-active turn aborted", tracked
295
+ // synchronously on `state.active` the instant `publicSession.abort` is
296
+ // called or the caller's signal fires (see below) — not any one event's shape.
303
297
  // The user message (and any toolResult from a tool that finished before
304
298
  // the abort) are real, completed history and ARE persisted; only the
305
- // assistant role is withheld.
299
+ // assistant role is withheld, as is any assistant pi itself ended as
300
+ // failed or cancelled.
306
301
  const persist = async () => {
307
- if (!state.lane || !sessionStore?.append) return;
308
- const entries = await state.lane.findEntries({ order: "oldestFirst" }, state.context);
309
- const messages = entries.filter((entry) => entry.type === "message").map((entry) => entry.message);
302
+ if (!state.agent || !sessionStore?.append) return;
303
+ const messages = conversation();
310
304
  if (messages.length <= state.persisted) return;
311
305
  const aborted = state.active?.abortRequested === true;
312
306
  const newlyGenerated = messages.slice(state.persisted);
313
- const failedAssistant = newlyGenerated.find((message) => message?.role === "assistant" && (aborted || message.stopReason === "error"));
314
- const pending = newlyGenerated.filter((message) => !(aborted && message?.role === "assistant") && !(message?.role === "assistant" && message?.stopReason === "error"));
307
+ const withheld = (message) => message?.role === "assistant" && (aborted || message.stopReason === "error" || message.stopReason === "aborted");
308
+ const failedAssistant = newlyGenerated.find(withheld);
309
+ // A tool call the abort cut short never finished: its result is not history either.
310
+ const cutShort = (message) => message?.role === "toolResult" && state.active?.abortedCalls.has(message.toolCallId);
311
+ const pending = newlyGenerated.filter((message) => !withheld(message) && !cutShort(message));
315
312
  state.persisted = messages.length;
316
313
  const input = state.lastInput || {};
317
314
  if (pending.length) await sessionStore.append(sessionId, pending, { surface, ...(input.meta || {}) });
318
- if (failedAssistant && sessionStore.appendTurnError) {
315
+ // One record per turn: an abort mid-tool withholds two assistants (the tool call, then the request it stopped).
316
+ if (failedAssistant && sessionStore.appendTurnError && !state.active?.turnErrorRecorded) {
317
+ if (state.active) state.active.turnErrorRecorded = true;
318
+ const cancelled = aborted || failedAssistant.stopReason === "aborted";
319
319
  const rawMessage = failedAssistant.errorMessage || "The model or live editor could not complete this turn.";
320
- const message = aborted ? "The turn was aborted." : sanitizeUpstreamDetail(JSON.stringify({ message: rawMessage })) || rawMessage;
321
- const code = aborted ? "aborted" : classifyProviderError(rawMessage);
320
+ const message = cancelled ? "The turn was aborted." : sanitizeUpstreamDetail(JSON.stringify({ message: rawMessage })) || rawMessage;
321
+ const code = cancelled ? "aborted" : classifyProviderError(rawMessage);
322
322
  await sessionStore.appendTurnError(sessionId, { code, message, at: new Date().toISOString() }, { surface, ...(input.meta || {}) });
323
323
  }
324
324
  };
@@ -337,13 +337,13 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
337
337
  // possible (#379, F2 pass 5 finding 4): `sendQuota` fires the moment
338
338
  // anything else is about to reach the browser (see `pushFrame` below),
339
339
  // using the freshest codex response headers captured so far (from the
340
- // `codexResponseObserver` seam wired around `state.lane.prompt` in
341
- // `start`) or the null-quota shape when none arrived yet (a non-codex
342
- // provider, or a codex call that has not resolved headers yet). The
343
- // `after_response` hook below is a pure fallback for a turn that somehow
344
- // reaches full completion without ever calling `pushFrame` — `sendQuota`
345
- // is idempotent (`quotaSent` guards both paths), so at most one quota
346
- // frame is ever sent, exactly like before.
340
+ // `codexResponseObserver` seam wired around `agent.prompt` in `start`, or
341
+ // the Agent's `onResponse`) or the null-quota shape when none arrived yet
342
+ // (a non-codex provider, or a codex call that has not resolved headers
343
+ // yet). Each finished provider request (agent-stream.mjs `onAttempt`) is a
344
+ // pure fallback for a turn that somehow reaches full completion without
345
+ // ever calling `pushFrame` — `sendQuota` is idempotent (`quotaSent` guards
346
+ // both paths), so at most one quota frame is ever sent, exactly like before.
347
347
  const sendQuota = (queue, input) => {
348
348
  if (!state.active || state.active.quotaSent) return;
349
349
  state.active.quotaSent = true;
@@ -357,13 +357,41 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
357
357
  for (const pending of state.active.pendingFrames) queue.push(pending);
358
358
  state.active.pendingFrames = [];
359
359
  };
360
- const emitQuota = (queue, input, response) => {
360
+ const emitQuota = (queue, input) => {
361
361
  if (input?.surface === "studio") return; // quotaSent is set at turn start; no quota frame ever
362
- if (state.active && response?.headers) state.active.lastResponseHeaders = response.headers;
363
362
  sendQuota(queue, input);
364
363
  };
365
364
 
366
- const ensureHarness = async (input) => {
365
+ // Every finished provider request counts, failed attempts included (agent-stream.mjs retries in place).
366
+ const countAttempt = (message) => {
367
+ const usage = state.active?.usage;
368
+ if (!usage) return;
369
+ usage.requests += 1;
370
+ for (const key of ["input", "output", "cacheRead", "cacheWrite"]) if (Number.isFinite(message?.usage?.[key])) usage[key] += message.usage[key];
371
+ if (Number.isFinite(message?.usage?.cost?.total)) state.active.cost = (state.active.cost ?? 0) + message.usage.cost.total;
372
+ };
373
+
374
+ // A Codex 401 earns one forced credential refresh per turn and one more try of the same request: completed tools
375
+ // stay completed, and the failed attempt stays out of the transcript and the JSONL.
376
+ const refreshAuth = async (model, message) => {
377
+ if (model?.provider !== "openai-codex" || !state.active || state.active.authRetried
378
+ || classifyError({ message: message.errorMessage }).code !== "unauthorized") return false;
379
+ state.active.authRetried = true;
380
+ try { await state.registry?.getAuth?.("openai-codex", { minOAuthValidityMs: Number.MAX_SAFE_INTEGER }); } catch { /* the retried call resolves credentials again and surfaces any refresh failure */ }
381
+ return true;
382
+ };
383
+
384
+ // Studio only, on the provider view (studioContextDiet, then studio-context-budget.mjs): the stored transcript and the
385
+ // persisted history keep every context, result, script and image whole. The prompt is the leading system message.
386
+ const studioView = async (messages) => {
387
+ if (state.lastInput?.surface !== "studio") return messages;
388
+ const head = messages[0]?.role === "system" ? messages[0] : null;
389
+ const prompt = typeof head?.content === "string" ? head.content : (head?.content ?? []).map((part) => part?.text ?? "").join("");
390
+ const view = budgetStudioRequest(studioContextDiet({ messages: head ? messages.slice(1) : messages, systemPrompt: prompt }));
391
+ return head ? [{ ...head, content: view.systemPrompt }, ...view.messages] : view.messages;
392
+ };
393
+
394
+ const ensureAgent = async (input) => {
367
395
  const { pi, models: registry } = await ensureModels();
368
396
  const activePrompt = input.systemPrompt || (input.surface === "studio" ? await (await import("./studio-motion-reference.mjs")).studioSystemPrompt() : systemPrompt);
369
397
  const { resolveModel, resolveEffort } = await import("./providers.mjs");
@@ -373,63 +401,35 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
373
401
  const modelId = slash === -1 ? requested : requested.slice(slash + 1);
374
402
  const direct = registry.getModel(provider, modelId);
375
403
  const selected = direct ? { provider, modelId, model: direct } : await resolveModel(requested, { models: registry });
376
- // Preserve an omitted effort (harness default or existing lane level).
377
- // For explicit none, pi's harness omits reasoning when the level is off,
404
+ // Preserve an omitted effort (off for a new session, else the session's
405
+ // level). For explicit none, off reaches pi-ai as no reasoning level,
378
406
  // avoiding the provider's clamp of unsupported off back up to low.
379
407
  const thinkingLevel = input.effort === undefined ? undefined : await resolveEffort(selected.model, input.effort);
380
- if (!state.harness) {
381
- state.harness = (await pi.AgentHarness.create({
382
- session: state.durable,
383
- models: registry,
384
- model: selected.model,
385
- thinkingLevel,
386
- tools: await adapters(input),
387
- systemPrompt: activePrompt,
408
+ state.registry = registry;
409
+ if (!state.agent) {
410
+ const restored = sessionStore?.read?.(sessionId);
411
+ state.agent = new pi.Agent({
412
+ initialState: { systemPrompt: activePrompt, model: selected.model, thinkingLevel: thinkingLevel ?? "off", tools: await adapters(input), messages: restored?.history ?? [] },
413
+ streamFn: createRetryingStream({ ai: pi.ai, models: registry, refreshAuth, onAttempt: (message) => { countAttempt(message); emitQuota(state.active?.queue, state.lastInput); } }),
414
+ transformContext: studioView,
415
+ onPayload: (payload, model) => (model?.api === "anthropic-messages" ? withAnthropicCacheBreakpoints(payload, model) : undefined),
416
+ onResponse: (response) => { if (state.active && response?.headers) state.active.lastResponseHeaders = response.headers; },
417
+ sessionId,
418
+ // pi-agent-core's Agent attributes requests to pi (OpenRouter, NVIDIA NIM and OpenCode headers); these are
419
+ // CozyClay's requests, and pi 0.99 sent no attribution, so none is sent.
420
+ identity: { attribution: null },
388
421
  toolExecution: "sequential",
389
422
  steeringMode: "one-at-a-time",
390
- compaction: { enabled: false, reserveTokens: 0, keepRecentTokens: 0, ...compaction },
391
- retry: RETRY_POLICY,
392
- streamOptions: { maxRetryDelayMs: RETRY_MAX_DELAY_MS, ...(codexBaseUrl ? { transport: "sse" } : {}) },
393
- }, state.context)).harness;
394
- state.harness.hooks.on("after_response", (response) => emitQuota(state.active?.queue, state.lastInput, response));
395
- state.harness.hooks.on("after_response", ({ message }) => {
396
- const usage = state.active?.usage;
397
- if (!usage) return;
398
- usage.requests += 1;
399
- for (const key of ["input", "output", "cacheRead", "cacheWrite"]) if (Number.isFinite(message?.usage?.[key])) usage[key] += message.usage[key]; if (Number.isFinite(message?.usage?.cost?.total)) state.active.cost = (state.active.cost ?? 0) + message.usage.cost.total;
400
- });
401
- state.harness.hooks.on("transform_context", (event) => state.lastInput?.surface === "studio" ? budgetStudioRequest(studioContextDiet(event)) : undefined);
402
- state.harness.hooks.on("before_payload", ({ model, payload }) => {
403
- if (model?.api !== "anthropic-messages") return undefined;
404
- const next = withAnthropicCacheBreakpoints(payload, model);
405
- return next ? { payload: next } : undefined;
423
+ maxRetryDelayMs: RETRY_MAX_DELAY_MS,
424
+ ...(codexBaseUrl ? { transport: "sse" } : {}),
406
425
  });
407
- state.harness.hooks.on("after_response", async ({ message }) => {
408
- if (message.stopReason !== "error" || state.provider !== "openai-codex" || state.active.authRetried
409
- || classifyError({ message: message.errorMessage }).code !== "unauthorized") return;
410
- state.active.authRetried = true;
411
- try { await state.registry?.getAuth?.("openai-codex", { minOAuthValidityMs: Number.MAX_SAFE_INTEGER }); } catch { /* the retried call resolves credentials again and surfaces any refresh failure */ }
412
- // "server error" opts this one auth failure into pi's generation retry.
413
- // It preserves completed tools and re-resolves credentials in place;
414
- // failed assistant attempts stay out of provider context and JSONL.
415
- return { message: { ...message, errorMessage: `${message.errorMessage} (credentials refreshed; server error, retrying)` } };
416
- });
417
- state.lane = await state.harness.lane("main", state.context);
418
- const restored = sessionStore?.read?.(sessionId);
419
- if (restored?.history?.length) {
420
- for (const message of restored.history) await state.lane.appendMessage(message, state.context);
421
- state.persisted = restored.history.length;
422
- }
423
- // Studio only, on the provider view (studioContextDiet, then studio-context-budget.mjs): the stored
424
- // lane and the persisted history keep every context, result, script and image whole.
425
- state.unsubscribers.push(state.harness.events.on("turn_end", persist));
426
- state.unsubscribers.push(state.harness.events.on("run_end", persist));
426
+ state.persisted = restored?.history?.length ?? 0;
427
+ state.unsubscribers.push(state.agent.subscribe(async (event) => { if (event.type === "turn_end" || event.type === "agent_end") await persist(); }));
427
428
  }
428
429
  state.currentModel = selected.model;
429
- state.registry = registry;
430
430
  state.provider = selected.provider;
431
- await state.lane.setModel({ provider: selected.provider, modelId: selected.modelId }, state.context);
432
- if (thinkingLevel !== undefined) await state.lane.setThinkingLevel(thinkingLevel, state.context);
431
+ state.agent.state.model = selected.model;
432
+ if (thinkingLevel !== undefined) state.agent.state.thinkingLevel = thinkingLevel;
433
433
  return { registry, selected };
434
434
  };
435
435
 
@@ -437,7 +437,6 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
437
437
  const toolStartedAt = new Map();
438
438
  const toolStarted = new Set();
439
439
  const toolCompleted = new Set();
440
- const eventUnsubscribers = [];
441
440
  // The frame gate must never hold a turn's first visible output waiting
442
441
  // on generation to finish (#379, F2 pass 5 finding 4): the ONLY thing it
443
442
  // still waits for is the quota frame itself, and only long enough to
@@ -449,14 +448,8 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
449
448
  if (state.active && !state.active.quotaSent && frame.type !== "quota") sendQuota(queue, state.lastInput);
450
449
  queue.push(frame);
451
450
  };
452
- const subscribe = (type, listener) => eventUnsubscribers.push(state.harness.events.on(type, listener));
453
451
  // The reasoning summary and the tool call being written, for the panel (turn-frames.mjs).
454
452
  const reasoning = createReasoningFrames(pushFrame, clock);
455
- subscribe("message_update", (event) => {
456
- reasoning.onMessageEvent(event.event);
457
- if (event.event?.type === "text_delta") pushFrame({ type: "text.delta", text: event.event.delta });
458
- if (event.event?.type === "error") pushFrame(errorFrame(event.event.error, event.event.reason === "aborted"));
459
- });
460
453
  const startTool = (event) => {
461
454
  if (toolStarted.has(event.toolCallId)) return;
462
455
  toolStarted.add(event.toolCallId);
@@ -465,9 +458,11 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
465
458
  const tool = (Array.isArray(activeTools) ? activeTools : []).find((candidate) => candidate.name === event.toolName);
466
459
  pushFrame({ type: "tool.start", callId: event.toolCallId, name: event.toolName, label: tool?.label || event.toolName.replaceAll("_", " "), args: summariseCanvasResult(event.args) });
467
460
  };
468
- subscribe("tool_start", startTool);
469
- subscribe("tool_end", (event) => {
461
+ const endTool = (event) => {
470
462
  if (toolCompleted.has(event.toolCallId)) return;
463
+ // A call the run's abort ended (pi-tools.mjs untilAborted) stays unfinished, as under the removed
464
+ // AgentHarness: the route reports it cancelled and the panel shows it stopped with an unknown result.
465
+ if (event.isError && state.agent?.signal?.aborted) { state.active?.abortedCalls.add(event.toolCallId); return; }
471
466
  startTool(event);
472
467
  toolCompleted.add(event.toolCallId);
473
468
  const started = toolStartedAt.get(event.toolCallId) ?? clock();
@@ -477,9 +472,13 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
477
472
  const message = event.result?.content?.find((part) => part.type === "text")?.text || "Tool execution failed.";
478
473
  pushFrame({ type: "tool.done", callId: event.toolCallId, ok: false, elapsedMs, error: message });
479
474
  } else pushFrame({ type: "tool.done", callId: event.toolCallId, ok: true, elapsedMs, result: details });
480
- });
481
- subscribe("run_end", (event) => {
475
+ };
476
+ const finish = (event) => {
482
477
  if (state.active?.pendingFrames.length) { for (const pending of state.active.pendingFrames) queue.push(pending); state.active.pendingFrames = []; }
478
+ // The run's outcome is its last assistant message: the Agent ends a
479
+ // failed or cancelled request there (stopReason error/aborted).
480
+ const last = event.messages.findLast((message) => message.role === "assistant");
481
+ const status = last?.stopReason === "aborted" ? "aborted" : last?.stopReason === "error" ? "error" : "completed";
483
482
  // #379 / 16q: an acknowledged Studio Stop whose held tool already settled
484
483
  // with a real structured outcome (tool.done above) calls `abort(reason,
485
484
  // { quiet: true })` purely to stop the model from being prompted again —
@@ -487,21 +486,27 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
487
486
  // told about a second time via a synthesized error. Only THIS run's own
488
487
  // quiet flag suppresses the error frame; a stop with no active job (no
489
488
  // resolvable outcome) never sets it and still surfaces error{aborted}.
490
- const quiet = event.status === "aborted" && state.active?.quietAbort;
491
- if (event.status !== "completed" && !quiet) {
492
- const rawMessage = event.error?.message || (event.status === "aborted" ? "The turn was aborted." : "The model or live editor could not complete this turn.");
489
+ const quiet = status === "aborted" && state.active?.quietAbort;
490
+ if (status !== "completed" && !quiet) {
491
+ const rawMessage = last?.errorMessage || (status === "aborted" ? "The turn was aborted." : "The model or live editor could not complete this turn.");
493
492
  const truncated = /ended before a terminal response event/i.test(rawMessage);
494
- const classified = event.status === "aborted" ? { code: "aborted", status: undefined } : classifyError(event.error || {});
495
- const code = event.status === "aborted" ? "aborted" : truncated ? "truncated" : classified.code;
496
- const status = classified.status;
493
+ const classified = status === "aborted" ? { code: "aborted", status: undefined } : classifyError({ message: rawMessage });
494
+ const code = status === "aborted" ? "aborted" : truncated ? "truncated" : classified.code;
497
495
  const detail = sanitizeUpstreamDetail(JSON.stringify({ message: rawMessage }));
498
496
  const fallback = code === "aborted" ? "The turn was aborted." : code === "truncated" ? "The model stream ended before a terminal response event." : "The model or live editor could not complete this turn.";
499
- queue.push({ type: "error", code, message: detail || fallback, ...(status ? { status } : {}) });
497
+ queue.push({ type: "error", code, message: detail || fallback, ...(classified.status ? { status: classified.status } : {}) });
500
498
  }
501
499
  queue.push({ type: "done", usage: { ...state.active.usage }, ...(state.lastInput?.reportCost ? { cost: state.active.cost ?? 0 } : {}) });
502
500
  queue.close();
501
+ };
502
+ return state.agent.subscribe((event) => {
503
+ if (event.type === "message_update") {
504
+ reasoning.onMessageEvent(event.assistantMessageEvent);
505
+ if (event.assistantMessageEvent?.type === "text_delta") pushFrame({ type: "text.delta", text: event.assistantMessageEvent.delta });
506
+ } else if (event.type === "tool_execution_start") startTool(event);
507
+ else if (event.type === "tool_execution_end") endTool(event);
508
+ else if (event.type === "agent_end") finish(event);
503
509
  });
504
- return () => { for (const unsubscribe of eventUnsubscribers) unsubscribe(); };
505
510
  };
506
511
 
507
512
  const start = async function* (input = {}) {
@@ -513,41 +518,38 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
513
518
  const queue = new FrameQueue();
514
519
  // Studio never sends a quota frame at all (unchanged, #379): mark it sent
515
520
  // up front so `pushFrame`'s gate never holds Studio's first frame.
516
- state.active = { queue, quotaSent: input.surface === "studio", pendingFrames: [], authRetried: false, abortRequested: false, lastResponseHeaders: null, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 } };
521
+ state.active = { queue, quotaSent: input.surface === "studio", pendingFrames: [], authRetried: false, abortRequested: false, abortedCalls: new Set(), lastResponseHeaders: null, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 } };
517
522
  state.lastInput = input;
518
523
  const controller = input.signal ? null : new AbortController();
519
524
  const signal = input.signal || controller.signal;
520
525
  state.lastInput.signal = signal;
526
+ // The caller's own signal (a Stop, a disconnect, a deadline) aborts the turn exactly like session.abort() does.
527
+ const cancel = () => { if (state.active) state.active.abortRequested = true; state.agent?.abort(); };
521
528
  const run = (async () => {
522
529
  let removeEventListeners;
523
530
  try {
524
- await ensureHarness(input);
525
- // Keep the lane history, but replace per-turn tool closures (admission,
531
+ await ensureAgent(input);
532
+ // Keep the transcript, but replace per-turn tool closures (admission,
526
533
  // workspace and cancellation). Auth retry reuses these same bindings.
527
- // The Tasks switch swaps the whole tool set inside one session; the lane's
528
- // active names must follow, or pi refuses the turn (configured_tools_unavailable).
529
- if (state.harness && input.tools !== undefined) { const turnTools = await adapters(input); await state.harness.setTools(turnTools, state.context); await state.lane.setActiveTools(turnTools.map((tool) => tool.name), state.context); }
534
+ // The Tasks switch swaps the whole tool set inside one session; the
535
+ // Agent declares the change to the model in a system message.
536
+ if (input.tools !== undefined) state.agent.state.tools = await adapters(input);
530
537
  removeEventListeners = mapEvents(queue);
531
- // Compose and deliver once; pi retries provider calls in place without
538
+ // Compose and deliver once; agent-stream.mjs retries provider calls in place without
532
539
  // repeating attachments, captures or completed tool executions.
533
- let composed;
540
+ let prompts;
534
541
  if (input.surface === "studio") {
535
542
  const studioContext = typeof input.studioContextText === "string" ? input.studioContextText : `<studio-context>\n${input.contextText || ""}\n</studio-context>`;
536
543
  const attachments = Array.isArray(input.attachments) ? input.attachments : [];
537
544
  if (input.frameObservation) {
538
- const observation = studioObservationMessage(input.frameObservation);
539
- composed = { appends: [studioUserMessage({ ...input, studioContextText: studioContext })], text: observation.content[0].text, images: [observation.content[1]] };
545
+ prompts = [studioUserMessage({ ...input, studioContextText: studioContext }), studioObservationMessage(input.frameObservation)];
540
546
  } else {
541
547
  // Same shape as studioUserMessage/attachmentMessage: one text part per
542
548
  // attachment label immediately followed by its image, then the studio
543
549
  // context and the user prompt as their OWN text parts (not concatenated
544
550
  // into one string) so session-store's transcriptFromHistory restores
545
551
  // the prompt text and the attachment names intact (#379, F2 pass 5).
546
- const message = {
547
- role: "user",
548
- content: [...studioAttachmentContent(attachments), { type: "text", text: studioContext }, { type: "text", text: input.text || "" }],
549
- };
550
- composed = { appends: [], text: message, images: undefined };
552
+ prompts = [{ role: "user", content: [...studioAttachmentContent(attachments), { type: "text", text: studioContext }, { type: "text", text: input.text || "" }] }];
551
553
  }
552
554
  } else {
553
555
  let text = input.text || "";
@@ -565,10 +567,11 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
565
567
  throw error;
566
568
  }
567
569
  }
568
- composed = { appends: (Array.isArray(input.attachments) ? input.attachments : []).map(attachmentMessage), text, images: [] };
570
+ prompts = [...(Array.isArray(input.attachments) ? input.attachments : []).map(attachmentMessage), { role: "user", content: [{ type: "text", text }] }];
569
571
  }
570
- for (const message of composed.appends) await state.lane.appendMessage(message, state.context);
571
- const promptContext = input.signal ? (await loadPi()).withAbortSignal(signal, state.context) : state.context;
572
+ const now = Date.now();
573
+ prompts = prompts.map((message) => ({ timestamp: now, ...message }));
574
+ signal.addEventListener("abort", cancel, { once: true });
572
575
  // The seam that lets the browser see codex quota headers before the
573
576
  // response body is read (#379, F2 pass 5 finding 4): `codexResponseObserver`
574
577
  // (bin/agent/providers.mjs) is an AsyncLocalStorage whose store this ONE
@@ -576,10 +579,15 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
576
579
  // patch it installs calls back into it the instant a `/codex/responses`
577
580
  // Response exists, correlated back to this turn by async context alone.
578
581
  const { codexResponseObserver } = await import("./providers.mjs");
579
- await codexResponseObserver.run((response) => { if (state.active) state.active.lastResponseHeaders = response.headers; }, () => state.lane.prompt(composed.text, composed.images, promptContext));
582
+ await codexResponseObserver.run((response) => { if (state.active) state.active.lastResponseHeaders = response.headers; }, () => {
583
+ const prompted = state.agent.prompt(prompts);
584
+ if (signal.aborted) cancel();
585
+ return prompted;
586
+ });
580
587
  } catch (error) {
581
588
  if (!queue.closed) { queue.push(errorFrame(error, signal.aborted)); queue.push({ type: "done" }); queue.close(); }
582
589
  } finally {
590
+ signal.removeEventListener("abort", cancel);
583
591
  removeEventListeners?.();
584
592
  }
585
593
  })();
@@ -588,23 +596,24 @@ export function createAgentRunner({ models: suppliedModels, sessionStore, tools
588
596
 
589
597
  const publicSession = {
590
598
  start,
591
- steer: async (text, images = []) => state.lane?.steer(text, images, state.context),
599
+ steer: async (text, images = []) => state.agent?.steer({ role: "user", content: [{ type: "text", text }, ...images], timestamp: Date.now() }),
592
600
  abort: async (reason, { quiet = false } = {}) => {
593
- // Set synchronously, before the (async) `lane.abort()` round-trip: any
594
- // `persist` that runs after this point — for the turn that is active
595
- // right now — must withhold its assistant message, whether or not the
596
- // underlying generation happens to finish before pi's cancellation
597
- // actually takes effect. `quiet` (#379 / 16q) additionally withholds the
598
- // run_end error frame this abort would otherwise cause: the caller
599
- // already has a real, settled tool outcome to show instead (a Studio
600
- // Stop that the motion runtime acknowledged) and is only using this
601
- // abort to stop the model from being prompted again.
601
+ // Set synchronously, before the run winds down: any `persist` that runs
602
+ // after this point — for the turn that is active right now — must
603
+ // withhold its assistant message, whether or not the underlying
604
+ // generation happens to finish before pi's cancellation actually takes
605
+ // effect. `quiet` (#379 / 16q) additionally withholds the run's error
606
+ // frame this abort would otherwise cause: the caller already has a real,
607
+ // settled tool outcome to show instead (a Studio Stop that the motion
608
+ // runtime acknowledged) and is only using this abort to stop the model
609
+ // from being prompted again.
602
610
  if (state.active) { state.active.abortRequested = true; if (quiet) state.active.quietAbort = true; }
603
- if (state.lane) return state.lane.abort(state.context);
604
- return { ok: false, reason };
611
+ if (!state.agent?.state.isStreaming) return { ok: false, reason };
612
+ state.agent.abort();
613
+ return { ok: true };
605
614
  },
606
- snapshot: async () => state.lane ? state.lane.inspectExecution(state.context) : null,
607
- close: async () => { for (const unsubscribe of state.unsubscribers.splice(0)) unsubscribe(); await state.harness?.close(state.context); await state.repo.close(state.context); },
615
+ snapshot: async () => (state.agent ? { streaming: state.agent.state.isStreaming, pendingToolCalls: [...state.agent.state.pendingToolCalls], messages: conversation().length } : null),
616
+ close: async () => { for (const unsubscribe of state.unsubscribers.splice(0)) unsubscribe(); state.agent?.abort(); await state.agent?.waitForIdle(); },
608
617
  };
609
618
  state.public = publicSession;
610
619
  openSessions.set(sessionId, state);