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.
- package/CHANGELOG.md +37 -0
- package/README.md +1 -1
- package/bin/agent/agent-runner.mjs +160 -151
- package/bin/agent/agent-stream.mjs +86 -0
- package/bin/agent/anthropic-subscription-events.mjs +120 -0
- package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
- package/bin/agent/anthropic-subscription-stream.mjs +251 -0
- package/bin/agent/anthropic-subscription.mjs +73 -0
- package/bin/agent/claude-code-executable.mjs +93 -0
- package/bin/agent/cliproxy-provider.mjs +25 -11
- package/bin/agent/model-roles.mjs +112 -0
- package/bin/agent/openai-compatible-provider.mjs +95 -0
- package/bin/agent/pi-tools.mjs +19 -10
- package/bin/agent/provider-login.mjs +3 -1
- package/bin/agent/provider-routes.mjs +2 -0
- package/bin/agent/providers.mjs +35 -130
- package/bin/cozyclay.mjs +2 -48
- package/bin/star-prompt.mjs +123 -0
- package/dist/app/index.html +5 -5
- package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
- package/dist/assets/app-BdRlMf4J.css +1 -0
- package/dist/assets/app-KXdgBC-S.js +5144 -0
- package/dist/assets/{motion3-compile-dgGhrvPK.js → motion3-compile-DDG3wklD.js} +1 -1
- package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
- package/dist/assets/task-board-DFfNa0xg.js +15 -0
- package/dist/assets/{workflow-C87p6E1E.js → workflow-BMJGd_gZ.js} +1 -1
- package/dist/cozyclay-package.json +1 -1
- package/dist/workflow/index.html +3 -3
- package/package.json +6 -4
- package/src/App.jsx +28 -28
- package/src/agent-image-client.js +2 -2
- package/src/asset-pane.jsx +14 -14
- package/src/character-kind.js +13 -12
- package/src/commands/project.js +2 -1
- package/src/commands/shot.js +1 -1
- package/src/commands/view.js +2 -2
- package/src/domains/cast.js +15 -15
- package/src/domains/motion/motion-range-pin-rules.js +1 -1
- package/src/domains/motion.js +11 -11
- package/src/domains/objects.js +20 -30
- package/src/domains/scenes.js +14 -21
- package/src/domains/shots.js +3 -3
- package/src/first-success-guide.jsx +19 -3
- package/src/hierarchy-panel.jsx +6 -7
- package/src/locale-zh/app.js +124 -0
- package/src/locale-zh/domains.js +36 -0
- package/src/locale-zh/labels.js +148 -0
- package/src/locale-zh/panels.js +225 -0
- package/src/locale-zh/shell.js +230 -0
- package/src/locale-zh/timeline.js +145 -0
- package/src/locale-zh/workflow.js +104 -0
- package/src/locale-zh/workspace.js +190 -0
- package/src/locale-zh.js +21 -0
- package/src/locale.js +16 -6
- package/src/main.jsx +1 -1
- package/src/motion/beat-board-model.js +4 -4
- package/src/motion/beat-board.jsx +1 -1
- package/src/motion/ik-pick.js +24 -0
- package/src/motion/take-segments.jsx +5 -5
- package/src/motion/timeline.jsx +30 -40
- package/src/motion/waypoints.js +6 -6
- package/src/object-catalog.jsx +4 -5
- package/src/panels/CameraPanel.jsx +3 -3
- package/src/panels/HdrEnvironmentControls.jsx +2 -1
- package/src/panels/ObjectTransformPanel.jsx +19 -29
- package/src/panels/PoseImagePanel.jsx +8 -8
- package/src/panels/PosePanel.jsx +2 -2
- package/src/panels/ReferenceImageField.jsx +2 -2
- package/src/panels/RigControlPanel.jsx +2 -2
- package/src/panels/SculptPanel.jsx +9 -9
- package/src/panels/SubjectBox.jsx +3 -3
- package/src/panels/SubjectsPanel.jsx +1 -1
- package/src/posestudio.jsx +10 -8
- package/src/project-browser.css +7 -6
- package/src/project-browser.jsx +7 -6
- package/src/project-open-file.js +19 -0
- package/src/qa-label.js +1 -1
- package/src/range-pin-panel.jsx +14 -14
- package/src/resource-status.jsx +3 -3
- package/src/result-modal.jsx +10 -10
- package/src/settings-menu.jsx +4 -1
- package/src/shell/BoardSlot.jsx +5 -5
- package/src/shell/CompositionGuideButton.jsx +1 -1
- package/src/shell/GhostsMenuGroup.jsx +1 -1
- package/src/shell/StatusBar.jsx +4 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +2 -2
- package/src/shell/details-selection.js +1 -1
- package/src/shell/preference-controls.jsx +2 -1
- package/src/shot-guides.js +5 -5
- package/src/stage-hdr.js +3 -3
- package/src/stage-labels-ko.js +2 -2
- package/src/stage-startup-defaults.js +3 -3
- package/src/studio-actions.js +2 -2
- package/src/styles/tokens.css +1 -1
- package/src/styles.css +1 -1
- package/src/use-case-question.jsx +18 -18
- package/src/workflow/AgentPanel.jsx +4 -4
- package/src/workflow/AgentPlanCard.jsx +1 -1
- package/src/workflow/AgentProgressCard.jsx +4 -4
- package/src/workflow/AgentTurnSteps.jsx +1 -1
- package/src/workflow/LoginPanel.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +1 -1
- package/src/workflow/TaskInline.jsx +1 -1
- package/src/workflow/agent-client.js +1 -1
- package/src/workflow/agent-turn.js +5 -5
- package/src/workflow/task-board.js +4 -4
- package/tools/loc-baseline.json +2 -2
- package/tools/run-tests.mjs +5 -5
- package/dist/assets/TaskBoard-GR8xZm5c.js +0 -1
- package/dist/assets/app-Bhe0TpGz.css +0 -1
- package/dist/assets/app-C4QTcDcb.js +0 -5144
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +0 -13
- 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)
|
|
9
|
-
//
|
|
10
|
-
// `isRetryableAssistantError`)
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
251
|
+
let piModules;
|
|
248
252
|
|
|
249
253
|
const loadPi = async () => {
|
|
250
254
|
if (piModules) return piModules;
|
|
251
|
-
const [{
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
298
|
-
// own
|
|
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 "
|
|
301
|
-
//
|
|
302
|
-
//
|
|
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.
|
|
308
|
-
const
|
|
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
|
|
314
|
-
const
|
|
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
|
-
|
|
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 =
|
|
321
|
-
const code =
|
|
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 `
|
|
341
|
-
// `
|
|
342
|
-
// provider, or a codex call that has not resolved headers
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// is idempotent (`quotaSent` guards
|
|
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
|
|
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
|
-
|
|
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 (
|
|
377
|
-
// For explicit none, pi
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
model: selected.model,
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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.
|
|
408
|
-
|
|
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
|
-
|
|
432
|
-
if (thinkingLevel !== undefined)
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
491
|
-
if (
|
|
492
|
-
const rawMessage =
|
|
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 =
|
|
495
|
-
const 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
|
|
525
|
-
// Keep the
|
|
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
|
|
528
|
-
//
|
|
529
|
-
if (
|
|
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;
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
+
prompts = [...(Array.isArray(input.attachments) ? input.attachments : []).map(attachmentMessage), { role: "user", content: [{ type: "text", text }] }];
|
|
569
571
|
}
|
|
570
|
-
|
|
571
|
-
|
|
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; }, () =>
|
|
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.
|
|
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
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
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.
|
|
604
|
-
|
|
611
|
+
if (!state.agent?.state.isStreaming) return { ok: false, reason };
|
|
612
|
+
state.agent.abort();
|
|
613
|
+
return { ok: true };
|
|
605
614
|
},
|
|
606
|
-
snapshot: async () => state.
|
|
607
|
-
close: async () => { for (const unsubscribe of state.unsubscribers.splice(0)) unsubscribe();
|
|
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);
|