codeply-cli 0.2.0 → 0.3.1

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 (43) hide show
  1. package/THIRD_PARTY_NOTICES.md +63 -0
  2. package/bin/codeply.js +558 -370
  3. package/lib/agent.mjs +1627 -160
  4. package/lib/ai.js +1107 -387
  5. package/lib/apply-limit.js +33 -51
  6. package/lib/apply-patch.js +217 -0
  7. package/lib/arity-table.json +138 -0
  8. package/lib/arity.js +70 -0
  9. package/lib/auth.js +118 -112
  10. package/lib/commands.js +103 -0
  11. package/lib/config.js +392 -136
  12. package/lib/design-library/artifact-template.html +500 -0
  13. package/lib/design-library/assemble-artifact.mjs +21 -0
  14. package/lib/design-library/build.mjs +226 -0
  15. package/lib/design-library/cleanup.mjs +45 -0
  16. package/lib/design-library/index.json +22380 -0
  17. package/lib/design-library/make-table-data.mjs +39 -0
  18. package/lib/design-library/query.mjs +104 -0
  19. package/lib/diagnostics.js +192 -0
  20. package/lib/edit-engine.js +171 -14
  21. package/lib/github-agent.mjs +307 -0
  22. package/lib/history.js +75 -0
  23. package/lib/mcp.js +258 -0
  24. package/lib/native-tools.mjs +189 -0
  25. package/lib/oauth-connectors.js +478 -0
  26. package/lib/permissions.js +109 -0
  27. package/lib/plugins.js +353 -0
  28. package/lib/render-md.mjs +617 -617
  29. package/lib/server.mjs +457 -0
  30. package/lib/session-db.js +220 -0
  31. package/lib/share.js +168 -0
  32. package/lib/skills.js +80 -22
  33. package/lib/snapshot.js +146 -0
  34. package/lib/subagents.js +164 -0
  35. package/lib/terminal-hints.js +154 -0
  36. package/lib/textarea.mjs +258 -258
  37. package/lib/tools.mjs +1658 -79
  38. package/lib/tui.js +7 -7
  39. package/lib/tui.mjs +1086 -992
  40. package/lib/typecheck-worker.js +147 -0
  41. package/lib/web-tools.js +158 -0
  42. package/package.json +4 -3
  43. package/skills/premium-web-design/SKILL.md +154 -0
package/lib/agent.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Codeply agent loop.
3
3
  *
4
- * The ai-proxy exposes a plain chat completion — no native function calling —
4
+ * The ai-proxy exposes a plain chat completion - no native function calling -
5
5
  * so tool use rides on a tag protocol the model writes into its reply and we
6
6
  * parse back out. Tags are used rather than JSON because file content goes in
7
7
  * verbatim: no escaping pass to get wrong, and models corrupt long JSON strings
@@ -11,31 +11,52 @@
11
11
  * instead of freezing until the whole task finishes.
12
12
  */
13
13
  import fs from 'fs';
14
+ import os from 'os';
14
15
  import path from 'path';
15
16
  import { execSync } from 'child_process';
16
17
  import { createRequire } from 'module';
17
- import { executeTool, TOOL_NEEDS_APPROVAL, walkFiles, resolvePath } from './tools.mjs';
18
+ import { executeTool, TOOL_NEEDS_APPROVAL, walkFiles, resolvePath, formatTodos, isPlanPath } from './tools.mjs';
19
+ import { buildToolSchemas, toNativeMessages, toolCallsToTags, NATIVE_FORMAT, TOOLS_UNSUPPORTED } from './native-tools.mjs';
18
20
 
19
21
  const require = createRequire(import.meta.url);
20
22
  const ai = require('./ai.js');
23
+ const mcpLib = require('./mcp.js');
21
24
  const skills = require('./skills.js');
25
+ const plugins = require('./plugins.js');
26
+ const rolesLib = require('./subagents.js');
27
+ const config = require('./config.js');
22
28
 
23
- const MAX_STEPS = 24;
29
+ // Enough room for a real task plus its verification pass. /goal runs pass a
30
+ // larger budget per iteration (see main.js).
31
+ const MAX_STEPS = 40;
24
32
  const MAX_MALFORMED_RETRIES = 3;
25
- // Deliberately conservative. The proxy is a Supabase Edge Function with its own
26
- // memory/CPU ceiling — it returns 546 when it blows through it — so keeping each
27
- // request small matters more here than squeezing in extra history.
28
- const CONTEXT_CHAR_BUDGET = 48000;
33
+ // How much transcript (system prompt included) is sent per step before older
34
+ // tool results get collapsed. The hosted proxy is a Supabase Edge Function
35
+ // with its own memory ceiling (it returns 546 past it), so Auto stays modest;
36
+ // the system prompt alone is ~40K chars, so this has to leave real room for
37
+ // the conversation or the model loses track of what it already did. User
38
+ // models usually have far larger windows; local Ollama runs at 32K tokens.
39
+ const CONTEXT_CHAR_BUDGET = 64000;
40
+ function contextBudgetFor(route) {
41
+ if (route && route.custom) return route.custom.kind === 'ollama' ? 100000 : 200000;
42
+ if (route && route.auto) return 160000; // Gemma 4 31B on Ollama Cloud has a large window
43
+ return CONTEXT_CHAR_BUDGET;
44
+ }
29
45
 
30
46
  const FORMAT_CORRECTION =
31
- '[system] Your action block was not in the required form. Do not invent wrappers like <toolcall>, ' +
32
- 'do not drop the "codeply:" prefix, and keep the underscore in the name. Write it exactly like this, ' +
33
- 'opening and closing tags complete:\n\n' +
47
+ '[system] Your action block was not in the required form. Do not invent wrappers like <toolcall> or ' +
48
+ '<|tool_call>, do not drop the "codeply:" prefix, and keep the underscore in the name. There is no native ' +
49
+ 'function-calling channel here - even if you have one, it is not connected, so anything you write there is ' +
50
+ 'silently discarded and never runs. Write it exactly like this, as literal text in your reply, opening and ' +
51
+ 'closing tags complete:\n\n' +
34
52
  '<codeply:read_file>\n<path>run.html</path>\n</codeply:read_file>\n\n' +
35
- 'Valid names: list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills.';
53
+ 'Valid names: todo, ask_user, mcp, list_dir, read_file, write_file, edit_file, search, run, use_skill, list_skills, fetch_image, ' +
54
+ 'browser_check, gmail_send, gmail_search, slack_post_message, vercel_deploy, supabase_create_project, ' +
55
+ 'supabase_delete_project, github_create_repo, design_reference_search, view_images, supabase_api, supabase_sql, vercel_api, ' +
56
+ 'web_fetch, web_search, apply_patch, plan_exit, plan_enter, lsp.';
36
57
 
37
58
  const TRUNCATION_CORRECTION =
38
- '[system] That reply got cut off partway through the action block — the tag syntax was fine, it simply ran out of ' +
59
+ '[system] That reply got cut off partway through the action block - the tag syntax was fine, it simply ran out of ' +
39
60
  'room before the block closed. This happens when write_file or edit_file tries to carry too much content in one reply. ' +
40
61
  "Don't re-explain or apologize for the format, just write less this time: for a new or rewritten file, write_file only " +
41
62
  'the first section now and use edit_file to add the rest over one or two more steps; for an edit, split it into smaller ' +
@@ -43,13 +64,207 @@ const TRUNCATION_CORRECTION =
43
64
 
44
65
  const MAX_TRUNCATED_RETRIES = 3;
45
66
 
67
+ // A reply that CLAIMS a file was written/edited/fixed, in the past tense,
68
+ // while itself containing no action block at all - and with no real
69
+ // write_file/edit_file success anywhere earlier in this same turn - is not
70
+ // "finished", it's a confabulated result. This is a real, observed failure
71
+ // mode on weaker models: they narrate "I have now applied the change" as
72
+ // filler text instead of actually emitting the tag, and the user is left
73
+ // with a chat that describes progress that never happened on disk. Matches
74
+ // only completion language (past tense / "successfully" / "has been"), not
75
+ // forward-looking intent ("I will now update...") - a plan is not a lie.
76
+ // The adverb list between "have" and the verb is deliberately a closed set
77
+ // (now/already/actually/just/finally), not "any word" - "I have TO update"
78
+ // is a statement of necessity, not a completion claim, and must not match.
79
+ const HC_VERB = '(?:applied|updated|written|wrote|edited|fixed|changed|added|created|modified|implemented|built|removed|deleted|replaced|refactored|renamed|rewritten|rewrote|moved|made)';
80
+ const HC_ADVERB = '(?:now|already|actually|just|finally)';
81
+ const HALLUCINATED_COMPLETION = new RegExp(
82
+ `\\bi(?:'ve|\\s+have)\\s+(?:${HC_ADVERB}\\s+){0,2}${HC_VERB}\\b` + // "I have now already applied..."
83
+ `|\\bsuccessfully\\s+${HC_VERB}\\b` + // "successfully applied..."
84
+ `|\\b${HC_VERB}\\b[^.!?\\n]{0,20}\\bsuccessfully\\b` + // "...applied this successfully"
85
+ `|\\b(?:has|have)\\s+been\\s+${HC_VERB}\\b` + // "...has been applied"
86
+ `|\\bi\\s+(?:${HC_ADVERB}\\s+)?(?:updated|edited|fixed|changed|added|created|modified|implemented|removed|replaced|refactored|rewrote|wrote)\\s+(?:the|your|a|an|it|this|that|all|each|every)\\b`, // "I updated the header"
87
+ 'i',
88
+ );
89
+
90
+ // The model writing a tool result itself instead of waiting for the program's
91
+ // real one. Results only ever arrive as a separate message, so this text in
92
+ // the model's own reply is always invented.
93
+ const FABRICATED_RESULT = /\[tool result\b|^\s*(?:exit code|exited with code)\s*[:=]?\s*\d+\s*$/im;
94
+
95
+ const FABRICATED_RESULT_CORRECTION =
96
+ '[system] Your last reply contains a tool result you wrote yourself. Tool results only come from the program, in a ' +
97
+ 'separate message after you write an action block, and you have not received one for that. Nothing you described there ' +
98
+ 'actually ran. Write the real action block and stop, then wait for its result.';
99
+
100
+ const HALLUCINATED_ACTION_CORRECTION =
101
+ "[system] Your last reply describes a file as already changed, but it contained no action block, and nothing has actually " +
102
+ "been written or edited yet this turn. Do not narrate work as done that you have not done - that leaves the user's " +
103
+ "request unfulfilled while the chat claims otherwise. Write the real <codeply:edit_file> or <codeply:write_file> action " +
104
+ "block now (search text copied verbatim from a file you have actually read), or if you genuinely cannot proceed, say so " +
105
+ "plainly instead of claiming success.";
106
+
107
+ // Filename-shaped tokens mentioned in the completion sentence(s), used to catch
108
+ // the narrower case: a turn where a REAL edit did happen, so the check above
109
+ // (gated on !madeAnyEdit) never fires, but the reply's completion claim lists
110
+ // more files than were actually touched this turn - "I've created index.html,
111
+ // styles.css, and app.js" after only index.html was actually write_file'd.
112
+ const FILENAME_TOKEN = /\b[\w-]+\.(?:html|htm|css|js|jsx|ts|tsx|mjs|cjs|py|json|md|yml|yaml|txt|svg|vue|svelte)\b/gi;
113
+
114
+ function extractClaimedFilenames(prose) {
115
+ const m = prose.match(FILENAME_TOKEN);
116
+ return m ? [...new Set(m.map((f) => f.toLowerCase()))] : [];
117
+ }
118
+
119
+ const overclaimCorrection = (missing) =>
120
+ `[system] Your last reply claims ${missing.length === 1 ? 'this file was' : 'these files were'} changed, but ` +
121
+ `${missing.length === 1 ? 'it was' : 'they were'} never actually written or edited this turn: ${missing.join(', ')}. ` +
122
+ "Only claim what you actually did. Either write the real <codeply:write_file> or <codeply:edit_file> action block for " +
123
+ `${missing.length === 1 ? 'it' : 'each of them'} now, or correct your summary to describe only the file(s) you truly changed.`;
124
+
125
+ // Claims about CHECKING work ("I ran the tests", "tests pass", "I verified it
126
+ // in the browser") and about SHIPPING it ("I've deployed", "pushed to
127
+ // GitHub") are the other common confabulations. Each is only accepted if a
128
+ // tool that could actually have done it ran successfully this turn. First-
129
+ // person / result phrasing only, so advice like "you should run the tests" or
130
+ // "it's not deployed yet" never trips it.
131
+ const CLAIM_VERIFIED = new RegExp(
132
+ `\\bi(?:'ve|\\s+have)\\s+(?:${HC_ADVERB}\\s+|also\\s+|successfully\\s+){0,2}(?:run|ran|tested|verified|executed|confirmed|checked)\\b` +
133
+ `|\\ball\\s+(?:the\\s+)?tests\\s+(?:now\\s+)?pass` +
134
+ `|\\btests?\\s+(?:now\\s+)?(?:pass(?:ed|es)?|succeed(?:ed|s)?)\\b` +
135
+ `|\\bbuild\\s+(?:now\\s+)?(?:passes|succeeded|is\\s+successful)\\b` +
136
+ `|\\bverified\\s+(?:that\\s+)?(?:it|the\\s+(?:page|app|site|fix|change))\\s+works`,
137
+ 'i',
138
+ );
139
+ const VERIFY_TOOLS = new Set(['run', 'browser_check']);
140
+
141
+ const CLAIM_SHIPPED = new RegExp(
142
+ `\\bi(?:'ve|\\s+have)\\s+(?:${HC_ADVERB}\\s+|also\\s+|successfully\\s+){0,2}(?:deployed|pushed|committed|published)\\b` +
143
+ `|\\bsuccessfully\\s+(?:deployed|pushed|committed|published)\\b` +
144
+ `|\\b(?:is|are)\\s+now\\s+live\\s+(?:at|on)\\b`,
145
+ 'i',
146
+ );
147
+ const SHIP_TOOLS = new Set(['run', 'vercel_deploy', 'vercel_api', 'github_create_repo', 'supabase_api']);
148
+
149
+ const unbackedClaimCorrection = (what) =>
150
+ `[system] Your last reply says you ${what}, but no tool that could have done that ran successfully this turn. ` +
151
+ 'Never report an action or a result you did not actually get from a tool. Either do it now with a real action block ' +
152
+ '(for checks: <codeply:run> a test/build/syntax command, or <codeply:browser_check> the page), or rewrite your summary ' +
153
+ 'so it only states what you truly did and says plainly what you did not verify.';
154
+
155
+ // A file-changing turn is not finished until it's been checked. When the model
156
+ // tries to wrap up after editing code or pages without running anything
157
+ // against them since the last edit, it gets sent back once to verify.
158
+ const VERIFIABLE_EXT = /\.(html?|css|scss|less|jsx?|tsx?|mjs|cjs|vue|svelte|py|go|rs|rb|php|java|kt|cs|c|cc|cpp|h|hpp|swift|json|ya?ml|toml|sql|sh)$/i;
159
+ const verifyBeforeDoneCorrection = (files) =>
160
+ `[system] Before you finish: you changed ${files.join(', ')} but haven't checked the result since your last edit. ` +
161
+ 'Verify it now - run a syntax check, the relevant test/lint/build script, or start the app; for a page, browser_check it ' +
162
+ 'and look at the screenshot. Fix anything that fails and check again. If there is genuinely nothing you can run for this ' +
163
+ 'change, say so explicitly in your final summary instead of implying it was tested.';
164
+
165
+ const failedCommandCorrection = (cmd, code) =>
166
+ `[system] The last command you ran (\`${cmd}\`) exited with code ${code}, so it failed. Do not describe the work as ` +
167
+ 'working or complete while that is unresolved. Read the error output, fix the cause and run it again - or, if the ' +
168
+ 'failure is unrelated to this task or can\'t be fixed here, say that plainly in your summary.';
169
+
170
+ // In Build mode, pasting a large code block into chat instead of writing it to
171
+ // a file usually means the user's requested change never happened.
172
+ const CODE_INSTEAD_OF_EDIT_CORRECTION =
173
+ '[system] You put a large code block in your reply but did not write any file this turn. If the user asked for a ' +
174
+ 'change to their project, apply it with write_file or edit_file instead of pasting code into chat. If they only ' +
175
+ 'wanted to see code (an explanation or a snippet to copy), you may finish as is.';
176
+
177
+ const MAX_HALLUCINATION_RETRIES = 3;
178
+ const MAX_VERIFY_NUDGES = 2;
179
+
180
+ // Look-around blocks that may share one reply (see the loop).
181
+ const BATCHABLE_TOOLS = new Set(['read_file', 'list_dir', 'search', 'web_fetch', 'web_search', 'lsp']);
182
+ const MAX_BATCH = 4;
183
+
184
+ // Identical call, identical arguments, this many times in a row = stuck.
185
+ const STUCK_REPEATS = 3;
186
+
187
+ function stuckCorrection(name, last) {
188
+ const failed = last && !last.ok;
189
+ const head = `[system] That is the same ${name} call ${STUCK_REPEATS} times in a row${failed ? ', and it failed every time' : ''}. ` +
190
+ 'It was not run again, because the same input gives the same result. Change your approach instead of repeating it:';
191
+ const ways = {
192
+ run: [
193
+ 'Read the error output of the last attempt word by word and fix what it actually names.',
194
+ 'If a command or package is missing, check with `where <cmd>` (Windows) or `command -v <cmd>`, then install it or use one that exists.',
195
+ 'Try a different way to reach the same goal: another tool, a script file instead of a long one-liner, or a smaller step first.',
196
+ 'Check you are in the right folder and the paths in the command exist (list_dir).',
197
+ ],
198
+ edit_file: [
199
+ 'read_file the exact region you are editing and copy the search text from that result.',
200
+ 'Make the search block smaller: 2-5 distinctive lines are enough.',
201
+ 'If the change is large, write_file the whole file instead.',
202
+ ],
203
+ }[name] || [
204
+ 'Use what that call already returned, or try a different tool or different arguments.',
205
+ 'If you are blocked, say plainly what is blocking you instead of retrying.',
206
+ ];
207
+ return `${head}\n${ways.map((w) => `- ${w}`).join('\n')}`;
208
+ }
209
+
210
+ // A reply with no action block that ends by announcing the next action ("Let
211
+ // me now update the header.") is not a final answer: the model meant to act
212
+ // and forgot the block. Seen from Hermes Agent's stall nudges (MIT, Nous
213
+ // Research). Questions to the user are excluded; those are real stops.
214
+ const ANNOUNCED_INTENT = /(?:^|[.!\n]\s*)(?:(?:ok(?:ay)?|now|next|so|great|alright|first|then),?\s+)*(?:let me|let's|i'll|i will|i'm going to|i am going to|i need to|now i(?:'ll| will)?)\s+(?:now\s+|also\s+|first\s+|go ahead and\s+)?(?:check|read|look|open|update|edit|fix|add|create|write|run|test|verify|search|find|install|implement|change|remove|apply|make|build|start|try|inspect|review|examine)\b[^?]{0,200}[.:!]?\s*$/i;
215
+ const STALL_CORRECTION =
216
+ '[system] Your reply announces what you are about to do but has no action block, so nothing ran and the task is ' +
217
+ 'not done. Write that action block now. If you are actually finished, give the final summary instead; if you ' +
218
+ 'need the user, ask them a direct question.';
219
+ const MAX_STALL_NUDGES = 2;
220
+
221
+ // Same tool failing again and again with DIFFERENT arguments is the other
222
+ // kind of stuck (the identical-call guard above doesn't see it). Warn, with
223
+ // ways around it, instead of letting it spiral. Threshold idea from Hermes
224
+ // Agent's tool_guardrails (MIT, Nous Research).
225
+ const FAILURE_STREAK_WARN = 3;
226
+ const failureStreakNote = (name, n) =>
227
+ `[system] ${name} has failed ${n} times in a row this turn. This looks like a loop. Stop and diagnose before the ` +
228
+ 'next attempt: what exactly did the last error say, and what assumption does it break? ' +
229
+ (name === 'run'
230
+ ? 'Check where you are and what exists (list_dir, or `cd` / `dir` / `ls`), try an absolute path, a simpler command, or a different tool for the same goal.'
231
+ : name === 'edit_file'
232
+ ? 'read_file the exact region first, or write_file the whole file if the edit is large.'
233
+ : 'Try a different tool or a genuinely different approach.') +
234
+ ' If nothing works, say plainly what is blocking you.';
235
+
236
+ // A,B,A,B (or A,B,C,A,B,C) with nothing changing in between: going in circles.
237
+ function findCycle(keys) {
238
+ for (const period of [2, 3]) {
239
+ if (keys.length < period * 2) continue;
240
+ const tail = keys.slice(-period * 2);
241
+ const a = tail.slice(0, period), b = tail.slice(period);
242
+ if (a.every((k, i) => k === b[i]) && new Set(a).size > 1) return period;
243
+ }
244
+ return 0;
245
+ }
246
+ const cycleNote = (period) =>
247
+ `[system] Your last ${period * 2} actions repeat the same ${period}-step pattern with the same inputs, and none of ` +
248
+ 'them changed anything. You are going in circles. Use what those results already told you and do something different.';
249
+
250
+ // Late in the step budget, one checkpoint so a long task wraps up properly
251
+ // instead of being cut off mid-edit. Idea from Hermes Agent / opencode.
252
+ const budgetCheckpointNote = (used, max) =>
253
+ `[system] You have used ${used} of ${max} steps for this turn. Finish the change you are in the middle of, check it, ` +
254
+ 'and wrap up with your summary. If the whole task cannot fit, get the current part to a working state and list what is left.';
255
+
256
+ const FINAL_SUMMARY_PROMPT =
257
+ '[system] The step budget for this turn is used up, so actions are switched off: do not write any action block, it ' +
258
+ 'would be ignored. Reply with a short summary for the user: what you actually changed (only files a tool result ' +
259
+ 'confirms), what you checked and the result, and what is left to do. Do not claim anything the tool results do not show.';
260
+
46
261
  // ─── Protocol ───────────────────────────────────────────────────────────────
47
262
 
48
263
  const TOOL_TAG = /<codeply:([a-z_]+)>([\s\S]*?)<\/codeply:\1>/g;
49
264
 
50
265
  // Params whose value is a raw payload (file content, code blocks) and may
51
266
  // legitimately contain angle-bracket tags of its own.
52
- const CONTAINER_PARAMS = new Set(['content', 'search', 'replace']);
267
+ const CONTAINER_PARAMS = new Set(['content', 'search', 'replace', 'body', 'text', 'patch']);
53
268
 
54
269
  /** Tag payloads are written on their own lines; drop only that framing. */
55
270
  function trimFraming(value) {
@@ -61,7 +276,7 @@ function trimFraming(value) {
61
276
  *
62
277
  * Container params are read first-open to last-close, so writing an HTML file
63
278
  * that itself contains `</content>` does not truncate the payload. Their spans
64
- * are then masked out before the scalar params are matched — otherwise a
279
+ * are then masked out before the scalar params are matched - otherwise a
65
280
  * `<path>` appearing inside that same HTML payload would be picked up as the
66
281
  * tool's path. (That is not hypothetical; it is what the test caught.)
67
282
  */
@@ -92,14 +307,37 @@ function extractParams(block, spec) {
92
307
  }
93
308
 
94
309
  const PARAMS = {
310
+ todo: ['items'],
311
+ ask_user: ['question', 'options'],
312
+ mcp: ['server', 'tool', 'args'],
95
313
  list_dir: ['path'],
96
314
  read_file: ['path', 'offset', 'limit'],
97
315
  write_file: ['path', 'content'],
98
- edit_file: ['path', 'search', 'replace'],
99
- search: ['pattern', 'glob', 'path'],
316
+ edit_file: ['path', 'search', 'replace', 'all'],
317
+ search: ['pattern', 'glob', 'path', 'context', 'files_only'],
100
318
  run: ['command'],
101
319
  use_skill: ['name'],
102
320
  list_skills: ['query'],
321
+ fetch_image: ['url', 'path'],
322
+ browser_check: ['url', 'wait', 'viewport'],
323
+ gmail_send: ['to', 'subject', 'body'],
324
+ gmail_search: ['query'],
325
+ slack_post_message: ['channel', 'text'],
326
+ vercel_deploy: ['path'],
327
+ supabase_create_project: ['name'],
328
+ supabase_delete_project: ['name', 'ref'],
329
+ github_create_repo: ['path', 'name'],
330
+ design_reference_search: ['term', 'category'],
331
+ view_images: ['urls'],
332
+ supabase_api: ['method', 'path', 'body'],
333
+ supabase_sql: ['ref', 'query'],
334
+ vercel_api: ['method', 'path', 'body'],
335
+ web_fetch: ['url', 'format'],
336
+ web_search: ['query', 'num'],
337
+ apply_patch: ['patch'],
338
+ plan_exit: ['path'],
339
+ plan_enter: ['reason'],
340
+ lsp: ['operation', 'path', 'line', 'character', 'symbol', 'query'],
103
341
  };
104
342
 
105
343
  // Names the model actually reaches for when it paraphrases the format.
@@ -107,6 +345,8 @@ const PARAMS = {
107
345
  // those are parameter names, and treating them as action names would misread
108
346
  // an edit_file block as a search.
109
347
  const NAME_ALIASES = {
348
+ ask_user: 'ask_user', askuser: 'ask_user', ask: 'ask_user', question: 'ask_user', askquestion: 'ask_user',
349
+ todo: 'todo', todos: 'todo', todowrite: 'todo', todo_write: 'todo', tasklist: 'todo', task_list: 'todo',
110
350
  read_file: 'read_file', readfile: 'read_file', read: 'read_file',
111
351
  open: 'read_file', cat: 'read_file', view: 'read_file', openfile: 'read_file',
112
352
  list_dir: 'list_dir', listdir: 'list_dir', list: 'list_dir',
@@ -118,6 +358,35 @@ const NAME_ALIASES = {
118
358
  run: 'run', bash: 'run', shell: 'run', exec: 'run', runcommand: 'run',
119
359
  use_skill: 'use_skill', useskill: 'use_skill', skill: 'use_skill', loadskill: 'use_skill',
120
360
  list_skills: 'list_skills', listskills: 'list_skills', skills: 'list_skills', findskill: 'list_skills',
361
+ fetch_image: 'fetch_image', fetchimage: 'fetch_image', downloadimage: 'fetch_image',
362
+ download_image: 'fetch_image', getimage: 'fetch_image',
363
+ browser_check: 'browser_check', browsercheck: 'browser_check', checkbrowser: 'browser_check',
364
+ previewcheck: 'browser_check', browserpreview: 'browser_check', check: 'browser_check',
365
+ gmail_send: 'gmail_send', gmailsend: 'gmail_send', sendemail: 'gmail_send', email: 'gmail_send', sendmail: 'gmail_send',
366
+ gmail_search: 'gmail_search', gmailsearch: 'gmail_search', searchemail: 'gmail_search', searchgmail: 'gmail_search',
367
+ slack_post_message: 'slack_post_message', slackpostmessage: 'slack_post_message',
368
+ slackmessage: 'slack_post_message', slackpost: 'slack_post_message', postmessage: 'slack_post_message',
369
+ vercel_deploy: 'vercel_deploy', vercel: 'vercel_deploy', deploy: 'vercel_deploy', publish: 'vercel_deploy', vercelpublish: 'vercel_deploy',
370
+ supabase_create_project: 'supabase_create_project', supabase: 'supabase_create_project', createproject: 'supabase_create_project',
371
+ createdatabase: 'supabase_create_project', makedatabase: 'supabase_create_project', create_database: 'supabase_create_project',
372
+ supabase_delete_project: 'supabase_delete_project', deletesupabaseproject: 'supabase_delete_project',
373
+ deletedatabase: 'supabase_delete_project', removedatabase: 'supabase_delete_project', delete_database: 'supabase_delete_project',
374
+ github_create_repo: 'github_create_repo', github: 'github_create_repo', creategithubrepo: 'github_create_repo',
375
+ createrepo: 'github_create_repo', pushtogithub: 'github_create_repo',
376
+ design_reference_search: 'design_reference_search', designreferencesearch: 'design_reference_search',
377
+ designreference: 'design_reference_search', referencesearch: 'design_reference_search',
378
+ designlibrary: 'design_reference_search', searchdesignlibrary: 'design_reference_search',
379
+ view_images: 'view_images', viewimages: 'view_images', viewimage: 'view_images',
380
+ lookatimages: 'view_images', lookatimage: 'view_images', seeimages: 'view_images',
381
+ supabase_api: 'supabase_api', supabaseapi: 'supabase_api',
382
+ supabase_sql: 'supabase_sql', supabasesql: 'supabase_sql', sql: 'supabase_sql', runsql: 'supabase_sql',
383
+ vercel_api: 'vercel_api', vercelapi: 'vercel_api',
384
+ web_fetch: 'web_fetch', webfetch: 'web_fetch', fetchurl: 'web_fetch', fetch_url: 'web_fetch', fetchpage: 'web_fetch', openurl: 'web_fetch', readurl: 'web_fetch',
385
+ web_search: 'web_search', websearch: 'web_search', searchweb: 'web_search', search_web: 'web_search', googlesearch: 'web_search',
386
+ apply_patch: 'apply_patch', applypatch: 'apply_patch', patch: 'apply_patch',
387
+ plan_exit: 'plan_exit', planexit: 'plan_exit', exitplan: 'plan_exit', exit_plan: 'plan_exit',
388
+ plan_enter: 'plan_enter', planenter: 'plan_enter', enterplan: 'plan_enter', enter_plan: 'plan_enter',
389
+ lsp: 'lsp', codenav: 'lsp', gotodefinition: 'lsp', findreferences: 'lsp', go_to_definition: 'lsp', find_references: 'lsp',
121
390
  };
122
391
 
123
392
  // Any tag whose name resolves to an action, with or without the codeply: prefix.
@@ -126,8 +395,17 @@ const LOOSE_NAME_TAG = new RegExp(
126
395
  );
127
396
 
128
397
  // Does this reply look like it was *trying* to act, even though nothing parsed?
398
+ // The `<\|?` (not just `<\/?`) matters: some models - Gemma via OpenRouter in
399
+ // particular - reach for their own native tool-call channel and it leaks out
400
+ // as literal Harmony-style text, "<|tool_call>call:codeply:edit_file", instead
401
+ // of the "<codeply:edit_file>" tag this protocol needs. Without the `\|`
402
+ // alternative that text doesn't match, so it falls through as a "reply is
403
+ // finished" and gets shown to the user verbatim as raw protocol debris (or
404
+ // worse, ends up as literal content inside a write_file/edit_file the model
405
+ // attempts right after). Catching it here routes it into the normal malformed
406
+ // retry instead, which corrects the model back onto the real tag format.
129
407
  const LOOKS_LIKE_ATTEMPT =
130
- /<\/?\s*(?:codeply[:_-])?(?:tool_?call|action|function_?call|invoke)\b|<(?:path|command|pattern|content|name)>/i;
408
+ /<\|?\/?\s*(?:codeply[:_-])?(?:tool_?call|action|function_?call|invoke)\b|<(?:path|command|pattern|content|name|term)>/i;
131
409
 
132
410
  /**
133
411
  * A reply that opens an action block but never closes it almost always means
@@ -137,7 +415,7 @@ const LOOKS_LIKE_ATTEMPT =
137
415
  * correction tells the model its *syntax* was wrong, which it wasn't, and
138
416
  * the model then apologizes for a mistake it didn't make and repeats the
139
417
  * same oversized write. Detecting this case lets the correction instead say
140
- * "you ran out of room, write less at once" — the thing that actually fixes it.
418
+ * "you ran out of room, write less at once" - the thing that actually fixes it.
141
419
  */
142
420
  function looksTruncated(text) {
143
421
  return /<codeply:([a-z_]+)>(?![\s\S]*<\/codeply:\1>)/i.test(text);
@@ -146,7 +424,7 @@ function looksTruncated(text) {
146
424
  /**
147
425
  * Best-effort recovery when the strict protocol did not match.
148
426
  *
149
- * gpt-oss-120b paraphrases the format — inventing a <toolcall> wrapper,
427
+ * gpt-oss-120b paraphrases the format - inventing a <toolcall> wrapper,
150
428
  * dropping the codeply: prefix, writing "readfile" for "read_file", even
151
429
  * dropping a bracket. Rejecting those wastes the user's turn, so the intent is
152
430
  * reconstructed from whatever survived. Checks run most-specific first: the
@@ -159,14 +437,25 @@ function recoverCall(text) {
159
437
  let name = null;
160
438
  if (has('content')) name = 'write_file';
161
439
  else if (has('search') && has('replace')) name = 'edit_file';
440
+ else if (has('items')) name = 'todo';
441
+ else if (has('question') && has('options')) name = 'ask_user';
442
+ else if (has('server') && has('tool')) name = 'mcp';
443
+ else if (has('to') && has('subject')) name = 'gmail_send';
444
+ else if (has('channel') && has('text')) name = 'slack_post_message';
162
445
  else {
163
446
  const m = text.match(LOOSE_NAME_TAG);
164
447
  if (m) name = NAME_ALIASES[m[1].toLowerCase().replace(/[_-]/g, '')] ?? NAME_ALIASES[m[1].toLowerCase()];
165
448
  if (!name && has('command')) name = 'run';
166
449
  else if (!name && has('pattern')) name = 'search';
450
+ // fetch_image and browser_check both take <url> now - <wait> only appears on browser_check.
451
+ else if (!name && has('url') && has('wait')) name = 'browser_check';
452
+ else if (!name && has('urls')) name = 'view_images'; // <urls> (plural) only appears on view_images
453
+ else if (!name && has('url')) name = 'fetch_image';
454
+ else if (!name && has('ref') && has('query')) name = 'supabase_sql';
167
455
  else if (!name && has('path')) name = 'read_file';
168
456
  else if (!name && has('name')) name = 'use_skill'; // only use_skill's params include <name>
169
457
  else if (!name && has('query')) name = 'list_skills'; // only list_skills' params include <query>
458
+ else if (!name && has('term')) name = 'design_reference_search';
170
459
  }
171
460
  if (!name || !PARAMS[name]) return null;
172
461
 
@@ -175,6 +464,46 @@ function recoverCall(text) {
175
464
  return { name, args, recovered: true };
176
465
  }
177
466
 
467
+ /**
468
+ * poolside/laguna's own native tool-calling convention leaks through as
469
+ * literal text here - same root cause as the Harmony `<|tool_call>` leak
470
+ * above (a different model reaching for its own trained tool-call format
471
+ * because the proxy declares no tools), just a different shape:
472
+ * <tool_call>read_file<arg_key>path</arg_key><arg_value>drop.html</arg_value></tool_call>
473
+ * The previous approach - reject it and ask for the real tags via
474
+ * FORMAT_CORRECTION - turned out not to work on this model: it kept writing
475
+ * this exact shape through all 3 retries and the whole turn errored out with
476
+ * nothing done. Since the shape is completely consistent, parsing it
477
+ * directly as a first-class input is far more reliable than hoping a
478
+ * deeply-trained habit reforms on request.
479
+ */
480
+ const POOLSIDE_CALL_RE = /<tool_call>\s*([a-zA-Z_]+)\s*([\s\S]*?)<\/tool_call>/gi;
481
+ const POOLSIDE_ARG_RE = /<arg_key>([\s\S]*?)<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/gi;
482
+
483
+ function parsePoolsideCalls(text) {
484
+ const calls = [];
485
+ let prose = '';
486
+ let lastIndex = 0;
487
+ POOLSIDE_CALL_RE.lastIndex = 0;
488
+ let m;
489
+ while ((m = POOLSIDE_CALL_RE.exec(text)) !== null) {
490
+ prose += text.slice(lastIndex, m.index);
491
+ lastIndex = m.index + m[0].length;
492
+ const rawName = m[1].toLowerCase();
493
+ const name = NAME_ALIASES[rawName] ?? (PARAMS[rawName] ? rawName : null);
494
+ if (!name) continue; // an unrecognized name in this shape isn't worth guessing at
495
+ const args = {};
496
+ POOLSIDE_ARG_RE.lastIndex = 0;
497
+ let am;
498
+ while ((am = POOLSIDE_ARG_RE.exec(m[2])) !== null) {
499
+ args[am[1].trim()] = am[2].trim();
500
+ }
501
+ calls.push({ name, args });
502
+ }
503
+ prose += text.slice(lastIndex);
504
+ return { prose, calls };
505
+ }
506
+
178
507
  /**
179
508
  * Split a model reply into prose and the actions embedded in it.
180
509
  * @returns {{prose:string, calls:Array, recovered:boolean, malformed:boolean}}
@@ -191,23 +520,35 @@ export function parseReply(text) {
191
520
  const name = m[1];
192
521
  const block = m[2];
193
522
  const spec = PARAMS[name];
194
- if (!spec) { calls.push({ name, args: {}, unknown: true }); continue; }
523
+ // `end` is where this block closes in the reply, so the loop can cut the
524
+ // stored reply right after the last block it actually runs.
525
+ const end = m.index + m[0].length;
526
+ if (!spec) { calls.push({ name, args: {}, unknown: true, end }); continue; }
195
527
  const args = extractParams(block, spec);
196
528
  // Single-param actions tolerate a bare body: <codeply:read_file>x.js</...>
197
529
  if (spec.length && Object.keys(args).length === 0 && block.trim()) {
198
530
  args[spec[0]] = block.trim();
199
531
  }
200
- calls.push({ name, args });
532
+ calls.push({ name, args, end });
201
533
  }
202
- prose += text.slice(lastIndex);
203
534
 
204
535
  if (calls.length > 0) {
205
- return { prose: prose.trim(), calls, recovered: false, malformed: false, truncated: false };
536
+ // Whatever follows the last block was written before any result came
537
+ // back: at best a restated plan, at worst an invented outcome ("done, the
538
+ // tests pass"). It is never shown and never kept.
539
+ const trailing = text.slice(lastIndex).trim();
540
+ return { prose: prose.trim(), calls, recovered: false, malformed: false, truncated: false, trailing };
541
+ }
542
+ prose += text.slice(lastIndex);
543
+
544
+ const poolside = parsePoolsideCalls(text);
545
+ if (poolside.calls.length > 0) {
546
+ return { prose: poolside.prose.trim(), calls: poolside.calls, recovered: false, malformed: false, truncated: false };
206
547
  }
207
548
 
208
549
  const truncated = looksTruncated(text);
209
550
 
210
- // Don't let the salvage path guess at a call built from a truncated block —
551
+ // Don't let the salvage path guess at a call built from a truncated block -
211
552
  // it would be missing content/search/replace and either fail oddly or write
212
553
  // a partial file. Truncation gets its own, more accurate correction instead.
213
554
  if (!truncated) {
@@ -241,12 +582,83 @@ function gitBranch(cwd) {
241
582
  } catch { return null; }
242
583
  }
243
584
 
585
+ function gitOutput(cwd, cmd) {
586
+ try {
587
+ return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim();
588
+ } catch { return ''; }
589
+ }
590
+
591
+ // Instruction files the user keeps for agents. The nearest project-level file
592
+ // wins (walking up from cwd, stopping at the git root), so a repo with both an
593
+ // AGENTS.md and a parent folder's AGENTS.md doesn't get both stacked in. The
594
+ // user-wide one in ~/.codeply is always added on top. Same lookup order idea
595
+ // as opencode's session/instruction.ts.
596
+ const INSTRUCTION_FILES = ['CODEPLY.md', 'AGENTS.md', 'CLAUDE.md'];
597
+ const MAX_INSTRUCTION_CHARS = 8000;
598
+
599
+ function readCapped(file) {
600
+ try {
601
+ const text = fs.readFileSync(file, 'utf8').trim();
602
+ if (!text) return '';
603
+ return text.length > MAX_INSTRUCTION_CHARS
604
+ ? `${text.slice(0, MAX_INSTRUCTION_CHARS)}\n[… truncated, read ${file} for the rest]`
605
+ : text;
606
+ } catch { return ''; }
607
+ }
608
+
609
+ export function findInstructionFiles(cwd) {
610
+ const found = [];
611
+ const root = gitOutput(cwd, 'git rev-parse --show-toplevel');
612
+ const stopAt = root ? path.resolve(root) : path.parse(cwd).root;
613
+ let dir = path.resolve(cwd);
614
+ outer: while (true) {
615
+ for (const name of INSTRUCTION_FILES) {
616
+ const file = path.join(dir, name);
617
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) { found.push(file); break outer; }
618
+ }
619
+ if (dir === stopAt || path.dirname(dir) === dir) break;
620
+ dir = path.dirname(dir);
621
+ }
622
+ const globalFile = path.join(os.homedir(), '.codeply', 'AGENTS.md');
623
+ if (fs.existsSync(globalFile)) found.push(globalFile);
624
+ return found;
625
+ }
626
+
627
+ function instructionsSection(cwd) {
628
+ const blocks = findInstructionFiles(cwd)
629
+ .map((file) => ({ file, text: readCapped(file) }))
630
+ .filter((b) => b.text)
631
+ .map((b) => `Instructions from: ${b.file}\n${b.text}`);
632
+ for (const b of plugins.instructionBlocks(cwd)) blocks.push(`Instructions from plugin ${b.plugin}:\n${b.text}`);
633
+ if (!blocks.length) return '';
634
+ return `PROJECT INSTRUCTIONS\nThe user wrote these for agents working here. Follow them; they override your defaults.\n\n${blocks.join('\n\n')}`;
635
+ }
636
+
244
637
  /** A compact snapshot of the project, so the agent starts oriented. */
245
638
  export function buildProjectContext(cwd) {
246
- const lines = [`Working directory: ${cwd}`, `Platform: ${process.platform}`];
639
+ const lines = [`Working directory: ${cwd}`, `Platform: ${process.platform}`, `Date: ${new Date().toISOString().slice(0, 10)}`];
640
+ // What `run` really executes under, so the model writes commands for that
641
+ // shell instead of assuming bash.
642
+ lines.push(process.platform === 'win32'
643
+ ? 'Shell for run: cmd.exe (chain with &&; no heredocs, no $(...); for PowerShell use powershell -NoProfile -Command "..."; for multi-line content use write_file)'
644
+ : `Shell for run: ${process.env.SHELL ? path.basename(process.env.SHELL) : 'sh'}`);
247
645
 
248
646
  const branch = gitBranch(cwd);
249
- if (branch) lines.push(`Git branch: ${branch}`);
647
+ if (branch) {
648
+ lines.push(`Git branch: ${branch}`);
649
+ // Uncommitted changes are usually what the user is in the middle of, and
650
+ // recent commits say what just happened - both cheap and often the fastest
651
+ // way to know where to look.
652
+ const status = gitOutput(cwd, 'git status --short').split('\n').filter(Boolean);
653
+ if (status.length) {
654
+ lines.push(`Uncommitted changes (${status.length}):`, ...status.slice(0, 25));
655
+ if (status.length > 25) lines.push(`… and ${status.length - 25} more`);
656
+ } else {
657
+ lines.push('Working tree clean.');
658
+ }
659
+ const log = gitOutput(cwd, 'git log --oneline -5');
660
+ if (log) lines.push('Recent commits:', log);
661
+ }
250
662
 
251
663
  const pkgPath = path.join(cwd, 'package.json');
252
664
  if (fs.existsSync(pkgPath)) {
@@ -274,7 +686,7 @@ export function buildProjectContext(cwd) {
274
686
 
275
687
  // NOTE ON WORDING: this reference deliberately avoids the words "tool",
276
688
  // "call" and "function". The proxy runs openai/gpt-oss-120b, whose harmony
277
- // training has a dedicated channel for function calls — phrasing this as tool
689
+ // training has a dedicated channel for function calls - phrasing this as tool
278
690
  // calling made it reach for the *native* mechanism, and Groq then rejected the
279
691
  // whole response with "Tool choice is none, but model called a tool" because
280
692
  // the proxy declares no tools. Framing it as an output format keeps the model
@@ -288,6 +700,32 @@ performs the action, and writes the result back to you as your next message.
288
700
 
289
701
  Never emit a structured/native function invocation. Write the tags as text.
290
702
 
703
+ <codeply:todo>
704
+ <items>
705
+ [x] read index.html and styles.css
706
+ [>] add the pricing section
707
+ [ ] check it on desktop and mobile
708
+ </items>
709
+ </codeply:todo>
710
+
711
+ (todo: your checklist for a task with 3+ steps. Send the WHOLE list every time,
712
+ one item per line: [ ] pending, [>] in progress (only one), [x] done, [-] dropped.
713
+ Mark an item [x] only after a tool result confirmed it, never because you meant
714
+ to do it. If something failed, mark it [-] and add a revised item.)
715
+
716
+ <codeply:ask_user>
717
+ <question>Which database should the app use?</question>
718
+ <options>
719
+ SQLite file (Recommended)
720
+ Supabase
721
+ Plain JSON file
722
+ </options>
723
+ </codeply:ask_user>
724
+
725
+ (ask_user: only for a real decision the user must make and you can't infer:
726
+ the user taps an option or types their own answer. Up to 5 short options,
727
+ recommended first. Never for things you can find out by reading the project.)
728
+
291
729
  <codeply:list_dir>
292
730
  <path>src</path>
293
731
  </codeply:list_dir>
@@ -301,8 +739,14 @@ Never emit a structured/native function invocation. Write the tags as text.
301
739
  <codeply:search>
302
740
  <pattern>createServer</pattern>
303
741
  <glob>**/*.js</glob>
742
+ <context>3</context>
304
743
  </codeply:search>
305
744
 
745
+ (search options: <context> 0-5 shows that many lines around each hit, often
746
+ enough to edit from without a separate read_file. <files_only>true</files_only>
747
+ lists just the matching files and hit counts. Leave out <pattern> and give only
748
+ <glob>, like <glob>*.test.js</glob>, to find files by name.)
749
+
306
750
  <codeply:write_file>
307
751
  <path>index.html</path>
308
752
  <content>
@@ -320,6 +764,10 @@ those lines with the change applied
320
764
  </replace>
321
765
  </codeply:edit_file>
322
766
 
767
+ (edit_file option: <all>true</all> replaces EVERY exact occurrence of the
768
+ search text, for a rename across one file. Without it the search text must
769
+ match exactly one place.)
770
+
323
771
  <codeply:run>
324
772
  <command>node --check index.js</command>
325
773
  </codeply:run>
@@ -332,33 +780,195 @@ those lines with the change applied
332
780
  <query>testing</query>
333
781
  </codeply:list_skills>
334
782
 
783
+ <codeply:fetch_image>
784
+ <url>https://loremflickr.com/1600/900/tea,leaves</url>
785
+ <path>assets/hero.jpg</path>
786
+ </codeply:fetch_image>
787
+
788
+ <codeply:browser_check>
789
+ <url>file:///absolute/path/to/index.html</url>
790
+ <viewport>mobile</viewport>
791
+ </codeply:browser_check>
792
+
793
+ <codeply:gmail_send>
794
+ <to>someone@example.com</to>
795
+ <subject>Subject line</subject>
796
+ <body>
797
+ The email body, plain text.
798
+ </body>
799
+ </codeply:gmail_send>
800
+
801
+ <codeply:gmail_search>
802
+ <query>from:someone@example.com is:unread</query>
803
+ </codeply:gmail_search>
804
+
805
+ <codeply:slack_post_message>
806
+ <channel>general</channel>
807
+ <text>
808
+ The message to post.
809
+ </text>
810
+ </codeply:slack_post_message>
811
+
812
+ <codeply:vercel_deploy>
813
+ <path>.</path>
814
+ </codeply:vercel_deploy>
815
+
816
+ <codeply:supabase_create_project>
817
+ <name>my-project</name>
818
+ </codeply:supabase_create_project>
819
+
820
+ <codeply:supabase_delete_project>
821
+ <name>my-project</name>
822
+ </codeply:supabase_delete_project>
823
+
824
+ <codeply:github_create_repo>
825
+ <path>.</path>
826
+ <name>my-project</name>
827
+ </codeply:github_create_repo>
828
+
829
+ <codeply:supabase_api>
830
+ <method>GET</method>
831
+ <path>/v1/projects</path>
832
+ </codeply:supabase_api>
833
+
834
+ <codeply:supabase_sql>
835
+ <ref>abcdefghijklmnopqrst</ref>
836
+ <query>
837
+ create table if not exists todos (id bigint generated always as identity primary key, title text not null, done boolean default false);
838
+ </query>
839
+ </codeply:supabase_sql>
840
+
841
+ <codeply:vercel_api>
842
+ <method>GET</method>
843
+ <path>/v9/projects</path>
844
+ </codeply:vercel_api>
845
+
846
+ <codeply:design_reference_search>
847
+ <term>to-do list</term>
848
+ <category>productivity</category>
849
+ </codeply:design_reference_search>
850
+
851
+ <codeply:view_images>
852
+ <urls>https://example.com/screenshot1.jpg,https://example.com/screenshot2.jpg</urls>
853
+ </codeply:view_images>
854
+
855
+ <codeply:web_search>
856
+ <query>vite 6 breaking changes</query>
857
+ </codeply:web_search>
858
+
859
+ <codeply:web_fetch>
860
+ <url>https://vite.dev/guide/migration</url>
861
+ </codeply:web_fetch>
862
+
863
+ (web_search finds pages, web_fetch reads one as text. Use them for current docs,
864
+ error messages you do not recognise, or anything after your training. Search
865
+ first, then fetch the best result. Never invent a URL you did not get from a
866
+ search or the user.)
867
+
868
+ <codeply:lsp>
869
+ <operation>references</operation>
870
+ <path>src/api.ts</path>
871
+ <symbol>fetchUser</symbol>
872
+ </codeply:lsp>
873
+
874
+ (lsp: code navigation for TypeScript/JavaScript projects. operation is one of
875
+ definition, references, implementation, hover (type and docs), documentSymbol
876
+ (outline of a file), workspaceSymbol (give <query>). Point at a spot with <line>
877
+ (1-based) plus <symbol> (the name on that line) or <character>. Prefer it over
878
+ search when you need the real definition or every real use of a name, for
879
+ example before renaming or changing a function's signature.)
880
+
881
+ <codeply:apply_patch>
882
+ <patch>
883
+ *** Begin Patch
884
+ *** Update File: src/app.js
885
+ @@ function start()
886
+ const port = 3000;
887
+ -app.listen(port);
888
+ +app.listen(port, () => console.log('up'));
889
+ *** Add File: src/config.js
890
+ +export const port = 3000;
891
+ *** Delete File: old.js
892
+ *** End Patch
893
+ </patch>
894
+ </codeply:apply_patch>
895
+
896
+ (apply_patch: several file changes in one call, approved once. Every change line
897
+ starts with " " (context), "-" (remove) or "+" (add). "@@ text" anchors a hunk
898
+ to a line. Use it when one change touches 2+ files or many spots; use edit_file
899
+ for one spot. Copy context and removed lines exactly from a read.)
900
+
901
+ <codeply:plan_exit>
902
+ <path>.codeply/plans/add-dark-mode.md</path>
903
+ </codeply:plan_exit>
904
+
905
+ (plan_exit: Plan mode only. After writing the plan file, call this to ask the user
906
+ whether to switch to Build mode and carry it out.)
907
+
335
908
  RULES
336
- - Write at most ONE action block per reply, then stop and wait for its result.
909
+ - Write ONE action block per reply, then stop and wait for its result. The one
910
+ exception is looking around: up to 4 read_file / list_dir / search blocks may
911
+ go in the same reply, and they all run before you get the results back
912
+ together. Anything that changes something (write_file, edit_file, run, ...)
913
+ is always alone in its reply.
914
+ - Stop writing the moment your action block closes. You have not seen its
915
+ result yet, so anything you write after it is a guess, and it is discarded
916
+ unread. Never write "[tool result" yourself; results only come from the program.
337
917
  - Do not waste steps. Every action block costs the user several seconds, so never
338
918
  perform one whose answer you already have:
339
- · If the user gave a full path, read it directly — do not list_dir first.
919
+ · If the user gave a full path, read it directly - do not list_dir first.
340
920
  · After a successful edit_file or write_file, do NOT read the file back. The
341
921
  result already confirms it applied. Only re-read a part you have not seen.
342
922
  · Do not re-read a file that is already earlier in this conversation.
343
923
  - When the result comes back, keep going on your own. Do not ask the user what to do next while you still have obvious work left.
344
- - read_file before you edit_file. "search" must be copied verbatim from what you just read — never from memory, never with the line-number prefixes the reader adds.
924
+ - If an action fails, do not apologize or re-explain what you were trying to do - that wastes a whole reply and fixes nothing. Look at the actual error, and on your very next reply either fix the real cause or try something genuinely different. If the same action has now failed more than once for the same reason, that reason is not going to change on a third identical attempt - stop and re-read the file, or the error, or rethink the approach instead of repeating it.
925
+ - WORKAROUNDS: when something fails because of the tool or the environment rather than your code (the shell chokes on a heredoc or quotes, a command is missing, a command line is too long, a port is taken, a path is wrong), do not repeat the same approach with small tweaks. Switch to a different way to reach the same goal: write the file with write_file instead of echo/heredoc, put a long or multi-line command in a script file and run that, use another command that does the same job, or install what is missing. Say in one short sentence what you are switching to and why, then do it.
926
+ - read_file before you edit_file. "search" must be copied verbatim from what you just read - never from memory, never with the line-number prefixes the reader adds.
927
+ - LONG FILES: do not read a big file top to bottom to find one spot. search for a
928
+ distinctive name first (with <context>), then read_file just that region with
929
+ <offset>/<limit> (offset is the 0-based line to start at), then edit_file it.
930
+ If an edit_file fails, its error shows the closest matching lines with numbers:
931
+ copy from those instead of re-reading the whole file.
932
+ - Before an action block write at most one short sentence (or nothing). Don't narrate plans, apologize, or restate what you just did; the user sees every action anyway. Save the explanation for your final answer.
933
+ - Keep "search" as narrow as the change actually is. When removing something, search should span exactly the thing being removed - not "from here to the end of the file" just because that was easy to copy. A wider span deletes whatever sits between your intended target and wherever you stopped, silently, even when it renders fine and reports no errors. If two things need removing and something unrelated sits between them, that is two edit_file calls, not one wide one.
345
934
  - edit_file for changes to an existing file. write_file only for new files or a genuine full rewrite.
346
935
  - Prefer run for anything you can check mechanically.
347
- - If a SKILLS entry below is a clear match for the task, use_skill it before starting — its instructions take priority over your own default approach for that kind of work. Do not use_skill speculatively; only when a listed skill actually matches what you are about to do.
348
- - The SKILLS list below is a subset. If the task is a specific kind of specialized work (a particular framework, a particular deliverable type) that doesn't clearly match anything listed, try list_skills with a one- or two-word query before assuming there's no skill for it — there are 281 in total, not just the ones shown.
349
- - When the task is finished, reply with prose only and NO action block. That ends your turn.`;
936
+ - run executes a real shell command on the user's own machine, in their own project directory, with their own git/gh credentials already configured - the exact same terminal they'd get typing it themselves. That includes git add, git commit, git push, gh pr create, npm install, or anything else. Never tell the user you don't have terminal or network access, or that you can't run a command - if it's a shell command, write a <codeply:run> action block and run it for real. Do not just describe what the command would do.
937
+ - If a task needs a CLI that turns out not to be installed, install it yourself with the platform's own package manager (winget on Windows, brew on macOS, apt/apt-get on Linux) via run before falling back to a manual workaround - do not immediately hand the user a "go do this in a browser" set of steps just because a binary is missing; installing it is itself a shell command. The one thing you genuinely cannot do unattended is an interactive auth step a CLI requires after installing (e.g. gh auth login opening a browser for a device code) - if the install succeeds but the tool then reports it isn't authenticated, that specific login step is the only part to ask the user for, not the whole task.
938
+ - Deploying to Vercel, creating a Supabase project, or creating a new GitHub repo and pushing to it are NOT CLI tasks here - do not install or shell out to the vercel CLI, the supabase CLI, or gh repo create for these. Use vercel_deploy, supabase_create_project, and github_create_repo instead: they call the connected account directly (once the user has connected it from Connect Apps), with no separate CLI login step and no "go create an empty repo on github.com first." If one reports its integration isn't connected, say so plainly and point the user to Connect Apps (account menu) - do not fall back to the CLI/manual route as a workaround, and do not install the vercel/supabase CLI to route around a missing connection.
939
+ - FULL SUPABASE ACCESS: when Supabase is connected (see CONNECTED SERVICES), supabase_api reaches the entire Supabase Management API (https://api.supabase.com, paths start with /v1/) - list projects (GET /v1/projects), read API keys (GET /v1/projects/{ref}/api-keys?reveal=true), manage auth config, storage buckets, edge functions, secrets, branches, and more. supabase_sql runs SQL directly against a project's Postgres database - create and alter tables, write RLS policies, seed data, inspect schemas (select from information_schema). Always look up the real project ref first (GET /v1/projects) instead of guessing it. Read requests run immediately; changes ask the user first. Enable RLS and add policies on any table that holds user data.
940
+ - FULL VERCEL ACCESS: when Vercel is connected, vercel_api reaches the entire Vercel REST API (https://api.vercel.com, paths start with a version like /v9/) - projects (GET /v9/projects), deployments and their status/logs (GET /v6/deployments, GET /v13/deployments/{id}, GET /v3/deployments/{id}/events), env vars (/v10/projects/{id}/env), domains (/v10/projects/{id}/domains). The team id is added for you. Use vercel_deploy to ship a folder, then vercel_api to confirm the deployment reached READY and to read its build logs if it failed - do not report a deployment as live until you've seen it succeed.
941
+ - Tool results are the only source of truth about what happened. A failed result (non-2xx status, non-zero exit code, "did not apply") means it did not happen - never describe it as done.
942
+ - Creating a remote repo, pushing code, enabling Pages, or deploying anything are exactly the kind of claim covered by "never report success you did not verify" above - and the easiest one to get wrong, because each step's own command can silently no-op or partially fail while a LATER step still appears to succeed. Concretely: run whoami equivalents (gh api user, gh auth status) to get the real signed-in username BEFORE building any URL with it - never guess a username from the OS account name, the folder name, or anything the user said earlier that could be stale; after gh repo create or git push, treat the command's own exit code and printed output as the only source of truth for whether it worked, not your prior turn's summary of what you intended to do - a command you ran two turns ago having succeeded is not evidence this turn's retry did too; and never hand the user a repository/deployment URL you have not just confirmed resolves (curl -I it, or read it back from the command's own output) - a plausible-looking URL built from a guessed username/slug is a fabrication even if the pattern is usually right.
943
+ - gmail_send and slack_post_message send a real email or a real Slack message the moment they run - there is no draft state, no "preview" mode. Only use them when the user actually asked for that email/message to go out, never speculatively, never as a way to "show" them what it would say. If gmail_search or a prior message makes clear Gmail/Slack isn't connected, say so plainly and stop - do not retry hoping it connects itself, and do not claim you sent something when the tool reported it wasn't connected.
944
+ - vercel_deploy, supabase_create_project, supabase_delete_project, and github_create_repo are the same category as gmail_send/slack_post_message above: real, immediate action the moment they run - a live production deployment, a newly provisioned cloud database with its own bill, a brand-new repository pushed with the user's code. Only use them when the user actually asked for that outcome, never speculatively "to check if it would work." If one reports its integration isn't connected, say so plainly and stop rather than retrying or working around it. supabase_delete_project is the sharpest of these - it permanently destroys a database with no undo - so only reach for it when the user has clearly asked to delete or remove a specific project, never as cleanup for something that merely looks unused.
945
+ - Every image in generated markup must be a local file, downloaded with fetch_image. NEVER write an <img> or CSS background-image pointing straight at loremflickr.com, picsum.photos, or any other live generator URL - those are redirect services that return a DIFFERENT random photo on every single request, so the page shows a different (sometimes completely unrelated) image on every reload, every redeploy, every visitor. Always fetch_image the URL to a real path under assets/ first, then reference that local path in the markup. If the user hasn't given you specific photos and the site needs placeholder imagery, fetch_image from https://loremflickr.com/<width>/<height>/<keyword1>,<keyword2> - it pulls a real tagged photo matching those keywords, no API key needed. Pick keywords that actually describe THAT section's subject (a tea shop's hero: 'tea,leaves' or 'matcha,ceremony', not generic filler) - never use a source that returns fully random, unrelated stock photos (e.g. picsum.photos) on a themed site; a beach or a crowd photo under a tea brand's "Our Heritage" section is worse than no image. If a downloaded placeholder turns out to be a broken/static-noise "no match" image or is visibly unrelated to its section once you look at the page, delete it and fetch_image again with more specific keywords - do not leave a wrong or corrupted image in place.
946
+ - If a SKILLS entry below is a clear match for the task, use_skill it before starting - its instructions take priority over your own default approach for that kind of work. Do not use_skill speculatively; only when a listed skill actually matches what you are about to do. A name under LIKELY RELEVANT TO THIS REQUEST, if that section is present, was matched against your actual request from the full library - treat it exactly the same way: use_skill it before starting unless it's obviously a false match, do not silently ignore it in favor of guessing your own approach.
947
+ - EXCEPTION - this one is not speculative: if the task is to build or restyle any page a human will look at in a browser (a landing page, a small-business site, a portfolio, a dashboard, any HTML/CSS/UI), use_skill 'premium-web-design' before writing markup, even if the brief sounds tiny or mundane ("a site for a tea shop"). A plain-sounding brief is not permission for a flat, default-Bootstrap-looking result - Codeply's bar is that every generated page reads as deliberately designed. Skip this only if the user explicitly asked for something minimal/utilitarian/no-frills.
948
+ - EXCEPTION - this one is MANDATORY, not speculative, and comes before you write any markup for that same kind of task (a page or app screen a human will look at): call design_reference_search on the core screen(s) the app needs (an onboarding flow, a checkout, a settings screen, a to-do list's main view - whatever the brief actually calls for) before designing it from memory. It is a local library (no external account, no login, no network dependency) so it is always available - do not skip this step, and do not substitute your own guess at what that kind of screen "usually" looks like. Real shipped apps solve layout/hierarchy/empty-state problems in ways worth matching, not just imitating the vibe of. Only if it reports the library itself is missing on this machine should you say that plainly to the user once and continue from your own judgment.
949
+ - CALLING design_reference_search IS NOT THE SAME AS USING IT. It returns a list of screenshot URLs - that is raw material, not a design brief, and the search result text alone tells you nothing about what those screens actually look like. fetch_image does NOT show you the image either - it only downloads it to disk, silently, no vision, so calling it on a reference screenshot accomplishes nothing here. Before you write markup, call view_images with 2-4 of the returned screenshot URLs (from different apps, not all from result #1) - that is the only tool that actually attaches the image for you to look at: the real layout structure, spacing, type scale, color choices, empty states, and component patterns those apps ship with. Build FROM specific things you saw in those images, not from a generic idea of what that kind of app "usually" looks like. If you searched but did not view_images any of the results, you did not reference real apps this turn - say that plainly in your summary ("I designed this from my own judgment, not a specific reference") rather than claiming you drew from real app patterns. This applies retroactively too: if the user later asks what reference you used, answer from what you actually did in this conversation (did you view_images any screenshots, and which apps), never from a guess or a vague assumption that you probably did - being wrong about your own prior actions is worse than admitting you skipped the step.
950
+ - design_reference_search searches a private library built ahead of time (914 real apps, 6,433 screenshots from official App Store listings, covering productivity/finance/shopping/social/travel/food_delivery/health_fitness/education/entertainment/real_estate). A category filter narrows results; term alone searches across all categories. Same fetch_image requirement applies - see the rule above.
951
+ - EXCEPTION - also not speculative, and it is the LAST thing you do before replying, not something you might get to: if you wrote or edited any HTML/CSS/JS/frontend file this turn, browser_check the actual page that changed (not just the file you touched - file:///<absolute path> for a static file, or http://localhost:<port> if the project needs a server, start one with run first if nothing is serving yet) BEFORE telling the user it's done. A page you have not opened is a page you do not know works. When it's available, the result includes an actual screenshot of the page as it just rendered, not only a text extraction - look at that image before judging the page correct; a layout that's visually broken, a section that's misaligned, or an image that rendered as a broken-icon placeholder won't always show up as a console error or missing text, so text-only reasoning is not enough. Read the report like a bug filed against you: an error names a file and often a line, and the screenshot shows you what a user would actually see - go fix whichever is wrong, then browser_check the same page again, and repeat until both the report and the screenshot come back clean. Do not call two checks "the same" or "different" from memory or assumption - judge each one from what that check actually returned. A fix in a shared file (a stylesheet, a component several pages import) can affect pages you did not start from - browser_check those too before you finish. Skip this only if browser_check reports itself unavailable (say so once, then continue from the source) or the task has no page to render (a CLI script, a backend-only route).
952
+ - Writing style: never use em dashes (the long dash character) in your replies. Use a comma, a period, or a plain hyphen instead.
953
+ - browser_check takes an optional <viewport>: desktop (default), tablet (768px), mobile (390px), or WIDTHxHEIGHT. After building or restyling a page, check it at desktop AND mobile. A NOT RESPONSIVE line means it scrolls sideways on phones: fix it (flexible widths, wrapping, media queries, a viewport meta tag) and check mobile again.
954
+ - If the user asks for a screenshot, or to see / show them a page, browser_check that page: the screenshot it takes is sent to the user in this chat automatically (on their PC and their phone). Say in one line that the screenshot is above; never claim a screenshot you didn't take.
955
+ - You are one agent and you do all of the work yourself, one step at a time. There is no one to delegate to and no background worker - never say you've handed something off.
956
+ - The SKILLS list below is a subset. If the task is a specific kind of specialized work (a particular framework, a particular deliverable type) that doesn't clearly match anything listed, try list_skills with a one- or two-word query before assuming there's no skill for it - there are 281 in total, not just the ones shown.
957
+ - STACK CHOICE - when the user asks you to build a new app/tool/site from scratch and does not say what to build it with (no "in React", "as an Electron app", "using Vite", etc.), do not silently default to a multi-file React project or an Electron shell - those add a build step, a node_modules install, and a packaging story the user did not ask for. Instead, ask one short question (with ask_user when it is available, otherwise as prose with no action block): whether they want a single self-contained HTML file (open it straight in a browser, nothing to install) or a specific framework/Electron/React setup instead, and briefly say why a single file is the lighter default. Wait for their answer before writing any code. Skip this ask only when the user already named a stack, is clearly extending/matching an existing project's stack (react/electron files already in the folder), or explicitly said to just decide for them.
958
+ - When the task is finished, reply with prose only and NO action block. That ends your turn. End with a short, plain-English close: what you actually changed (only files you really wrote or edited), how you verified it (only checks you really ran, and their result), and anything left undone or unverified. Never list a file, a check, or a result that doesn't appear in a tool result from this conversation.`;
350
959
 
351
960
  const VERIFY_RULES = `VERIFYING YOUR WORK
352
961
  You are not done when the code is written. You are done when it is checked.
353
962
  - After editing JS/TS, run a syntax check (e.g. node --check <file>).
354
963
  - If the project has a test/lint/build script, run the relevant one.
964
+ - After editing any HTML/CSS/frontend file, browser_check the page it changed. This is not optional when the capability is available - console errors, broken images, and a blank render are exactly as real a failure as a failing test, and reading source code does not catch them; only opening the page does. When the check returns a screenshot, actually look at it - that is the real verification, not the text report around it. Never guess or assume what the page looks like from having generated the markup; judge it from what you were just shown.
355
965
  - If a command fails, read the error, fix the cause, and run it again. Repeat until it passes or you have a specific reason it cannot.
356
966
  - Never report success for something you did not verify. If you could not verify it, say exactly that.`;
357
967
 
358
968
  const MODE_PROMPT = {
359
969
  Build: `You are Codeply, an autonomous coding agent working in the user's terminal.
360
970
 
361
- Do the work the user asked for, end to end. Explore the project before assuming anything about it — read the actual files, do not guess at their contents or invent APIs. Make the change, then verify it.
971
+ Do the work the user asked for, end to end. Explore the project before assuming anything about it - read the actual files, do not guess at their contents or invent APIs. Make the change, then verify it.
362
972
 
363
973
  ${VERIFY_RULES}
364
974
 
@@ -373,9 +983,13 @@ Keep prose short. A sentence about what you are about to do, then the action blo
373
983
 
374
984
  Plan: `You are Codeply, working in Plan mode in the user's terminal.
375
985
 
376
- Investigate and produce a concrete plan. You MAY read, list, search, and run read-only commands to ground the plan in what is actually there. You MUST NOT write or edit files in this mode — if the user wants the change applied, tell them to switch to Build mode with tab.
986
+ Investigate and produce a concrete plan. You MAY read, list, search, fetch web pages, and run read-only commands to ground the plan in what is actually there. You MUST NOT change the project: the only file you may write is your plan, under .codeply/plans/.
377
987
 
378
- Deliver: what you found, the specific files and functions involved, then numbered steps. Be concrete about file paths and names. Flag anything genuinely ambiguous instead of picking silently.`,
988
+ Workflow:
989
+ 1. Explore first. If a real decision is the user's to make, ask_user before planning around a guess.
990
+ 2. Write the plan to .codeply/plans/<short-slug>.md with write_file (a new file each time, a short kebab-case slug). Start with a one-line goal, then what you found (the specific files and functions involved), then numbered steps with concrete paths and names, then how it will be verified. Flag anything genuinely ambiguous instead of picking silently.
991
+ 3. Call plan_exit with that path. The user is asked whether to switch to Build mode. If they say yes you become the builder and carry the plan out; if they say no, keep refining the plan with them.
992
+ 4. Your final message summarises the plan in a few lines and names the plan file.`,
379
993
 
380
994
  Ask: `You are Codeply, working in Ask mode in the user's terminal.
381
995
 
@@ -386,30 +1000,125 @@ Be concise and concrete. Quote the relevant code and cite it as path:line. If th
386
1000
 
387
1001
  const READ_ONLY_MODES = new Set(['Plan', 'Ask']);
388
1002
 
1003
+ /** Files an apply_patch text adds, updates or moves to (for gates that run before the tool does). */
1004
+ function patchTargets(text) {
1005
+ return [...String(text || '').matchAll(/^\*\*\* (?:Add File|Update File|Move to):\s*(.+?)\s*$/gm)].map((m) => m[1]);
1006
+ }
1007
+
389
1008
  /**
390
1009
  * Skills add capability without adding weight proportional to the whole
391
- * library: only a curated subset's name + capped description goes in here —
1010
+ * library: only a curated subset's name + capped description goes in here -
392
1011
  * see lib/skills.js for why the bundled set (281 skills, ~3.4MB) can't be
393
1012
  * injected in full on every request without the skill library itself becoming
394
1013
  * the majority of the token cost. The rest is one list_skills query away.
395
1014
  */
396
- function buildSkillIndex() {
397
- const all = skills.listSkills();
1015
+ function buildSkillIndex(userMessage, cwd) {
1016
+ const all = skills.listSkills(cwd);
398
1017
  const daily = skills.formatSkillIndex(all);
399
1018
  if (!daily) return null;
400
1019
  const rest = all.length - all.filter((s) => s.daily).length;
401
- return `SKILLS\nOptional playbooks for specific kinds of work. Load one with use_skill ` +
402
- `when its description clearly matches the current task. This is a subset — ` +
1020
+ let out = `SKILLS\nOptional playbooks for specific kinds of work. Load one with use_skill ` +
1021
+ `when its description clearly matches the current task. This is a subset - ` +
403
1022
  `${rest} more exist; search them with list_skills if nothing here fits:\n${daily}`;
1023
+
1024
+ // Computed fresh from the actual request, not a fixed list - this is what
1025
+ // lets one of the other ~244 skills get reached without the model having to
1026
+ // guess a good list_skills query first. Excludes daily skills: they're
1027
+ // already shown above, no need to say the same name twice.
1028
+ const relevant = skills.findRelevantSkills(userMessage, all, { limit: 6, excludeDaily: true });
1029
+ if (relevant.length) {
1030
+ const lines = relevant.map((s) => `- ${s.name}: ${s.description.length > 140 ? s.description.slice(0, 139).trim() + '…' : s.description}`).join('\n');
1031
+ out += `\n\nLIKELY RELEVANT TO THIS REQUEST (matched from the full ${all.length}-skill library):\n${lines}\n` +
1032
+ `Treat a name here exactly like a SKILLS match above: use_skill it before starting unless it's clearly a false match for what you're about to do.`;
1033
+ }
1034
+
1035
+ return out;
1036
+ }
1037
+
1038
+ /**
1039
+ * Which external accounts are connected right now, so the agent knows what it
1040
+ * can actually reach instead of guessing (or claiming access it doesn't have).
1041
+ */
1042
+ function connectedServicesSection() {
1043
+ const lines = [];
1044
+ const describe = (name, label, detail) => {
1045
+ let connected = false;
1046
+ try { connected = config.isIntegrationConnected(name); } catch {}
1047
+ lines.push(`- ${label}: ${connected ? `connected${detail ? ` (${detail})` : ''}` : 'not connected'}`);
1048
+ };
1049
+ let vercelUser = '', githubUser = '', gmailUser = '';
1050
+ try { vercelUser = config.getIntegration('vercel').userName; } catch {}
1051
+ try { githubUser = config.getIntegration('github').userName; } catch {}
1052
+ try { gmailUser = config.getIntegration('gmail').email; } catch {}
1053
+ describe('supabase', 'Supabase', 'full Management API via supabase_api, SQL via supabase_sql');
1054
+ describe('vercel', 'Vercel', [vercelUser, 'full REST API via vercel_api'].filter(Boolean).join(' - '));
1055
+ describe('github', 'GitHub', githubUser);
1056
+ describe('gmail', 'Gmail', gmailUser);
1057
+ describe('slack', 'Slack', '');
1058
+ return `CONNECTED SERVICES\n${lines.join('\n')}\nA service marked "not connected" cannot be used - tell the user to connect it from Connect Apps rather than attempting it.`;
404
1059
  }
405
1060
 
406
- export function buildSystemPrompt(mode, cwd) {
407
- const parts = [MODE_PROMPT[mode] || MODE_PROMPT.Build, '', TOOL_REFERENCE];
408
- if (READ_ONLY_MODES.has(mode)) {
1061
+ const ROLE_INTRO = (role) =>
1062
+ `CURRENT ROLE: ${role.tagline}\nYou are still Codeply - the single agent doing all of this work yourself. For this task you are working in the ${role.tagline} role; the guide below is the standard to hold your work to. It is a way of working, not a separate agent: never mention handing off or delegating.`;
1063
+
1064
+ /**
1065
+ * @param {string} mode Build | Plan | Ask
1066
+ * @param {string} cwd
1067
+ * @param {string} userMessage used to pick relevant skills
1068
+ * @param {object} [opts]
1069
+ * @param {string} [opts.roleId] a role from lib/subagents.js (frontend, backend, ...) to take on this turn
1070
+ * @param {string} [opts.goal] the overall objective when running under /goal
1071
+ */
1072
+ const MAX_MCP_TOOLS_LISTED = 80;
1073
+
1074
+ /** Which MCP servers are connected and what their tools do. */
1075
+ function mcpServersSection(list, native) {
1076
+ if (!Array.isArray(list) || !list.length) return '';
1077
+ const lines = [];
1078
+ let shown = 0;
1079
+ for (const s of list) {
1080
+ if (s.error) { lines.push(`- ${s.name}: NOT CONNECTED (${s.error.slice(0, 160)})`); continue; }
1081
+ lines.push(`- ${s.name}${s.instructions ? ` - ${s.instructions.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`);
1082
+ for (const t of s.tools) {
1083
+ if (shown++ >= MAX_MCP_TOOLS_LISTED) break;
1084
+ const params = Object.keys(t.inputSchema?.properties || {});
1085
+ lines.push(` ${t.name}(${params.join(', ')}): ${String(t.description || '').replace(/\s+/g, ' ').slice(0, 150)}`);
1086
+ }
1087
+ }
1088
+ const how = native
1089
+ ? 'Each tool above is also available to you directly as mcp__<server>__<tool>.'
1090
+ : 'Call one with <codeply:mcp> <server>name</server> <tool>tool</tool> <args>{"param": "value"}</args> </codeply:mcp> (args is a JSON object).';
1091
+ return `MCP SERVERS\nTools from the user's connected MCP servers. ${how}\n${lines.join('\n')}`;
1092
+ }
1093
+
1094
+ export function buildSystemPrompt(mode, cwd, userMessage, opts = {}) {
1095
+ const parts = [MODE_PROMPT[mode] || MODE_PROMPT.Build];
1096
+ // The role guide goes right after the base framing and before the tool
1097
+ // reference, so it colors how every tool gets used.
1098
+ const role = opts.roleId ? rolesLib.getSubagent(opts.roleId) : null;
1099
+ if (role) parts.push('', ROLE_INTRO(role), role.persona);
1100
+ if (opts.goal) {
1101
+ parts.push('', `GOAL MODE\nYou are working autonomously toward this goal until it is fully achieved:\n"${opts.goal}"\n` +
1102
+ 'Keep going without asking the user questions - make sensible, conventional decisions yourself and note them in your summary. ' +
1103
+ 'Only stop to ask if you are truly blocked (missing credentials, an irreversible choice only the user can make).');
1104
+ }
1105
+ // Native mode swaps the tag examples for a short note; the tools themselves
1106
+ // arrive as function definitions. The RULES apply either way.
1107
+ parts.push('', opts.native ? `${NATIVE_FORMAT}\n\n${TOOL_REFERENCE.slice(TOOL_REFERENCE.indexOf('RULES\n'))}` : TOOL_REFERENCE);
1108
+ parts.push('', connectedServicesSection());
1109
+ if (mode === 'Plan') {
1110
+ parts.push('', 'MODE RESTRICTION: this is Plan mode. write_file and edit_file only work on plan files under .codeply/plans/ (*.md); everything else is disabled and returns an error. run is limited to read-only commands. Call plan_exit when the plan file is written.');
1111
+ } else if (READ_ONLY_MODES.has(mode)) {
409
1112
  parts.push('', 'MODE RESTRICTION: write_file and edit_file are disabled. Using them returns an error. run is limited to read-only commands.');
1113
+ } else if (mode === 'Build') {
1114
+ parts.push('', 'PLANS: if the user points you at a plan file under .codeply/plans/, read it and carry it out step by step, keeping its checklist in todo. plan_enter switches to Plan mode; use it only when the user asks to plan first, or the request is large and ambiguous enough that building without a plan would be a gamble. It asks the user before switching.');
410
1115
  }
411
- const skillIndex = buildSkillIndex();
1116
+ const skillIndex = buildSkillIndex(userMessage, cwd);
412
1117
  if (skillIndex) parts.push('', skillIndex);
1118
+ const instructions = instructionsSection(cwd);
1119
+ if (instructions) parts.push('', instructions);
1120
+ const mcpSection = mcpServersSection(opts.mcp, opts.native);
1121
+ if (mcpSection) parts.push('', mcpSection);
413
1122
  parts.push('', 'PROJECT CONTEXT', buildProjectContext(cwd));
414
1123
  return parts.join('\n');
415
1124
  }
@@ -420,23 +1129,176 @@ export function buildSystemPrompt(mode, cwd) {
420
1129
  * Keep the transcript under budget by dropping the oldest tool exchanges.
421
1130
  * The system prompt and the most recent turns always survive.
422
1131
  */
423
- function trimTranscript(messages) {
424
- let total = messages.reduce((n, m) => n + m.content.length, 0);
425
- if (total <= CONTEXT_CHAR_BUDGET) return messages;
1132
+ // A message's content is a plain string for every ordinary turn, but an
1133
+ // OpenAI-shaped array (text + image_url parts) whenever the user pasted an
1134
+ // image - .length and .startsWith exist on arrays too, just meaning
1135
+ // something completely different (part count, not a function), so treating
1136
+ // content as "always a string" here would either miscount the budget or
1137
+ // throw outright the first time an image-bearing message reached this
1138
+ // function. contentLength() and isToolResult() are what keep this safe.
1139
+ function contentLength(content) {
1140
+ return typeof content === 'string' ? content.length : JSON.stringify(content).length;
1141
+ }
1142
+ // Text of a tool-result message, whether it's a plain string or the
1143
+ // image-bearing [{type:'text',...}, {type:'image_url',...}] array a
1144
+ // browser_check-with-screenshot result now produces (see the tool_result
1145
+ // push above). Screenshots are large - a single one in base64 can outweigh
1146
+ // the whole char budget on its own - so they must be just as trimmable as
1147
+ // any other aging tool result, not permanently exempt because the shape
1148
+ // changed from a plain string.
1149
+ function toolResultText(content) {
1150
+ if (typeof content === 'string') return content;
1151
+ if (Array.isArray(content)) return content.find((p) => p.type === 'text')?.text || '';
1152
+ return '';
1153
+ }
1154
+ function isToolResult(content) {
1155
+ return toolResultText(content).startsWith('[tool result');
1156
+ }
1157
+
1158
+ function trimTranscript(messages, budget = CONTEXT_CHAR_BUDGET) {
1159
+ let total = messages.reduce((n, m) => n + contentLength(m.content), 0);
1160
+ if (total <= budget) return messages;
426
1161
 
427
1162
  const kept = [...messages];
428
1163
  // index 0 is the system prompt; never touch the last 6 messages.
429
- for (let i = 1; i < kept.length - 6 && total > CONTEXT_CHAR_BUDGET; i++) {
430
- if (kept[i].role === 'user' && kept[i].content.startsWith('[tool result')) {
431
- total -= kept[i].content.length;
432
- const head = kept[i].content.slice(0, 200);
1164
+ for (let i = 1; i < kept.length - 6 && total > budget; i++) {
1165
+ if (kept[i].role === 'user' && isToolResult(kept[i].content)) {
1166
+ total -= contentLength(kept[i].content);
1167
+ const head = toolResultText(kept[i].content).slice(0, 200);
433
1168
  kept[i] = { role: 'user', content: `${head}\n[… older tool output dropped to save context]` };
434
- total += kept[i].content.length;
1169
+ total += contentLength(kept[i].content);
435
1170
  }
436
1171
  }
437
1172
  return kept;
438
1173
  }
439
1174
 
1175
+ function transcriptLength(messages) {
1176
+ return messages.reduce((n, m) => n + contentLength(m.content), 0);
1177
+ }
1178
+
1179
+ // ─── Compaction ─────────────────────────────────────────────────────────────
1180
+ //
1181
+ // Dropping old tool output (trimTranscript) is enough most of the time. When
1182
+ // the transcript is STILL over budget after that, the middle of the turn is
1183
+ // replaced by a structured summary written by the model itself, so the goal,
1184
+ // decisions and file paths survive instead of just falling off the end.
1185
+ // The summary template is adapted from opencode (packages/core/src/session/
1186
+ // compaction.ts), MIT License, Copyright (c) 2025 opencode.
1187
+
1188
+ const KEEP_RECENT_MESSAGES = 6;
1189
+ const SUMMARY_SOURCE_CHARS = 2000; // per message fed to the summarizer
1190
+
1191
+ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
1192
+ <template>
1193
+ ## Objective
1194
+ - [one or two brief sentences describing what the user is trying to accomplish]
1195
+
1196
+ ## Important Details
1197
+ - [constraints/preferences, decisions and why, important facts, exact context needed to continue, or "(none)"]
1198
+
1199
+ ## Work State
1200
+ ### Completed
1201
+ - [finished work, verified facts, or changes made, ONLY if a tool result below confirms it; otherwise "(none)"]
1202
+
1203
+ ### Active
1204
+ - [current work, partial changes, or investigation state; otherwise "(none)"]
1205
+
1206
+ ### Blocked
1207
+ - [blockers, failing commands, or unknowns; otherwise "(none)"]
1208
+
1209
+ ## Next Move
1210
+ 1. [immediate concrete action, or "(none)"]
1211
+
1212
+ ## Relevant Files
1213
+ - [file or directory path: why it matters, or "(none)"]
1214
+ </template>
1215
+
1216
+ Rules:
1217
+ - Keep every section, even when empty. Terse bullets, not paragraphs.
1218
+ - Preserve exact file paths, symbols, commands, error strings and identifiers.
1219
+ - A change counts as Completed only if a [tool result] in the conversation shows it succeeded. Something the assistant only said it would do, or said it did without a matching result, goes under Active.
1220
+ - Do not mention the summary process.`;
1221
+
1222
+ function summarySource(messages) {
1223
+ return messages.map((m) => {
1224
+ const text = typeof m.content === 'string' ? m.content : toolResultText(m.content);
1225
+ const cut = text.length > SUMMARY_SOURCE_CHARS ? `${text.slice(0, SUMMARY_SOURCE_CHARS)} [...]` : text;
1226
+ return `${m.role === 'assistant' ? 'ASSISTANT' : 'USER'}: ${cut}`;
1227
+ }).join('\n\n');
1228
+ }
1229
+
1230
+ /**
1231
+ * Replace messages[1 .. length-KEEP_RECENT_MESSAGES) with one summary message,
1232
+ * in place. The system prompt, the user's request and the latest steps stay.
1233
+ * @returns {Promise<boolean>} whether anything was compacted
1234
+ */
1235
+ /**
1236
+ * Used when the summarizer call fails or comes back empty: a plain summary
1237
+ * built from what the tools really did. Worse prose than the model's, but it
1238
+ * can never invent anything, and it still frees the room. (Hermes Agent does
1239
+ * the same, MIT, Nous Research.)
1240
+ */
1241
+ function fallbackSummary(userMessage, actions) {
1242
+ const changed = [...new Set(actions.filter((a) => a.ok && /^(write_file|edit_file)$/.test(a.tool)).map((a) => a.label))];
1243
+ const failed = actions.filter((a) => !a.ok || (a.exitCode !== undefined && a.exitCode !== 0)).slice(-8);
1244
+ return [
1245
+ '## Objective', `- ${String(typeof userMessage === 'string' ? userMessage : '').slice(0, 400) || '(see request)'}`,
1246
+ '', '## Work State', '### Completed',
1247
+ ...(changed.length ? changed.map((f) => `- changed ${f}`) : ['- (none)']),
1248
+ '', '### Blocked',
1249
+ ...(failed.length ? failed.map((a) => `- ${a.tool} ${a.label} failed${a.exitCode !== undefined ? ` (exit ${a.exitCode})` : ''}`) : ['- (none)']),
1250
+ '', '## Steps so far', ...actions.slice(-25).map((a) => `- ${a.tool} ${a.label}${a.ok ? '' : ' (FAILED)'}`),
1251
+ ].join('\n');
1252
+ }
1253
+
1254
+ async function compactTranscript(messages, userMessage, signal, route, { actions = [], todos = [] } = {}) {
1255
+ const start = 1;
1256
+ const end = messages.length - KEEP_RECENT_MESSAGES;
1257
+ if (end - start < 4) return false;
1258
+ const prompt = [
1259
+ 'You are a context summarization agent. Produce a structured summary so another coding agent can continue this work. ' +
1260
+ 'Do not continue the conversation or answer questions in it.',
1261
+ `The user's request for this turn:\n${typeof userMessage === 'string' ? userMessage : ''}`,
1262
+ `Here is the conversation so far:\n\n<conversation>\n${summarySource(messages.slice(start, end))}\n</conversation>`,
1263
+ SUMMARY_TEMPLATE,
1264
+ ].join('\n\n');
1265
+ let result = null;
1266
+ try { result = await ai.chat([{ role: 'user', content: prompt }], { signal, route }); } catch {}
1267
+ if (signal?.aborted) return false;
1268
+ let summary = result?.success ? String(result.data?.choices?.[0]?.message?.content || '').trim() : '';
1269
+ // Any action block in a summary would be read as a request later on.
1270
+ summary = summary.replace(/<\/?codeply:[a-z_]+>/gi, '');
1271
+ if (!summary) summary = fallbackSummary(userMessage, actions);
1272
+ const open = formatTodos(todos, { openOnly: true });
1273
+ messages.splice(start, end - start, {
1274
+ role: 'user',
1275
+ content: `[context summary] Reference only: earlier steps of this turn, summarized to save room. Their tool ` +
1276
+ 'results are gone, so re-read a file if you need its exact content. Your actions all still work: keep going ' +
1277
+ 'from "Next Move" with action blocks, and do not redo what is listed as completed.\n\n' + summary +
1278
+ (open ? `\n\n[Your open task list, kept across the summary]\n${open}` : ''),
1279
+ });
1280
+ return true;
1281
+ }
1282
+
1283
+ /**
1284
+ * Once old tool output has been dropped, the model can no longer see what it
1285
+ * already did, which is exactly when it starts redoing steps or "remembering"
1286
+ * work that never happened. A compact record of this turn's real actions,
1287
+ * straight from the tool results, is sent along with every trimmed request.
1288
+ */
1289
+ function actionLedger(actions, todos = []) {
1290
+ if (!actions.length) return null;
1291
+ const rows = actions.slice(-40).map((a, i) =>
1292
+ `${i + 1}. ${a.tool} ${a.label}${a.ok ? '' : ' (FAILED)'}${a.exitCode !== undefined ? ` exit ${a.exitCode}` : ''}`);
1293
+ const open = formatTodos(todos, { openOnly: true });
1294
+ return {
1295
+ role: 'user',
1296
+ content: `[system] Record of what actually ran so far this turn (older output was trimmed). ` +
1297
+ `This is the ground truth; anything not listed here did not happen:\n${rows.join('\n')}` +
1298
+ (open ? `\n\nOpen items on your task list:\n${open}` : ''),
1299
+ };
1300
+ }
1301
+
440
1302
  // ─── Model call ─────────────────────────────────────────────────────────────
441
1303
 
442
1304
  /**
@@ -454,18 +1316,80 @@ function isNativeToolCallError(error) {
454
1316
 
455
1317
  const NO_NATIVE_TOOLS_REMINDER =
456
1318
  '[system] Your last reply tried to invoke a function through the API. That is not available here and the response was discarded. ' +
457
- 'Write the action block as literal text in your message — the tags exactly as shown in the OUTPUT FORMAT section — and nothing else.';
1319
+ 'Write the action block as literal text in your message - the tags exactly as shown in the OUTPUT FORMAT section - and nothing else.';
458
1320
 
459
- async function callModel(messages, promptText) {
1321
+ // ─── Native tool calling: which models get it ──────────────────────────────
1322
+ // Auto (the hosted model behind a proxy that declares no tools) stays on text
1323
+ // actions. User-added models and the CLI's own providers use native calls
1324
+ // unless their entry says toolMode: 'text', or CODEPLY_TOOLS=text is set.
1325
+ // A model that rejects tools is remembered for the rest of the session.
1326
+ const nativeUnsupported = new Set();
1327
+ const MUTATING_ACTIONS = new Set(['write_file', 'edit_file', 'apply_patch', 'fetch_image', 'gmail_send', 'slack_post_message', 'vercel_deploy', 'supabase_create_project', 'supabase_delete_project', 'github_create_repo']);
1328
+
1329
+ /**
1330
+ * Native function names for MCP tools (mcp__server__tool, within the 64-char
1331
+ * [A-Za-z0-9_-] limit APIs enforce) and the maps between them and the
1332
+ * engine's single <codeply:mcp> action.
1333
+ */
1334
+ function mcpNativeNames(list) {
1335
+ const schemas = [];
1336
+ const fromNative = {}; // native name -> { server, tool }
1337
+ const toNative = {}; // "server/tool" -> native name
1338
+ const clean = (s) => String(s).replace(/[^A-Za-z0-9_-]/g, '_');
1339
+ for (const s of list || []) {
1340
+ if (s.error) continue;
1341
+ for (const t of s.tools) {
1342
+ let name = `mcp__${clean(s.name)}__${clean(t.name)}`.slice(0, 64);
1343
+ for (let n = 2; fromNative[name]; n++) name = `${name.slice(0, 60)}_${n}`;
1344
+ fromNative[name] = { server: s.name, tool: t.name };
1345
+ toNative[`${s.name}/${t.name}`] = name;
1346
+ const params = t.inputSchema && t.inputSchema.type === 'object' ? t.inputSchema : { type: 'object', properties: {} };
1347
+ schemas.push({ type: 'function', function: { name, description: `[${s.name}] ${String(t.description || t.name).slice(0, 900)}`, parameters: params } });
1348
+ }
1349
+ }
1350
+ return { schemas, fromNative, toNative };
1351
+ }
1352
+
1353
+ function routeKey(route) {
1354
+ if (route?.custom) return `custom:${route.custom.id || route.custom.model}`;
1355
+ if (route?.auto) return 'auto';
1356
+ try { const c = config.getConfig(); return `cli:${c.provider}:${c[c.provider]?.model || ''}`; } catch { return 'cli'; }
1357
+ }
1358
+
1359
+ function wantsNativeTools(route) {
1360
+ if (process.env.CODEPLY_TOOLS === 'text') return false;
1361
+ if (route?.auto) return false;
1362
+ if (route?.custom) return route.custom.toolMode !== 'text';
1363
+ let c;
1364
+ try { c = config.getConfig(); } catch { return false; }
1365
+ return !!c && !!c.provider && c.provider !== 'codeply' && c.toolMode !== 'text';
1366
+ }
1367
+
1368
+ async function callModel(messages, promptText, signal, route, tools = null, mcpToNative = null) {
460
1369
  const meta = promptText ? { promptText } : undefined;
461
- let result = await ai.chat(messages, { meta });
1370
+ if (tools) {
1371
+ // Native mode: same engine history, translated at the edge (native-tools.mjs).
1372
+ const result = await ai.chat(toNativeMessages(messages, parseReply, mcpToNative), { meta, signal, route, tools });
1373
+ return { result, messages };
1374
+ }
1375
+ let result = await ai.chat(messages, { meta, signal, route });
462
1376
  if (result.success || !isNativeToolCallError(result.error)) return { result, messages };
463
1377
 
464
1378
  const corrected = [...messages, { role: 'user', content: NO_NATIVE_TOOLS_REMINDER }];
465
- result = await ai.chat(corrected, { meta });
1379
+ result = await ai.chat(corrected, { meta, signal, route });
466
1380
  return { result, messages: corrected };
467
1381
  }
468
1382
 
1383
+ /**
1384
+ * House style: no em dashes in anything the user reads. The prompt asks for
1385
+ * that too, but models slip, so prose is cleaned before it's shown. Fenced
1386
+ * code is left exactly as written.
1387
+ */
1388
+ function withoutEmDashes(text) {
1389
+ return text.split(/(```[\s\S]*?```)/).map((part, i) => (i % 2 === 1 ? part
1390
+ : part.replace(/ \u2014 /g, ', ').replace(/\u2014/g, '-'))).join('');
1391
+ }
1392
+
469
1393
  // ─── Loop ───────────────────────────────────────────────────────────────────
470
1394
 
471
1395
  /**
@@ -477,27 +1401,110 @@ async function callModel(messages, promptText) {
477
1401
  * @param {string} o.mode Build | Plan | Ask
478
1402
  * @param {string} o.cwd
479
1403
  * @param {Function} o.approve async ({tool,title,detail,danger}) => 'once'|'always'|'reject'
480
- * @param {{aborted:boolean}} o.signal
481
- * @yields {{type:string, ...}} text | tool_start | tool_end | done | error
1404
+ * @param {Function} [o.browser] async (url, {wait}) => report - optional; only a host with a
1405
+ * real browser (the Codeply Craft desktop app) provides this.
1406
+ * Its absence (plain CLI) is what makes browser_check gracefully
1407
+ * report itself unavailable rather than erroring the whole turn.
1408
+ * @param {object} [o.route] per-turn model route: {auto:true} for the hosted model, or
1409
+ * {custom:<model entry>} for a user-added model (see ai.js chat()).
1410
+ * Omitted = the stored CLI provider config.
1411
+ * @param {string[]} [o.images] data: URLs of images pasted alongside this message - only sent
1412
+ * on THIS turn (not replayed into history on later turns); silently
1413
+ * ignored by any model/provider that doesn't accept vision input.
1414
+ * @param {AbortSignal} o.signal a real AbortSignal - it is handed straight to fetch()
1415
+ * in ai.js, which requires an actual instance, not a
1416
+ * look-alike {aborted} object.
1417
+ * @param {string} [o.roleId] a role from lib/subagents.js (frontend, backend, security, ...)
1418
+ * the agent takes on for this turn - its guide is injected into
1419
+ * the system prompt. Unknown/omitted = no role guide.
1420
+ * @param {string} [o.goal] the overall objective when running under /goal.
1421
+ * @param {number} [o.maxSteps] step budget for this turn (default MAX_STEPS).
1422
+ * @param {boolean} [o.verifyOnly] a checking turn (task verification pass, /goal check): its
1423
+ * replies describe work done in EARLIER turns, so the
1424
+ * "claims an edit it didn't make this turn" check is skipped.
1425
+ * @yields {{type:string, ...}} text | reasoning | tool_start | tool_end | done | error | aborted
482
1426
  */
483
- export async function* runAgent({ userMessage, history, mode, cwd, approve, signal }) {
484
- const readOnly = READ_ONLY_MODES.has(mode);
1427
+ export async function* runAgent({ userMessage, history, mode, cwd, approve, browser, images, signal, route, roleId, goal, maxSteps, verifyOnly }) {
1428
+ let readOnly = READ_ONLY_MODES.has(mode);
1429
+ const stepBudget = Math.max(1, maxSteps || MAX_STEPS);
1430
+ // OpenAI-shaped content array only when there's actually an image to carry -
1431
+ // every ordinary turn keeps the plain string content every other code path
1432
+ // (trimTranscript, prompt caching, dedup keys) already assumes.
1433
+ const userContent = images && images.length
1434
+ ? [{ type: 'text', text: userMessage }, ...images.map((dataUrl) => ({ type: 'image_url', image_url: { url: dataUrl } }))]
1435
+ : userMessage;
1436
+ // Native function calling when the model supports it (see native-tools.mjs).
1437
+ const nativeKey = routeKey(route);
1438
+ let native = wantsNativeTools(route) && !nativeUnsupported.has(nativeKey);
1439
+ // MCP servers the user configured for this project (connections are reused).
1440
+ let mcpList = [];
1441
+ if (Object.keys(mcpLib.loadServers(cwd)).length) {
1442
+ try { mcpList = await mcpLib.listServers(cwd); } catch {}
1443
+ const down = mcpList.filter((s) => s.error);
1444
+ if (down.length) yield { type: 'notice', level: 'warn', text: `MCP: couldn't connect to ${down.map((s) => s.name).join(', ')}. ${down[0].error.slice(0, 160)}` };
1445
+ }
1446
+ const mcpNames = mcpNativeNames(mcpList);
1447
+ // Plan mode keeps write_file/edit_file (only the plan file is writable, enforced
1448
+ // in tools.mjs); plan_exit exists only in Plan and plan_enter only in Build.
1449
+ const schemasFor = (m) => {
1450
+ const ro = READ_ONLY_MODES.has(m);
1451
+ const entries = Object.entries(PARAMS).filter(([n]) => {
1452
+ if (n === 'plan_exit') return m === 'Plan';
1453
+ if (n === 'plan_enter') return m === 'Build';
1454
+ if (ro && MUTATING_ACTIONS.has(n)) return m === 'Plan' && (n === 'write_file' || n === 'edit_file');
1455
+ return true;
1456
+ });
1457
+ return [...buildToolSchemas(Object.fromEntries(entries)), ...mcpNames.schemas];
1458
+ };
1459
+ let toolSchemas = schemasFor(mode);
485
1460
  const messages = [
486
- { role: 'system', content: buildSystemPrompt(mode, cwd) },
1461
+ { role: 'system', content: buildSystemPrompt(mode, cwd, userMessage, { roleId, goal, native, mcp: mcpList }) },
487
1462
  ...history,
488
- { role: 'user', content: userMessage },
1463
+ { role: 'user', content: userContent },
489
1464
  ];
490
1465
 
491
- const ctx = { cwd, approve };
1466
+ // fileState: path -> mtime when this turn last read/wrote it (see tools.mjs).
1467
+ const ctx = { cwd, approve, browser, signal, mode, route, fileState: new Map(), ask: typeof approve?.ask === 'function' ? approve.ask : null };
1468
+ const recentCallKeys = []; // executed calls, in order, for the stuck-loop guard
1469
+ const lastResultFor = new Map(); // call key -> its most recent result
492
1470
  const transcript = [{ role: 'user', content: userMessage }];
493
1471
  let malformedRetries = 0;
494
1472
  let truncatedRetries = 0;
1473
+ let hallucinationRetries = 0;
1474
+ let stallNudges = 0;
1475
+ let budgetWarned = false;
1476
+ const openProblems = new Map(); // file -> syntax problems from its latest write/edit
1477
+ const failuresByTool = new Map(); // tool -> consecutive failures (any args)
1478
+ // What really happened this turn, straight from tool results - the ground
1479
+ // truth every completion claim is checked against, and what the host shows
1480
+ // the user as the turn's actual changes.
1481
+ const actions = []; // [{ tool, label, ok, exitCode }]
1482
+ const succeededTools = new Set();
1483
+ let unverifiedEdits = new Set(); // files changed since the last PASSING check (run exit 0 / browser_check)
1484
+ let uncheckedEdits = new Set(); // files changed since the last check ATTEMPT of any outcome
1485
+ let lastRun = null; // { command, exitCode } of the most recent run
1486
+ let verifyNudges = 0;
1487
+ let codeBlockNudged = false;
1488
+ let madeAnyEdit = false; // true once a write_file/edit_file actually succeeds this turn - gates the check below
1489
+ const writtenBasenames = new Set(); // basenames of files actually write_file'd/edit_file'd this turn - feeds the overclaim check below
1490
+
1491
+ // A prompt instruction alone ("call view_images before designing") was not
1492
+ // reliable enough - real runs showed the model calling
1493
+ // design_reference_search, getting real hits back, and then
1494
+ // going straight to write_file without ever looking at a single screenshot
1495
+ // (see the incident that led to this: it cited "productivity app
1496
+ // references" in its summary for a design it never actually viewed). This
1497
+ // turns that into a hard code-level gate instead of a suggestion: once a
1498
+ // reference search returns real hits, writing/editing a UI file is blocked
1499
+ // until view_images has actually run at least once this turn.
1500
+ let referenceSearchPending = false;
1501
+ const UI_FILE_EXT = /\.(html?|css|jsx?|tsx|vue|svelte)$/i;
495
1502
 
496
1503
  // Two mechanisms that cut the actual token COUNT sent, not just its billed
497
1504
  // price (prompt caching, elsewhere in ai.js, only does the latter):
498
1505
  //
499
1506
  // - servedCalls: a repeat read_file/list_dir/search/use_skill/list_skills
500
- // call within one turn — same tool, same arguments — is a real, common
1507
+ // call within one turn - same tool, same arguments - is a real, common
501
1508
  // pattern (the model re-checks something it already has). Re-running it
502
1509
  // would resend the exact same content a second time for zero new
503
1510
  // information; a short pointer back to the earlier result carries the
@@ -508,16 +1515,87 @@ export async function* runAgent({ userMessage, history, mode, cwd, approve, sign
508
1515
  // stale content), keeping it around costs tokens for information that is
509
1516
  // now wrong. It gets collapsed to a pointer the moment the edit lands,
510
1517
  // not deferred to trimTranscript()'s budget-driven pass.
511
- const DEDUPABLE = new Set(['read_file', 'list_dir', 'search', 'use_skill', 'list_skills']);
1518
+ const DEDUPABLE = new Set(['read_file', 'list_dir', 'search', 'use_skill', 'list_skills', 'web_fetch', 'web_search']);
512
1519
  const servedCalls = new Map(); // callKey -> message index of its result
513
1520
  const readIndexByPath = new Map(); // resolved path -> message index of its read_file result
1521
+ // Consecutive edit_file failures per path - a weak model that keeps
1522
+ // guessing at search text instead of re-reading will otherwise loop
1523
+ // through several apologetic retries in a row without ever converging.
1524
+ // Cleared the moment that path gets a real read_file or a successful edit.
1525
+ const failedEditsByPath = new Map();
1526
+ const MAX_EDIT_FAILURES_BEFORE_FORCE_READ = 2;
1527
+ // resolved path -> next unseen line offset, once a read_file call comes back
1528
+ // with more of the file left. A model re-requesting "the rest of this file"
1529
+ // doesn't reliably track and restate the right offset itself - left to that,
1530
+ // a long file often just gets re-shown from the top every time instead of
1531
+ // actually advancing. Consulted (and rewritten) right before a read_file
1532
+ // call executes, below; cleared once a path's edited, same as readIndexByPath.
1533
+ const readProgressByPath = new Map();
1534
+
1535
+ // A model that keeps investigating without ever committing to an action -
1536
+ // reading file after file, re-checking things it already looked at, never
1537
+ // reaching write_file/edit_file/run - otherwise burns the whole MAX_STEPS
1538
+ // budget on research alone and fails with nothing to show for it (the
1539
+ // observed failure this guards against: several read_file calls in a row,
1540
+ // no edit, straight into "Stopped after 24 steps"). DEDUPABLE's own
1541
+ // "unchanged, reusing that result" nudge above only catches an EXACT
1542
+ // repeat of the same call; this also has to count a DEDUPED call's own
1543
+ // "step" toward the streak (it costs nothing extra, but still, correctly,
1544
+ // is a step spent with nothing new to show) or a model that keeps
1545
+ // blindly re-requesting the same thing would burn the whole loop that way
1546
+ // without ever tripping this guard. Any tool not in this set - including a
1547
+ // failed write/edit attempt, which is still a real attempt to act -
1548
+ // resets the streak; only read-only, no-side-effect calls extend it.
1549
+ const NON_PROGRESS_TOOLS = new Set(['read_file', 'list_dir', 'search', 'use_skill', 'list_skills', 'view_images', 'design_reference_search', 'browser_check', 'web_fetch', 'web_search', 'lsp']);
1550
+ const MAX_READ_ONLY_STREAK = 5;
1551
+ let readOnlyStreak = 0;
1552
+ function readOnlyStreakNote() {
1553
+ readOnlyStreak++;
1554
+ if (readOnlyStreak < MAX_READ_ONLY_STREAK) return '';
1555
+ readOnlyStreak = 0;
1556
+ return ` [system] That's ${MAX_READ_ONLY_STREAK} read-only calls in a row with nothing written or run. Stop investigating - either make the actual change now (write_file/edit_file) based on what you've already seen, or state plainly what's blocking you. Do not call another read-only tool this step.`;
1557
+ }
514
1558
 
515
1559
  const callKey = (name, args) => `${name}:${JSON.stringify(args)}`;
516
1560
 
517
- for (let step = 0; step < MAX_STEPS; step++) {
1561
+ for (let step = 0; step < stepBudget; step++) {
1562
+ if (signal.aborted) { yield { type: 'aborted' }; return; }
1563
+
1564
+ const budget = contextBudgetFor(route);
1565
+ let outgoing = trimTranscript(messages, budget);
1566
+ if (transcriptLength(outgoing) > budget && await compactTranscript(messages, userMessage, signal, route, { actions, todos: ctx.todos || [] })) {
1567
+ // Message positions moved, so the index-based caches below point at the
1568
+ // wrong entries now. They are only shortcuts; starting them over is safe.
1569
+ servedCalls.clear();
1570
+ readIndexByPath.clear();
1571
+ yield { type: 'notice', level: 'info', text: 'Summarized earlier steps of this task to make room.' };
1572
+ outgoing = trimTranscript(messages, budget);
1573
+ }
1574
+ if (outgoing !== messages) {
1575
+ const ledger = actionLedger(actions, ctx.todos || []);
1576
+ if (ledger) outgoing = [...outgoing, ledger];
1577
+ }
518
1578
  if (signal.aborted) { yield { type: 'aborted' }; return; }
519
1579
 
520
- const attempt = await callModel(trimTranscript(messages), userMessage);
1580
+ const stepStarted = Date.now();
1581
+ let attempt = await callModel(outgoing, userMessage, signal, route, native ? toolSchemas : null, mcpNames.toNative);
1582
+ // The model or its endpoint turned out not to do function calling: drop
1583
+ // to text actions for the rest of this turn (and remember it for later
1584
+ // turns), with the text-format system prompt.
1585
+ if (native && !attempt.result.success && !signal.aborted && TOOLS_UNSUPPORTED.test(String(attempt.result.error || ''))) {
1586
+ native = false;
1587
+ nativeUnsupported.add(nativeKey);
1588
+ messages[0] = { role: 'system', content: buildSystemPrompt(mode, cwd, userMessage, { roleId, goal, native: false, mcp: mcpList }) };
1589
+ outgoing = [messages[0], ...outgoing.slice(1)];
1590
+ yield { type: 'notice', level: 'info', text: 'This model does not support native tool calls, so Codeply switched to text actions.' };
1591
+ attempt = await callModel(outgoing, userMessage, signal, route);
1592
+ }
1593
+ const stepMs = Date.now() - stepStarted;
1594
+ // A real fetch abort (see the signal wiring in ai.js) lands here as a
1595
+ // failed result, not a thrown exception - check the abort flag itself
1596
+ // before treating it as an error worth showing the user, otherwise a
1597
+ // stop mid-generation would flash "AbortError" instead of just stopping.
1598
+ if (signal.aborted) { yield { type: 'aborted' }; return; }
521
1599
  const result = attempt.result;
522
1600
  if (!result.success) {
523
1601
  yield {
@@ -529,12 +1607,32 @@ export async function* runAgent({ userMessage, history, mode, cwd, approve, sign
529
1607
  return;
530
1608
  }
531
1609
  // The correction message, if one was needed, has to stay in the transcript.
532
- if (attempt.messages.length > messages.length) {
1610
+ if (attempt.messages !== outgoing) {
533
1611
  messages.push(attempt.messages[attempt.messages.length - 1]);
534
1612
  }
535
1613
 
536
- // gpt-oss puts chain-of-thought in a separate field; content can be null.
537
- const reply = result.data?.choices?.[0]?.message?.content ?? '';
1614
+ // gpt-oss/Laguna put chain-of-thought in a separate field; content can be
1615
+ // null while it's populated. Surfaced as its own event - not appended to
1616
+ // `reply` and not shown inline - so the UI can offer it as an optional,
1617
+ // collapsed "thought for Xs" the user opens on demand instead of dumping
1618
+ // raw reasoning into the chat unconditionally.
1619
+ const reasoning = result.data?.choices?.[0]?.message?.reasoning || '';
1620
+ if (reasoning.trim()) {
1621
+ yield { type: 'reasoning', text: reasoning.trim(), ms: stepMs };
1622
+ }
1623
+
1624
+ const replyMsg = result.data?.choices?.[0]?.message || {};
1625
+ let reply = replyMsg.content ?? '';
1626
+ if (Array.isArray(replyMsg.tool_calls) && replyMsg.tool_calls.length) {
1627
+ // Native calls become ordinary action blocks from here on.
1628
+ const conv = toolCallsToTags(reply, replyMsg.tool_calls, PARAMS, mcpNames.fromNative);
1629
+ reply = conv.text;
1630
+ if (conv.problems.length && !reply.includes('<codeply:')) {
1631
+ messages.push({ role: 'assistant', content: reply || '(tool call)' });
1632
+ messages.push({ role: 'user', content: `[system] Your tool call could not be run: ${conv.problems.join('; ')}. Call one of the provided tools with valid arguments.` });
1633
+ continue;
1634
+ }
1635
+ }
538
1636
  if (!reply.trim()) {
539
1637
  messages.push({
540
1638
  role: 'user',
@@ -542,131 +1640,500 @@ export async function* runAgent({ userMessage, history, mode, cwd, approve, sign
542
1640
  });
543
1641
  continue;
544
1642
  }
545
- messages.push({ role: 'assistant', content: reply });
546
-
547
1643
  const { prose, calls, recovered, malformed, truncated } = parseReply(reply);
548
- if (prose) {
549
- yield { type: 'text', text: prose };
550
- transcript.push({ role: 'assistant', content: prose });
1644
+
1645
+ // Which blocks run this step: normally just the first, but a run of
1646
+ // look-around blocks (reads, listings, searches) goes together, since none
1647
+ // of them changes anything and each would otherwise cost a full round trip.
1648
+ const toRun = [];
1649
+ if (calls.length) {
1650
+ toRun.push(calls[0]);
1651
+ if (BATCHABLE_TOOLS.has(calls[0].name)) {
1652
+ for (const c of calls.slice(1)) {
1653
+ if (!BATCHABLE_TOOLS.has(c.name) || toRun.length >= MAX_BATCH) break;
1654
+ toRun.push(c);
1655
+ }
1656
+ }
551
1657
  }
1658
+ // Keep only what the model wrote up to the last block that runs. The rest
1659
+ // was written blind, before any result, and leaving it in the history
1660
+ // teaches the model to treat its own guesses as things that happened.
1661
+ const lastEnd = toRun.length ? toRun[toRun.length - 1].end : undefined;
1662
+ messages.push({ role: 'assistant', content: typeof lastEnd === 'number' ? reply.slice(0, lastEnd) : reply });
552
1663
 
553
1664
  if (calls.length === 0) {
554
- // A reply that was clearly *reaching* for an action but came out
555
- // unparseable must not be mistaken for "finished" — that silently drops
556
- // the user's task. Correct it and let it try again.
557
- if (truncated && truncatedRetries < MAX_TRUNCATED_RETRIES) {
558
- truncatedRetries++;
559
- messages.push({ role: 'user', content: TRUNCATION_CORRECTION });
1665
+ // Protocol debris first: a truncated or malformed reply's raw text is
1666
+ // fumbled tag syntax, not an answer, and must never reach the user.
1667
+ if (truncated) {
1668
+ if (truncatedRetries < MAX_TRUNCATED_RETRIES) {
1669
+ truncatedRetries++;
1670
+ messages.push({ role: 'user', content: TRUNCATION_CORRECTION });
1671
+ continue;
1672
+ }
1673
+ yield {
1674
+ type: 'error',
1675
+ error: 'The model kept running out of room mid-write, even after being told to write less at once. Try a narrower request.',
1676
+ };
1677
+ return { transcript };
1678
+ }
1679
+ if (malformed) {
1680
+ if (malformedRetries < MAX_MALFORMED_RETRIES) {
1681
+ malformedRetries++;
1682
+ messages.push({ role: 'user', content: FORMAT_CORRECTION });
1683
+ continue;
1684
+ }
1685
+ yield {
1686
+ type: 'error',
1687
+ error: 'The model kept writing its action in the wrong format, even after being corrected. Try rephrasing the request.',
1688
+ };
1689
+ return { transcript };
1690
+ }
1691
+
1692
+ // ── Claim audit ──────────────────────────────────────────────────────
1693
+ // A final answer is checked against what the tools actually did BEFORE
1694
+ // any of it is shown. Corrections are silent: the false claim never
1695
+ // reaches the screen, the model is sent back to either do the work or
1696
+ // restate only what's true.
1697
+ //
1698
+ // 1. Claims a file change. Two shapes: nothing was written this turn at
1699
+ // all, or something was but the claim names more files than were
1700
+ // actually touched ("I've created index.html, styles.css and app.js"
1701
+ // after only index.html was written).
1702
+ // Skipped for read-only and checking turns, whose answers legitimately
1703
+ // describe changes made in earlier turns.
1704
+ // 0. Wrote a tool result itself. Always invented, in any mode.
1705
+ if (FABRICATED_RESULT.test(prose) && hallucinationRetries < MAX_HALLUCINATION_RETRIES) {
1706
+ hallucinationRetries++;
1707
+ messages.push({ role: 'user', content: FABRICATED_RESULT_CORRECTION });
560
1708
  continue;
561
1709
  }
562
- if (malformed && malformedRetries < MAX_MALFORMED_RETRIES) {
563
- malformedRetries++;
564
- messages.push({ role: 'user', content: FORMAT_CORRECTION });
1710
+
1711
+ // 0b. Announced an action and stopped without writing it.
1712
+ if (stallNudges < MAX_STALL_NUDGES && !/\?\s*$/.test(prose) && ANNOUNCED_INTENT.test(prose.slice(-300))) {
1713
+ stallNudges++;
1714
+ messages.push({ role: 'user', content: STALL_CORRECTION });
565
1715
  continue;
566
1716
  }
567
- // No action block: the model considers the turn finished.
568
- yield { type: 'done', steps: step + 1 };
1717
+
1718
+ // A successful shell command can change files too (sed, a codegen
1719
+ // script, git checkout), so after one the file-claim check can't tell a
1720
+ // true claim from a false one and stays out of it.
1721
+ const ranShell = succeededTools.has('run');
1722
+ if (!readOnly && !verifyOnly && !ranShell && HALLUCINATED_COMPLETION.test(prose)) {
1723
+ const overclaimed = madeAnyEdit
1724
+ ? extractClaimedFilenames(prose).filter((f) => !writtenBasenames.has(f))
1725
+ : [];
1726
+ if (!madeAnyEdit || overclaimed.length > 0) {
1727
+ if (hallucinationRetries < MAX_HALLUCINATION_RETRIES) {
1728
+ hallucinationRetries++;
1729
+ messages.push({ role: 'user', content: madeAnyEdit ? overclaimCorrection(overclaimed) : HALLUCINATED_ACTION_CORRECTION });
1730
+ continue;
1731
+ }
1732
+ // Out of retries (it may be describing an earlier turn's work):
1733
+ // let it through, flagged, rather than failing the whole run.
1734
+ yield { type: 'notice', level: 'warn', text: 'Heads up: this reply describes file changes that were not made in this turn. Check the "What actually happened" card for what really changed.' };
1735
+ }
1736
+ }
1737
+
1738
+ // 2. Claims it ran/tested/verified something, or deployed/pushed it,
1739
+ // with no tool that could have done so succeeding this turn.
1740
+ const claimedVerify = CLAIM_VERIFIED.test(prose) && ![...VERIFY_TOOLS].some((t) => succeededTools.has(t));
1741
+ const claimedShip = CLAIM_SHIPPED.test(prose) && ![...SHIP_TOOLS].some((t) => succeededTools.has(t));
1742
+ if (claimedVerify || claimedShip) {
1743
+ if (hallucinationRetries < MAX_HALLUCINATION_RETRIES) {
1744
+ hallucinationRetries++;
1745
+ messages.push({
1746
+ role: 'user',
1747
+ content: unbackedClaimCorrection(claimedShip ? 'deployed, pushed or published something' : 'ran, tested or verified something'),
1748
+ });
1749
+ continue;
1750
+ }
1751
+ // Out of retries: let the answer through, but never silently.
1752
+ yield { type: 'notice', level: 'warn', text: 'Heads up: this summary mentions checks or deployments that no tool actually performed this turn. Treat those claims as unverified.' };
1753
+ }
1754
+
1755
+ // 3. Verification loop - changed code/pages must be checked before the
1756
+ // turn can end (Build mode only; Plan/Ask never edit).
1757
+ if (!readOnly && verifyNudges < MAX_VERIFY_NUDGES) {
1758
+ // The last command failed and the answer doesn't own up to it.
1759
+ if (lastRun && lastRun.exitCode !== 0 && !/(fail|error|couldn'?t|could not|unable|not (?:pass|work)|broken|exit(?:ed)? (?:with )?code)/i.test(prose)) {
1760
+ verifyNudges++;
1761
+ messages.push({ role: 'user', content: failedCommandCorrection(lastRun.command.slice(0, 120), lastRun.exitCode) });
1762
+ continue;
1763
+ }
1764
+ // A file this turn wrote is still failing its syntax check and the
1765
+ // answer doesn't own up to it.
1766
+ const broken = [...openProblems.entries()];
1767
+ // File names are removed first so "broken.js" or "errors.ts" doesn't
1768
+ // count as owning up to the problem.
1769
+ const admitted = /(syntax|error|problem|broken|not (?:fixed|valid))/i.test(prose.replace(FILENAME_TOKEN, ''));
1770
+ if (broken.length && !admitted) {
1771
+ verifyNudges++;
1772
+ messages.push({
1773
+ role: 'user',
1774
+ content: `[system] You are finishing, but the syntax check still fails for ${broken.map(([f]) => f).join(', ')}:\n` +
1775
+ broken.map(([f, p]) => `- ${f}: ${p[0]}`).join('\n') +
1776
+ '\nFix it (edit_file), or if you cannot, say so plainly in your summary.',
1777
+ });
1778
+ continue;
1779
+ }
1780
+ // Files changed since the last time anything was checked at all.
1781
+ const needsCheck = [...uncheckedEdits].filter((f) => VERIFIABLE_EXT.test(f));
1782
+ if (needsCheck.length) {
1783
+ verifyNudges++;
1784
+ messages.push({ role: 'user', content: verifyBeforeDoneCorrection(needsCheck.slice(0, 6)) });
1785
+ yield { type: 'verifying', files: needsCheck };
1786
+ continue;
1787
+ }
1788
+ }
1789
+
1790
+ // 4. Pasted a big code block instead of applying it (asked once).
1791
+ if (!readOnly && !madeAnyEdit && !codeBlockNudged) {
1792
+ const longestBlock = Math.max(0, ...[...prose.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1].split('\n').length));
1793
+ if (longestBlock >= 15) {
1794
+ codeBlockNudged = true;
1795
+ messages.push({ role: 'user', content: CODE_INSTEAD_OF_EDIT_CORRECTION });
1796
+ continue;
1797
+ }
1798
+ }
1799
+ }
1800
+
1801
+ if (prose) {
1802
+ const shown = withoutEmDashes(prose);
1803
+ // interim: narration that accompanies an action (shown folded as "Thinking");
1804
+ // otherwise it's the final answer, shown as a normal message.
1805
+ yield { type: 'text', text: shown, interim: calls.length > 0 };
1806
+ transcript.push({ role: 'assistant', content: shown });
1807
+ }
1808
+
1809
+ if (calls.length === 0) {
1810
+ // No action block, and every check above passed: the turn is finished.
1811
+ yield {
1812
+ type: 'done',
1813
+ steps: step + 1,
1814
+ madeAnyEdit,
1815
+ writtenFiles: [...writtenBasenames],
1816
+ actions,
1817
+ unverifiedFiles: [...unverifiedEdits],
1818
+ };
569
1819
  return { transcript };
570
1820
  }
571
1821
 
572
1822
  if (signal.aborted) { yield { type: 'aborted' }; return; }
573
1823
 
574
- const call = calls[0];
575
- if (calls.length > 1) {
576
- messages.push({
577
- role: 'user',
578
- content: '[note] You wrote several action blocks at once. Only the first was performed. Write one per reply.',
579
- });
580
- }
581
1824
  if (recovered) {
582
1825
  // It worked this time, but only because we guessed. Nudge it back on
583
1826
  // format so the next step does not depend on the same guess.
584
1827
  messages.push({ role: 'user', content: FORMAT_CORRECTION });
585
1828
  }
586
1829
 
587
- if (readOnly && (call.name === 'write_file' || call.name === 'edit_file')) {
588
- messages.push({
589
- role: 'user',
590
- content: `[tool result: ${call.name}] Blocked — ${mode} mode cannot modify files. Describe the change instead, or tell the user to press tab for Build mode.`,
591
- });
592
- yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: `blocked in ${mode} mode` };
593
- continue;
594
- }
1830
+ for (const call of toRun) {
1831
+ if (signal.aborted) { yield { type: 'aborted' }; return; }
595
1832
 
596
- // A repeat of an earlier read-only call, same tool and same arguments,
597
- // carries zero new information — reuse the earlier result instead of
598
- // spending tokens to resend it. This is skipped for read_file specifically
599
- // when the file was edited since (see the invalidation below): the whole
600
- // point there is to force a fresh read, not to serve stale content.
601
- if (DEDUPABLE.has(call.name) && servedCalls.has(callKey(call.name, call.args))) {
1833
+ // A read_file with no <offset> on a path already partway read continues
1834
+ // from where the last call left off, instead of silently restarting at
1835
+ // line 1 - see readProgressByPath above. Only fills in what the model
1836
+ // left unspecified; an explicit offset (including a deliberate 0, to
1837
+ // recheck the top again) always wins.
1838
+ if (call.name === 'read_file' && call.args.path && (call.args.offset === undefined || call.args.offset === null || call.args.offset === '')) {
1839
+ const abs = resolvePath(call.args.path, cwd).abs;
1840
+ if (readProgressByPath.has(abs)) call.args.offset = String(readProgressByPath.get(abs));
1841
+ }
1842
+ const isPlanFileWrite = mode === 'Plan' && (call.name === 'write_file' || call.name === 'edit_file') && isPlanPath(call.args.path, cwd);
1843
+ if (readOnly && (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'apply_patch') && !isPlanFileWrite) {
1844
+ messages.push({
1845
+ role: 'user',
1846
+ content: `[tool result: ${call.name}] Blocked - ${mode} mode cannot modify files${mode === 'Plan' ? ' except the plan file in .codeply/plans/' : ''}. Describe the change instead, or tell the user to press tab for Build mode.`,
1847
+ });
1848
+ yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: `blocked in ${mode} mode` };
1849
+ continue;
1850
+ }
1851
+
1852
+ const touchesUiFile = call.name === 'apply_patch'
1853
+ ? patchTargets(call.args.patch).some((p) => UI_FILE_EXT.test(p))
1854
+ : (call.name === 'write_file' || call.name === 'edit_file') && UI_FILE_EXT.test(call.args.path || '');
1855
+ if (referenceSearchPending && touchesUiFile) {
1856
+ messages.push({
1857
+ role: 'user',
1858
+ content: `[tool result: ${call.name}] Blocked - you searched for real-app references and got real hits back, but never called view_images ` +
1859
+ `to actually look at any of the screenshot URLs. Call view_images now with 2-4 of those URLs (from different apps), THEN write ${call.args.path || 'the file'}.`,
1860
+ });
1861
+ yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: 'blocked - reference screenshots not viewed yet' };
1862
+ continue;
1863
+ }
1864
+
1865
+ // A repeat of an earlier read-only call, same tool and same arguments,
1866
+ // carries zero new information - reuse the earlier result instead of
1867
+ // spending tokens to resend it. This is skipped for read_file specifically
1868
+ // when the file was edited since (see the invalidation below): the whole
1869
+ // point there is to force a fresh read, not to serve stale content.
1870
+ if (DEDUPABLE.has(call.name) && servedCalls.has(callKey(call.name, call.args))) {
1871
+ messages.push({
1872
+ role: 'user',
1873
+ content: `[tool result: ${call.name}] Unchanged since your earlier identical call - reusing that result, not re-run. Nothing new to see; move on.`,
1874
+ });
1875
+ yield {
1876
+ type: 'tool_end', name: call.name, args: call.args, ok: true,
1877
+ meta: { deduped: true }, summary: 'unchanged - reused earlier result',
1878
+ };
1879
+ continue;
1880
+ }
1881
+
1882
+ // The same call, same arguments, several times in a row: whatever it
1883
+ // returns will not change, so running it again just burns a step.
1884
+ const key = callKey(call.name, call.args);
1885
+ if (recentCallKeys.length >= STUCK_REPEATS - 1 && recentCallKeys.slice(-(STUCK_REPEATS - 1)).every((k) => k === key)) {
1886
+ recentCallKeys.length = 0;
1887
+ messages.push({ role: 'user', content: stuckCorrection(call.name, lastResultFor.get(key)) });
1888
+ yield { type: 'tool_end', name: call.name, args: call.args, ok: false, summary: 'skipped - same as the last attempts' };
1889
+ continue;
1890
+ }
1891
+
1892
+ yield { type: 'tool_start', name: call.name, args: call.args };
1893
+
1894
+ const needsApproval = TOOL_NEEDS_APPROVAL.has(call.name);
1895
+ const started = Date.now();
1896
+ const out = await executeTool(call.name, call.args, ctx);
1897
+ recentCallKeys.push(key);
1898
+ lastResultFor.set(key, out);
1899
+ const ms = Date.now() - started;
1900
+
1901
+ yield {
1902
+ type: 'tool_end',
1903
+ name: call.name,
1904
+ args: call.args,
1905
+ ok: out.ok,
1906
+ meta: out.meta,
1907
+ summary: out.meta?.label,
1908
+ ms,
1909
+ needsApproval,
1910
+ };
1911
+
1912
+ // browser_check with a real screenshot attached, or view_images with one
1913
+ // or more fetched images, gets the OpenAI-shaped image content array
1914
+ // (same shape used for pasted user images above) so the model actually
1915
+ // looks at the rendered page / reference screenshots instead of judging
1916
+ // them solely from a text description - that blind verification was
1917
+ // exactly why the same page could get called "the same" or "different"
1918
+ // inconsistently, and why a reference-search result could get cited
1919
+ // without ever actually being looked at. Every other tool, and any
1920
+ // provider that can't take image input, keeps the plain string content
1921
+ // unchanged.
1922
+ const resultText = `[tool result: ${call.name}]\n${out.output}`;
1923
+ const imageUrls = out.meta?.screenshotDataUrl
1924
+ ? [out.meta.screenshotDataUrl]
1925
+ : Array.isArray(out.meta?.imageDataUrls) ? out.meta.imageDataUrls : null;
602
1926
  messages.push({
603
1927
  role: 'user',
604
- content: `[tool result: ${call.name}] Unchanged since your earlier identical call — reusing that result, not re-run. Nothing new to see; move on.`,
1928
+ content: imageUrls
1929
+ ? [{ type: 'text', text: resultText }, ...imageUrls.map((url) => ({ type: 'image_url', image_url: { url } }))]
1930
+ : resultText,
605
1931
  });
606
- yield {
607
- type: 'tool_end', name: call.name, args: call.args, ok: true,
608
- meta: { deduped: true }, summary: 'unchanged — reused earlier result',
609
- };
610
- continue;
611
- }
612
-
613
- yield { type: 'tool_start', name: call.name, args: call.args };
614
-
615
- const needsApproval = TOOL_NEEDS_APPROVAL.has(call.name);
616
- const started = Date.now();
617
- const out = await executeTool(call.name, call.args, ctx);
618
- const ms = Date.now() - started;
619
-
620
- yield {
621
- type: 'tool_end',
622
- name: call.name,
623
- args: call.args,
624
- ok: out.ok,
625
- meta: out.meta,
626
- summary: out.meta?.label,
627
- ms,
628
- needsApproval,
629
- };
630
-
631
- messages.push({ role: 'user', content: `[tool result: ${call.name}]\n${out.output}` });
632
-
633
- if (out.ok) {
634
- if (DEDUPABLE.has(call.name)) {
635
- servedCalls.set(callKey(call.name, call.args), messages.length - 1);
636
- }
637
- if (call.name === 'read_file' && call.args.path) {
638
- const abs = resolvePath(call.args.path, cwd).abs;
639
- readIndexByPath.set(abs, { msgIndex: messages.length - 1, key: callKey(call.name, call.args) });
1932
+
1933
+ // Ground truth for the claim audit above and for the host's "what
1934
+ // actually happened" summary.
1935
+ const exitCode = typeof out.meta?.exitCode === 'number' ? out.meta.exitCode : undefined;
1936
+ const patchFiles = call.name === 'apply_patch' && Array.isArray(out.meta?.files) ? out.meta.files : null;
1937
+ actions.push({
1938
+ tool: call.name,
1939
+ label: String(out.meta?.label || call.args.path || call.args.command || '').slice(0, 200),
1940
+ ok: !!out.ok,
1941
+ ...(exitCode !== undefined ? { exitCode } : {}),
1942
+ ...(patchFiles ? { files: patchFiles } : {}),
1943
+ });
1944
+ // The plan file is not a change to the project, so it must not count as an edit
1945
+ // (it would otherwise feed the "you claimed a change" audit after the switch to Build).
1946
+ const wasPlanFile = isPlanFileWrite;
1947
+ if (out.ok && patchFiles) {
1948
+ succeededTools.add(call.name);
1949
+ for (const f of patchFiles) {
1950
+ const rel = String(f).replace(/\\/g, '/');
1951
+ unverifiedEdits.add(rel);
1952
+ uncheckedEdits.add(rel);
1953
+ const probs = out.meta?.problemsByFile?.[f];
1954
+ if (probs?.length) openProblems.set(rel, probs);
1955
+ else openProblems.delete(rel);
1956
+ }
1957
+ } else if (out.ok) {
1958
+ succeededTools.add(call.name);
1959
+ if (out.meta?.switchMode && out.meta.switchMode !== mode) {
1960
+ mode = out.meta.switchMode;
1961
+ readOnly = READ_ONLY_MODES.has(mode);
1962
+ ctx.mode = mode;
1963
+ toolSchemas = schemasFor(mode);
1964
+ messages[0] = { role: 'system', content: buildSystemPrompt(mode, cwd, userMessage, { roleId, goal, native, mcp: mcpList }) };
1965
+ yield { type: 'mode_switch', mode, planPath: out.meta.planPath };
1966
+ }
1967
+ if ((call.name === 'write_file' || call.name === 'edit_file' || call.name === 'fetch_image') && !out.meta?.noop && call.args.path && !wasPlanFile) {
1968
+ const rel = String(call.args.path).replace(/\\/g, '/');
1969
+ unverifiedEdits.add(rel);
1970
+ uncheckedEdits.add(rel);
1971
+ if (Array.isArray(out.meta?.problems) && out.meta.problems.length) openProblems.set(rel, out.meta.problems);
1972
+ else openProblems.delete(rel);
1973
+ } else if (call.name === 'run') {
1974
+ lastRun = { command: String(call.args.command || ''), exitCode: exitCode ?? 0 };
1975
+ uncheckedEdits = new Set();
1976
+ if (lastRun.exitCode === 0) unverifiedEdits = new Set();
1977
+ } else if (call.name === 'browser_check') {
1978
+ uncheckedEdits = new Set();
1979
+ unverifiedEdits = new Set();
1980
+ }
640
1981
  }
641
- if ((call.name === 'edit_file' || call.name === 'write_file') && call.args.path) {
642
- // The file just changed, so any earlier read_file result for it is now
643
- // wrong, not just old — collapse it in place rather than leaving
644
- // outdated content sitting in the transcript for the model to
645
- // (mis)reason from, and drop it from the dedup cache so a genuinely
646
- // fresh read_file after this point is not short-circuited by it.
1982
+
1983
+ if (out.ok) {
1984
+ if (call.name === 'design_reference_search' && out.meta?.count > 0) {
1985
+ referenceSearchPending = true;
1986
+ }
1987
+ if (call.name === 'view_images') {
1988
+ referenceSearchPending = false;
1989
+ }
1990
+ if (DEDUPABLE.has(call.name)) {
1991
+ servedCalls.set(callKey(call.name, call.args), messages.length - 1);
1992
+ }
1993
+ if (call.name === 'read_file' && call.args.path) {
1994
+ const abs = resolvePath(call.args.path, cwd).abs;
1995
+ readIndexByPath.set(abs, { msgIndex: messages.length - 1, key: callKey(call.name, call.args) });
1996
+ // A fresh read is exactly the course-correction we'd otherwise force -
1997
+ // no need to keep counting failures against this path anymore.
1998
+ failedEditsByPath.delete(abs);
1999
+ if (out.meta?.hasMore) readProgressByPath.set(abs, (out.meta.offset || 0) + (out.meta.linesShown || 0));
2000
+ else readProgressByPath.delete(abs); // the whole file has now been seen at least once
2001
+ }
2002
+ if ((call.name === 'edit_file' || call.name === 'write_file') && !wasPlanFile) {
2003
+ madeAnyEdit = true;
2004
+ if (call.args.path) writtenBasenames.add(path.basename(call.args.path).toLowerCase());
2005
+ }
2006
+ if (patchFiles) {
2007
+ madeAnyEdit = true;
2008
+ for (const f of patchFiles) writtenBasenames.add(path.basename(f).toLowerCase());
2009
+ }
2010
+ // Paths this call just changed: earlier reads of them are now wrong.
2011
+ const changedNow = patchFiles
2012
+ ? [...patchFiles, ...patchTargets(call.args.patch)]
2013
+ : ((call.name === 'edit_file' || call.name === 'write_file') && call.args.path ? [call.args.path] : []);
2014
+ for (const p of changedNow) failedEditsByPath.delete(resolvePath(p, cwd).abs);
2015
+ for (const p of changedNow) {
2016
+ // The file just changed, so any earlier read_file result for it is now
2017
+ // wrong, not just old - collapse it in place rather than leaving
2018
+ // outdated content sitting in the transcript for the model to
2019
+ // (mis)reason from, and drop it from the dedup cache so a genuinely
2020
+ // fresh read_file after this point is not short-circuited by it.
2021
+ const abs = resolvePath(p, cwd).abs;
2022
+ const stale = readIndexByPath.get(abs);
2023
+ if (stale) {
2024
+ messages[stale.msgIndex] = {
2025
+ role: 'user',
2026
+ content: `[tool result: read_file] ${p} - superseded by the ${call.name} below; that earlier content no longer matches the file. Re-read if you need to see it again.`,
2027
+ };
2028
+ servedCalls.delete(stale.key);
2029
+ readIndexByPath.delete(abs);
2030
+ }
2031
+ readProgressByPath.delete(abs);
2032
+ }
2033
+ } else if (call.name === 'edit_file' && call.args.path) {
2034
+ // A model that keeps guessing at search text instead of re-reading the
2035
+ // file will otherwise spiral through several apologetic retries in a
2036
+ // row without ever converging on the actual current content. After
2037
+ // enough consecutive failures on the *same* path, stop it from trying
2038
+ // again blind and force a real read_file first.
647
2039
  const abs = resolvePath(call.args.path, cwd).abs;
648
- const stale = readIndexByPath.get(abs);
649
- if (stale) {
650
- messages[stale.msgIndex] = {
2040
+ const failures = (failedEditsByPath.get(abs) || 0) + 1;
2041
+ failedEditsByPath.set(abs, failures);
2042
+ if (failures >= MAX_EDIT_FAILURES_BEFORE_FORCE_READ) {
2043
+ failedEditsByPath.set(abs, 0);
2044
+ messages.push({
651
2045
  role: 'user',
652
- content: `[tool result: read_file] ${call.args.path} — superseded by the ${call.name} below; that earlier content no longer matches the file. Re-read if you need to see it again.`,
653
- };
654
- servedCalls.delete(stale.key);
655
- readIndexByPath.delete(abs);
2046
+ content: `[note] That edit_file call on ${call.args.path} has now failed ${failures} times in a row - the search text you're guessing at does not match the file. Do not apologize or re-explain what you were trying to do. Call read_file on ${call.args.path} right now, look at its actual current content, then make the edit with search text copied exactly from it.`,
2047
+ });
2048
+ }
2049
+ }
2050
+
2051
+ // A declined action is the user's answer, not a retry prompt.
2052
+ if (out.meta?.rejected) {
2053
+ messages.push({
2054
+ role: 'user',
2055
+ content: '[note] The user declined that action. Do not retry it. Either continue without it or stop and explain what you would have done.',
2056
+ });
2057
+ }
2058
+
2059
+ // Failure streaks per tool, whatever the arguments. A run that exits
2060
+ // non-zero counts as a failure here even though the tool itself worked.
2061
+ // Any successful change (write/edit, or a passing run) starts over,
2062
+ // since "edit, re-run, still failing" is normal debugging, not a loop.
2063
+ const failed = !out.ok || (call.name === 'run' && out.meta?.exitCode !== 0);
2064
+ if (out.meta?.rejected) {
2065
+ // The user's call, not the model's mistake.
2066
+ } else if (failed) {
2067
+ const n = (failuresByTool.get(call.name) || 0) + 1;
2068
+ failuresByTool.set(call.name, n);
2069
+ if (n === FAILURE_STREAK_WARN || n === FAILURE_STREAK_WARN * 2) {
2070
+ messages.push({ role: 'user', content: failureStreakNote(call.name, n) });
2071
+ }
2072
+ } else {
2073
+ failuresByTool.delete(call.name);
2074
+ if (/^(write_file|edit_file|apply_patch|run)$/.test(call.name)) {
2075
+ failuresByTool.clear();
2076
+ recentCallKeys.length = 0; // a real change breaks any cycle
656
2077
  }
657
2078
  }
658
2079
  }
659
2080
 
660
- // A declined action is the user's answer, not a retry prompt.
661
- if (out.meta?.rejected) {
2081
+ if (calls.length > toRun.length) {
662
2082
  messages.push({
663
2083
  role: 'user',
664
- content: '[note] The user declined that action. Do not retry it. Either continue without it or stop and explain what you would have done.',
2084
+ content: `[note] You wrote ${calls.length} action blocks at once. Only ${toRun.length === 1 ? 'the first was' : `the first ${toRun.length} (all look-around) were`} performed. ` +
2085
+ 'Anything that changes something (write_file, edit_file, run, ...) goes alone in its reply.',
665
2086
  });
666
2087
  }
2088
+
2089
+ const period = findCycle(recentCallKeys);
2090
+ if (period) {
2091
+ messages.push({ role: 'user', content: cycleNote(period) });
2092
+ recentCallKeys.length = 0;
2093
+ }
2094
+
2095
+ // Counted once per step, not per block, so a batch of reads is one step
2096
+ // of looking around rather than four. The task list is planning, not
2097
+ // looking around, so it neither extends nor resets the streak.
2098
+ const counted = toRun.filter((c) => c.name !== 'todo' && c.name !== 'ask_user');
2099
+ if (!counted.length) {
2100
+ // todo only
2101
+ } else if (counted.every((c) => NON_PROGRESS_TOOLS.has(c.name))) {
2102
+ const note = readOnlyStreakNote().trim();
2103
+ if (note) messages.push({ role: 'user', content: note });
2104
+ } else {
2105
+ readOnlyStreak = 0;
2106
+ }
2107
+
2108
+ if (!budgetWarned && stepBudget >= 12 && step + 1 >= stepBudget - 5) {
2109
+ budgetWarned = true;
2110
+ messages.push({ role: 'user', content: budgetCheckpointNote(step + 1, stepBudget) });
2111
+ }
2112
+ }
2113
+
2114
+ // Out of steps. One last call with actions switched off, so the user gets a
2115
+ // real account of where things stand instead of a bare "stopped". Its
2116
+ // answer is checked the same way: no action blocks, no invented results.
2117
+ if (!signal.aborted && actions.length) {
2118
+ const closing = await callModel([...trimTranscript(messages, contextBudgetFor(route)), { role: 'user', content: FINAL_SUMMARY_PROMPT }], userMessage, signal, route);
2119
+ const text = closing.result?.success ? String(closing.result.data?.choices?.[0]?.message?.content || '') : '';
2120
+ const { prose, calls: stray } = parseReply(text);
2121
+ // Keep only what comes before any action block it wrote anyway.
2122
+ const cleaned = (stray.length ? text.slice(0, Math.max(0, text.search(/<codeply:/i))) : prose).trim();
2123
+ if (cleaned && !FABRICATED_RESULT.test(cleaned)) {
2124
+ const shown = withoutEmDashes(cleaned);
2125
+ yield { type: 'text', text: shown, interim: false };
2126
+ transcript.push({ role: 'assistant', content: shown });
2127
+ }
667
2128
  }
668
2129
 
669
- yield { type: 'error', error: `Stopped after ${MAX_STEPS} steps without finishing. Narrow the request and try again.` };
2130
+ yield {
2131
+ type: 'error',
2132
+ error: `Stopped after ${stepBudget} steps without finishing.` +
2133
+ (madeAnyEdit ? ` Changed so far: ${[...writtenBasenames].join(', ')}.` : ' No files were changed.') +
2134
+ ' Send a follow-up to continue, or narrow the request.',
2135
+ actions,
2136
+ };
670
2137
  }
671
2138
 
672
2139
  export { MAX_STEPS };