zames_pro 2.69.0 → 2.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ // DeepSeek UI constants — the SINGLE place where chat.deepseek.com specifics live.
2
+ //
3
+ // WHY this module exists: the agent drives the DeepSeek web UI through
4
+ // Playwright, so every selector, button label and protocol marker is a
5
+ // hardcoded assumption about a page we do not control. Keeping them scattered
6
+ // across browser.ts made a DeepSeek redesign a diffuse, easy-to-miss breakage
7
+ // (a renamed class here, a relabeled button there). Concentrating them here
8
+ // turns "DeepSeek changed the UI" into ONE file to audit, and lets the health
9
+ // check (probeUiHealth) report the exact broken capability to the operator
10
+ // BEFORE a task hangs on it.
11
+ //
12
+ // Priority order everywhere: prefer SEMANTIC anchors (role / aria-label /
13
+ // accessible name / input type) over DeepSeek's `ds-*` class names, which
14
+ // drift between builds. The `ds-*` selectors stay in the chains as a
15
+ // high-precision FIRST hit, with semantic fallbacks after them.
16
+ // ---------- endpoints ----------
17
+ export const CHAT_URL = 'https://chat.deepseek.com/';
18
+ export const CHAT_ID_RE = /\/chat\/s\/([a-zA-Z0-9_-]+)/;
19
+ // DeepSeek's chat URL shape. A direct navigation always yields the requested
20
+ // chat (the sidebar link click is tried first, this is the fallback).
21
+ export const CHAT_PATH_PREFIX = 'a/chat/s/';
22
+ export function chatUrl(id) {
23
+ return CHAT_URL + CHAT_PATH_PREFIX + id;
24
+ }
25
+ // The history endpoint returns EVERY message of a chat as JSON (the rendered
26
+ // page virtualizes long chats). Requested from INSIDE the page so the auth
27
+ // cookie and PoW handling are the browser's; see browser.fetchChatMessages.
28
+ export const HISTORY_MESSAGES_URL = 'https://chat.deepseek.com/api/v0/chat/history_messages';
29
+ // ---------- input ----------
30
+ export const INPUT_SELECTORS = [
31
+ 'textarea',
32
+ 'div[contenteditable="true"]',
33
+ '[role="textbox"]',
34
+ ];
35
+ // ---------- send ----------
36
+ // IMPORTANT: you MUST NOT add the generic
37
+ // 'div[role="button"][class*="ds-button--primary"]' here — it matches the
38
+ // STOP button too, and then _isGenerating() always returns true, so an answer
39
+ // is never considered ready.
40
+ export const SEND_SELECTORS = [
41
+ 'div[role="button"].ds-button--primary.ds-button--circle',
42
+ 'div[role="button"].ds-button--primary.ds-button--filled',
43
+ 'button[type="submit"]',
44
+ 'button[aria-label*="send" i]',
45
+ 'button[aria-label*="отправ" i]',
46
+ ];
47
+ // DeepSeek's own button classes. Used as a coarse "is this a DeepSeek button?"
48
+ // filter in the raw-DOM fallbacks for Stop and for the send/stop icon
49
+ // detection. Kept as a source string because the fallback runs INSIDE
50
+ // page.evaluate, which cannot receive a RegExp object.
51
+ export const BUTTON_CLASS_RE = /ds-button--(circle|primary|filled)/i;
52
+ // ---------- stop ----------
53
+ export const STOP_SELECTORS = [
54
+ 'div[role="button"][aria-label*="stop" i]',
55
+ 'div[role="button"][aria-label*="останов" i]',
56
+ 'button:has-text("Stop")',
57
+ 'button:has-text("Остановить")',
58
+ 'button[aria-label*="Stop" i]',
59
+ ];
60
+ // Accessible name of the Stop button, for a TRUSTED Playwright click. A bare
61
+ // stop word (optionally with a short suffix), anchored and length-limited so a
62
+ // "Stop" inside rendered prose is never clicked.
63
+ export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
64
+ // ---------- continue ----------
65
+ // DeepSeek's "Continue" button. With the reasoning ("Deep thinking") toggle on
66
+ // the server caps the THINK phase: the reasoning stops mid-way and the UI
67
+ // offers a Continue button (NOT the same as a truncated turn with
68
+ // generation_err — there the answer FAILED; here the model paused). The agent
69
+ // must click it so the reasoning/answer keeps flowing.
70
+ //
71
+ // Reasoning mode uses a LONGER label («Продолжить размышление» / «Continue
72
+ // thinking»), not a bare «Continue», so the match is a regex: a bare continue
73
+ // word OR the word plus a short reasoning/answer suffix. It is anchored and
74
+ // length-limited, so a "Continue" inside rendered prose is never clicked.
75
+ export const CONTINUE_NAME_RE = /^(?:continue|продолжить|продолжение)(?:\s+(?:think(?:ing)?|reason(?:ing)?|размышлени[ея]|генераци[юя]|ответ))?\s*[.!…]?$/i;
76
+ // ---------- messages / answers ----------
77
+ // The rendered assistant answer. The FIRST hit is preferred (most specific),
78
+ // so `ds-markdown` stays ahead of the generic `markdown` catch-all.
79
+ export const ANSWER_SELECTORS = [
80
+ 'div.ds-assistant-message-main-content',
81
+ 'div[class*="ds-assistant-message-main-content"]',
82
+ 'div[class*="ds-markdown"]',
83
+ 'div[class*="markdown"]',
84
+ ];
85
+ // Reasoning blocks. They are NOT the answer and must never be picked up as
86
+ // one, otherwise the terminal would show the model's long reasoning.
87
+ export const THINK_CLASS_RE = /ds-think-content|thinking-content/i;
88
+ // Last-resort answer-only selectors for the restored-dialogue scraper. The
89
+ // generic `markdown` catch-all is deliberately absent here: without a message
90
+ // container it would also match a user bubble that happens to render markdown.
91
+ export const RESTORE_ANSWER_SELECTORS = [
92
+ 'div.ds-assistant-message-main-content',
93
+ 'div[class*="ds-assistant-message-main-content"]',
94
+ 'div[class*="ds-markdown"]',
95
+ ];
96
+ // Message-level containers for the restored dialogue (broad first).
97
+ export const MESSAGE_CONTAINER_SELECTORS = [
98
+ '[data-message-id]',
99
+ '[class*="ds-message"]',
100
+ '[class*="chat-message"]',
101
+ '[class*="message-item"]',
102
+ '[class*="_message"]',
103
+ ];
104
+ // A message block is "assistant" when it carries the assistant class OR a
105
+ // rendered markdown wrapper (a user bubble has no rendered markdown). Used by
106
+ // the restored-dialogue scraper; the class names drift between builds, so the
107
+ // detection is content-based rather than class-prefix-only.
108
+ export const ASSISTANT_CLASS_RE = /ds-assistant-message|assistant-message/i;
109
+ export const ASSISTANT_SELECTOR = '[class*="ds-assistant-message"]';
110
+ export const ANSWER_MARKDOWN_SELECTOR = '[class*="ds-markdown"]';
111
+ // A cheap "did the page grow?" signal (_chatSignal): count message nodes and
112
+ // measure the LAST answer node's text length. The old code read
113
+ // document.body.innerText.length every tick, which serializes the WHOLE page
114
+ // (sidebar, history, menus) and forces a layout — expensive on a long chat.
115
+ export const CHAT_SIGNAL_NODES_SELECTOR = '[class*="ds-message"], [class*="chat-message"], .ds-markdown';
116
+ export const MARKDOWN_SELECTOR = '.ds-markdown';
117
+ // ---------- new chat ----------
118
+ // The "New chat" control is NOT a <button>/<a> on the current DeepSeek build
119
+ // (it is a <div>), so matching only button/a missed it. Match any
120
+ // clickable-ish element whose text is EXACTLY the new-chat label (an anchored
121
+ // regex keeps a big outer container from matching its long descendant text).
122
+ export const NEW_CHAT_SELECTOR = 'button, a, [role="button"], div, span';
123
+ export const NEW_CHAT_TEXT_RE = /^\s*(new chat|новый чат|новый диалог)\s*$/i;
124
+ // ---------- login ----------
125
+ // The login page is served on the same origin and swaps in a password field;
126
+ // the exact classes change, so we match loosely on input types and the known
127
+ // placeholder/autocomplete attributes.
128
+ export const PASSWORD_SELECTORS = [
129
+ 'input[type="password"]',
130
+ 'input[autocomplete="current-password"]',
131
+ 'input[autocomplete="new-password"]',
132
+ ];
133
+ export const LOGIN_SELECTORS = [
134
+ 'input[type="email"]',
135
+ 'input[type="tel"]',
136
+ 'input[name="email"]',
137
+ 'input[name="phone"]',
138
+ 'input[name="username"]',
139
+ 'input[placeholder*="email" i]',
140
+ 'input[placeholder*="phone" i]',
141
+ 'input[placeholder*="телефон" i]',
142
+ 'input[placeholder*="почт" i]',
143
+ 'input[autocomplete="username"]',
144
+ 'input[autocomplete="email"]',
145
+ ];
146
+ // Any login-ish button: a primary DeepSeek button or a plain <button>. The
147
+ // visible label is filtered by LOGIN_NAME_RE so "Log in with Google" is not
148
+ // hit by mistake.
149
+ export const LOGIN_BUTTON_SELECTOR = 'div[role="button"].ds-button--primary, button';
150
+ export const LOGIN_NAME_RE = /^\s*(log ?in|sign ?in|войти)\s*$/i;
151
+ // A looser label test used inside the form fallback: a Continue button can also
152
+ // submit the login step on some builds.
153
+ export const LOGIN_FORM_SUBMIT_RE = /log ?in|sign ?in|войти|continue|продолж/i;
154
+ export const LOGIN_SUBMIT_SELECTORS = [
155
+ // DeepSeek's real button: a div[role=button] with these classes and a
156
+ // <span class="ds-button__content">Log in</span> inside.
157
+ 'div[role="button"].ds-button--primary.ds-button--filled',
158
+ 'div[role="button"].ds-button--primary',
159
+ 'button[type="submit"]',
160
+ 'button:has-text("Log in")',
161
+ 'button:has-text("Sign in")',
162
+ 'button:has-text("Войти")',
163
+ 'div[role="button"]:has-text("Log in")',
164
+ 'div[role="button"]:has-text("Sign in")',
165
+ 'div[role="button"]:has-text("Войти")',
166
+ ];
167
+ // ---------- sidebar / chats ----------
168
+ export const SIDEBAR_TOGGLE_SELECTORS = [
169
+ 'button[aria-label*="sidebar" i]',
170
+ 'button[aria-label*="история" i]',
171
+ 'button[aria-label*="history" i]',
172
+ 'button[class*="sidebar-toggle"]',
173
+ 'button[class*="sidebarToggle"]',
174
+ ];
175
+ // ---------- toggles (deep thinking / web search) ----------
176
+ // DeepSeek exposes two toggle buttons above the input. Their labels are
177
+ // localized, so we match by a loose regex on the visible text and read the
178
+ // state from aria-pressed. `.ds-toggle-button` is the class anchor; the
179
+ // semantic fallback ([aria-pressed] or [role=switch]) is tried when the class
180
+ // is renamed, so a redesign does not silently disable the toggles.
181
+ export const TOGGLE_SELECTORS = [
182
+ '.ds-toggle-button',
183
+ '[role="switch"]',
184
+ 'div[role="button"][aria-pressed]',
185
+ 'button[aria-pressed]',
186
+ ];
187
+ export const DEEP_THINKING_RE = /глубок|deep\s*think/i;
188
+ export const WEB_SEARCH_RE = /поиск|search/i;
189
+ // ---------- attachments ----------
190
+ export const ATTACH_SELECTORS = [
191
+ 'div[role="button"][aria-label*="attach" i]',
192
+ 'div[role="button"][aria-label*="влож" i]',
193
+ 'button[aria-label*="attach" i]',
194
+ 'button[aria-label*="влож" i]',
195
+ '[class*="upload"]',
196
+ '[class*="attach"]',
197
+ ];
198
+ // ---------- service statuses / errors ----------
199
+ // DeepSeek UI service statuses that are NOT the model's answer. Otherwise the
200
+ // agent takes a status (Reading...) for an answer and breaks parsing.
201
+ export const STATUS_RE = /^(reading|thinking|searching|analyzing|generating|stop|остановить|читаю|думаю|поиск|анализ)[\s.…]*$/i;
202
+ // DeepSeek's answer when the rate limit is exceeded.
203
+ //
204
+ // IMPORTANT: the generic "try again later" / "повторите позже" tail is NOT
205
+ // part of this pattern. DeepSeek appends it to MANY toasts ("Server busy.
206
+ // Try again later.", "Service unavailable. Try again later."), so matching it
207
+ // here classified every server hiccup as a RATE LIMIT and sent the agent into
208
+ // a 5-minute wait. The rate limit is recognized by its specific wording only.
209
+ export const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|слишком часто|сообщени[яе] слишком част)/i;
210
+ // Transient server-side hiccups (DeepSeek overloaded / hiccup). Unlike the
211
+ // rate limit, these usually clear in seconds, so we retry quickly instead of
212
+ // waiting minutes.
213
+ export const SERVER_BUSY_RE = /(server (is )?busy|server error|service (is )?unavailable|temporarily unavailable|internal server error|502|503|504|server overloaded|сервер занят|сервер перегружен|сервис недоступен|внутренняя ошибка|попробуйте позже)/i;
214
+ // ---------- network protocol (SSE) ----------
215
+ export const GENERATION_ERR_RE = /"finish_reason":\s*"generation_err"/i;
216
+ export const INCOMPLETE_STATUS_RE = /"quasi_status","v":"INCOMPLETE"/i;
217
+ // A turn that ENDED (`quasi_status: FINISHED`) but produced NO answer text —
218
+ // the reasoning was generated and the model stopped WITHOUT a RESPONSE
219
+ // fragment. The UI shows Stopped + Continue.
220
+ export const FINISHED_STATUS_RE = /"quasi_status","v":"FINISHED"/i;
221
+ export const RESPONSE_CONTENT_RE = /"type":"RESPONSE","content":"[^"]/;
222
+ // Only a completion/continue turn carries a real answer. Gating the capture
223
+ // assignment to these URLs keeps a future endpoint (which happens to return a
224
+ // `content`/`text` field) from clobbering the answer and looking fresh.
225
+ export const ANSWER_URL_RE = /chat\/(completion|continue)/i;
@@ -119,6 +119,14 @@ export const configMessages = {
119
119
  ru: 'Пересылать системный промпт при возобновлении чата',
120
120
  en: 'Resend the system prompt on chat resume',
121
121
  },
122
+ 'cfg.f.browser_subagents': {
123
+ ru: 'Субагенты: инструмент Task (отдельный чат)',
124
+ en: 'Subagents: the Task tool (separate chat)',
125
+ },
126
+ 'cfg.f.browser_maxSubagents': {
127
+ ru: 'Максимум субагентов на задачу',
128
+ en: 'Max subagents per task',
129
+ },
122
130
  'cfg.f.browser_deepThinking': {
123
131
  ru: 'Глубокое мышление (долго; размышления не выводятся)',
124
132
  en: 'Deep thinking (slow; reasoning is hidden)',
@@ -38,6 +38,26 @@ export const doctorMessages = {
38
38
  ru: 'UA ещё не закэширован (первый запуск перезапустится один раз)',
39
39
  en: 'no cached UA yet (first start relaunches once)',
40
40
  },
41
+ 'doctor.ui_ok': {
42
+ ru: 'все ключевые элементы найдены',
43
+ en: 'all key elements found',
44
+ },
45
+ 'doctor.ui_degraded': {
46
+ ru: 'часть элементов не найдена: {v} (агент может работать некорректно)',
47
+ en: 'some elements not found: {v} (the agent may misbehave)',
48
+ },
49
+ 'doctor.ui_broken': {
50
+ ru: 'НЕ найдено критичное: {v} — DeepSeek сменил вёрстку, задачи не будут отправляться. Запусти /debug-dom.',
51
+ en: 'MISSING critical: {v} — DeepSeek changed the markup, tasks will not send. Run /debug-dom.',
52
+ },
53
+ 'ui.probe_failed': {
54
+ ru: '⚠ DeepSeek UI: не найдено критичное ({v}) — возможно, сменилась вёрстка chat.deepseek.com. Задачи могут не отправляться; проверь /doctor и /debug-dom.',
55
+ en: '⚠ DeepSeek UI: missing critical element(s) ({v}) — chat.deepseek.com markup may have changed. Tasks may not send; check /doctor and /debug-dom.',
56
+ },
57
+ 'ui.probe_degraded': {
58
+ ru: '⚠ DeepSeek UI: не найдено ({v}) — часть возможностей (тумблеры/новый чат) может не работать.',
59
+ en: '⚠ DeepSeek UI: not found ({v}) — some features (toggles/new chat) may not work.',
60
+ },
41
61
  'adddir.usage': {
42
62
  ru: 'Использование: /add-dir <path>',
43
63
  en: 'Usage: /add-dir <path>',
package/dist/index.js CHANGED
@@ -6,9 +6,10 @@ import { existsSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme } from './theme.js';
8
8
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
- import { createTools, mergeTools } from './tools.js';
9
+ import { createTools, mergeTools, filterToolsForReadOnly } from './tools.js';
10
10
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
11
11
  import { runAgentLoop } from './agent-loop.js';
12
+ import { createSubagentRunner } from './subagent.js';
12
13
  import { createSpinner } from './spinner.js';
13
14
  import { LineEditor } from './input.js';
14
15
  import { parseImagePaste, extForMime, saveToTemp, guessMime, formatSize, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
@@ -997,6 +998,23 @@ async function main() {
997
998
  await transcript.close();
998
999
  process.exit(1);
999
1000
  }
1001
+ // DeepSeek UI health probe. The agent drives a page we do not control, so a
1002
+ // redesign silently breaks selectors and the FIRST symptom is a task hanging
1003
+ // on "input field not found". Probe the always-present controls right after
1004
+ // login and warn the operator up front. Best-effort: a probe failure must
1005
+ // never block startup.
1006
+ const warnUiHealth = (h) => {
1007
+ if (h.missingCritical.length) {
1008
+ console.error(theme.warn(t('ui.probe_failed', { v: h.missingCritical.join(', ') })));
1009
+ }
1010
+ else if (h.missing.length) {
1011
+ console.error(theme.warn(t('ui.probe_degraded', { v: h.missing.join(', ') })));
1012
+ }
1013
+ };
1014
+ try {
1015
+ warnUiHealth(await browser.probeUiHealth());
1016
+ }
1017
+ catch { }
1000
1018
  // One-shot mode
1001
1019
  if (task) {
1002
1020
  const baseTools = mod.createTools(currentWorkdir, {
@@ -1104,6 +1122,33 @@ async function main() {
1104
1122
  // Write the checklist whenever the agent changes it, so a restart/resume
1105
1123
  // brings the tasks back (they used to be in-memory only).
1106
1124
  todoStore.onChange = () => persistTodos();
1125
+ // Subagents (BACKLOG N36): the `Task` tool delegates a self-contained
1126
+ // sub-task to a nested agent loop in its OWN chat (context isolation — in
1127
+ // zames "context" IS the chat), then restores this chat and returns only
1128
+ // the report. Sequential by design (one browser, one send slot). On by
1129
+ // default via browser.subagents (its own budget caps the extra sends).
1130
+ const makeSubagentRunner = () => config.browser.subagents
1131
+ ? createSubagentRunner({
1132
+ browser,
1133
+ workdir: currentWorkdir,
1134
+ locale: currentLocale,
1135
+ runAgentLoop: mod.runAgentLoop,
1136
+ buildTools: (readOnly) => {
1137
+ const base = mod.createTools(currentWorkdir, {
1138
+ undo,
1139
+ todos: todoStore,
1140
+ readOnly: false,
1141
+ subagents: false,
1142
+ });
1143
+ return readOnly ? filterToolsForReadOnly(base) : base;
1144
+ },
1145
+ transcript,
1146
+ maxSubagents: config.browser.maxSubagents,
1147
+ askDeadlineMs: config.browser.askDeadlineMs,
1148
+ maxAfterToolRetries: config.browser.maxAfterToolRetries,
1149
+ selfImprovement: devMode,
1150
+ })
1151
+ : null;
1107
1152
  // A compact "tasks: 2/5" summary for the status line. Empty when the list
1108
1153
  // is empty, so an idle status is not cluttered.
1109
1154
  const todosSummary = () => {
@@ -2940,6 +2985,9 @@ async function main() {
2940
2985
  const mcpStatus = mcpPool
2941
2986
  ? mcpPool.status()
2942
2987
  : { servers: [], toolCount: 0 };
2988
+ // Probe the DeepSeek UI so a redesign shows up as a /doctor row instead
2989
+ // of a task that hangs on a missing input. Best-effort.
2990
+ const uiHealth = await browser.probeUiHealth().catch(() => null);
2943
2991
  console.log(theme.system(mod.commands.renderDoctor({
2944
2992
  nodeVersion: process.version,
2945
2993
  platform: process.platform,
@@ -2961,6 +3009,9 @@ async function main() {
2961
3009
  sshRemote: !!(process.env.SSH_CONNECTION || process.env.SSH_TTY),
2962
3010
  headless: !!headless,
2963
3011
  headlessUaCached: await headlessUACacheReady().catch(() => false),
3012
+ uiPresent: uiHealth?.present,
3013
+ uiMissing: uiHealth?.missing,
3014
+ uiMissingCritical: uiHealth?.missingCritical,
2964
3015
  }, t)));
2965
3016
  continue;
2966
3017
  }
@@ -3372,6 +3423,9 @@ async function main() {
3372
3423
  undo,
3373
3424
  todos: todoStore,
3374
3425
  readOnly: planMode,
3426
+ // `Task` is only offered when subagents are enabled AND we are not in
3427
+ // plan (read-only) mode — a `general` subagent could write otherwise.
3428
+ subagents: config.browser.subagents && !planMode,
3375
3429
  });
3376
3430
  const tools = mergeTools(baseTools, mcpPool?.tools, planMode);
3377
3431
  if (editor)
@@ -3429,6 +3483,7 @@ async function main() {
3429
3483
  goal: sessionGoal,
3430
3484
  todosQuery: () => todosSummary(),
3431
3485
  selfImprovement: devMode,
3486
+ onSubagent: makeSubagentRunner(),
3432
3487
  }, inputAttachments);
3433
3488
  }
3434
3489
  finally {
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { FINISHED_STATUS_RE, RESPONSE_CONTENT_RE } from './deepseek-ui.js';
4
5
  // Extraction of the RAW model answer text from DeepSeek's network data.
5
6
  //
6
7
  // Why: the answer is read from the RENDERED DOM, and DeepSeek's rendering
@@ -175,11 +176,11 @@ export function isFinishedWithoutAnswer(body) {
175
176
  const b = String(body || '');
176
177
  if (!b)
177
178
  return false;
178
- const finished = /"quasi_status","v":"FINISHED"/i.test(b);
179
+ const finished = FINISHED_STATUS_RE.test(b);
179
180
  if (!finished)
180
181
  return false;
181
182
  // A RESPONSE fragment with actual content means the model DID answer.
182
- if (/"type":"RESPONSE","content":"[^"]/.test(b))
183
+ if (RESPONSE_CONTENT_RE.test(b))
183
184
  return false;
184
185
  // The turn carried reasoning (THINK) or was truncated mid-way; treat the
185
186
  // absence of response content as "no answer produced".
package/dist/run-task.js CHANGED
@@ -237,7 +237,7 @@ export function watchInput({ onEscape, onChange, onQueue, } = {}) {
237
237
  }
238
238
  export async function runTask(deps, browser, tools, taskText, workdir, opts, attachments = []) {
239
239
  const { t, locale, debug, maxIter, config, runAgentLoop, createSpinner } = deps;
240
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, selfImprovement = false, } = opts;
240
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, todosQuery = null, selfImprovement = false, onSubagent = null, } = opts;
241
241
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
242
242
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
243
243
  // mode (pipes) - a regular spinner + watchInput.
@@ -358,6 +358,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
358
358
  contextLimit,
359
359
  getTokenUsage,
360
360
  selfImprovement,
361
+ onSubagent,
361
362
  });
362
363
  // The loop may end WITHOUT a model answer: an exhausted iteration limit
363
364
  // or an ask() watchdog. Surface it instead of a silent stop.
@@ -0,0 +1,119 @@
1
+ // Subagent runner (BACKLOG N36). A `Task` tool call from the model is handled
2
+ // by the agent loop as a SEAM (like onAutoCompact): it calls back into the
3
+ // caller, which owns the whole lifecycle. This module builds that callback.
4
+ //
5
+ // Why a SEPARATE chat: in zames "context" IS the DeepSeek chat, so context
6
+ // isolation can only be achieved by opening a new chat, running a nested
7
+ // agent loop there, and restoring the parent chat afterwards. The nested loop
8
+ // returns ONLY the subagent's final `respond` report; the parent never sees
9
+ // the subagent's raw turns, which is the whole point.
10
+ //
11
+ // This is deliberately SEQUENTIAL: one browser, one send slot. Parallel
12
+ // subagents would need a second page and a shared send scheduler (a change to
13
+ // browser.ts, which owns the live Playwright context) — out of scope here.
14
+ /** Hard cap on iterations for one subagent run. */
15
+ export const SUBAGENT_MAX_ITER = 30;
16
+ /**
17
+ * The subagent persona, prepended to the task message. It is deliberately NOT
18
+ * injected as a replacement system prompt: the nested runAgentLoop builds the
19
+ * standard system prompt itself (tool list + call format), and the model needs
20
+ * exactly that to work. The persona only adds role + output contract.
21
+ */
22
+ export function subagentPersona(type) {
23
+ const base = 'You are a SUBAGENT launched by a main coding agent to complete ONE ' +
24
+ 'self-contained sub-task in an ISOLATED chat. You do NOT see the main ' +
25
+ "conversation's history, so rely only on the instructions below and on " +
26
+ 'the files/tools available. You cannot talk to the operator, so do not ' +
27
+ 'ask questions. When the sub-task is done, call respond with a concise, ' +
28
+ 'information-dense report: findings, exact file paths, relevant code ' +
29
+ 'snippets and a clear conclusion. Do NOT call respond until the work is ' +
30
+ 'complete.';
31
+ if (type === 'explore') {
32
+ return (base +
33
+ '\n\nYou are in EXPLORE (read-only) mode: you may search and read files ' +
34
+ 'but you CANNOT modify anything or run mutating commands. Investigate ' +
35
+ 'and report your findings.');
36
+ }
37
+ return base;
38
+ }
39
+ /**
40
+ * Build the `onSubagent` callback for runAgentLoop. It enforces the per-task
41
+ * budget, opens a fresh chat, runs a nested loop (with the subagent's own
42
+ * task text and a restricted or full tool set), restores the parent chat and
43
+ * returns only the report.
44
+ */
45
+ export function createSubagentRunner(opts) {
46
+ const { browser, workdir, locale, runAgentLoop, buildTools, transcript = null, maxSubagents, askDeadlineMs, maxAfterToolRetries, selfImprovement = false, onAskPermission, } = opts;
47
+ let used = 0;
48
+ return async (req) => {
49
+ if (used >= maxSubagents) {
50
+ return {
51
+ ok: false,
52
+ text: 'Subagent budget exhausted (' +
53
+ maxSubagents +
54
+ ' per task). Do the remaining work yourself with the other tools.',
55
+ };
56
+ }
57
+ used++;
58
+ const parentChatId = await browser.getCurrentChatId().catch(() => null);
59
+ const subTools = buildTools(req.type === 'explore');
60
+ const taskText = subagentPersona(req.type) + '\n\n--- SUB-TASK ---\n' + req.prompt;
61
+ // Save the parent's send hooks: the nested loop OVERWRITES them on
62
+ // browser, and leaving the nested (no-op) callbacks in place would kill
63
+ // the parent's spinner/status for the rest of the task.
64
+ const savedHooks = {
65
+ onSendStart: browser.onSendStart ?? null,
66
+ onSendPause: browser.onSendPause ?? null,
67
+ onSendState: browser.onSendState ?? null,
68
+ onNotice: browser.onNotice ?? null,
69
+ };
70
+ let report = '';
71
+ let failed = '';
72
+ try {
73
+ report = await runAgentLoop({
74
+ browser,
75
+ tools: subTools,
76
+ task: taskText,
77
+ workdir,
78
+ maxIterations: SUBAGENT_MAX_ITER,
79
+ // A fresh chat gives the subagent an isolated context; the nested loop
80
+ // opens it AND sends the standard system prompt itself.
81
+ freshChat: true,
82
+ sendSystemPrompt: true,
83
+ transcript,
84
+ locale,
85
+ askDeadlineMs,
86
+ maxAfterToolRetries,
87
+ selfImprovement,
88
+ // No recursion: the subagent cannot spawn its own subagents.
89
+ onSubagent: null,
90
+ // Do not persist the subagent chat as a session.
91
+ onChatReady: () => { },
92
+ onAskPermission: onAskPermission
93
+ ? (info) => onAskPermission({
94
+ tool: info.tool,
95
+ reason: info.reason,
96
+ action: info.action,
97
+ })
98
+ : undefined,
99
+ });
100
+ }
101
+ catch (e) {
102
+ failed = e.message;
103
+ }
104
+ finally {
105
+ // Restore the parent chat, then the hooks, so a failure in openChat
106
+ // still leaves the hooks correct.
107
+ if (parentChatId) {
108
+ await browser.openChat(parentChatId).catch(() => false);
109
+ }
110
+ browser.onSendStart = savedHooks.onSendStart;
111
+ browser.onSendPause = savedHooks.onSendPause;
112
+ browser.onSendState = savedHooks.onSendState;
113
+ browser.onNotice = savedHooks.onNotice;
114
+ }
115
+ if (failed)
116
+ return { ok: false, text: failed };
117
+ return { ok: true, text: report };
118
+ };
119
+ }
@@ -21,6 +21,35 @@ Rules for that note:
21
21
  - this is the ONE exception to "do not touch unrelated files". If nothing
22
22
  worth noting, add nothing.
23
23
  `;
24
+ // Guidance for the `Task` tool (subagents), injected only when that tool is in
25
+ // the model's set. Without it the model under-uses subagents (it does every
26
+ // broad search itself and floods the main context) or misuses them (delegates
27
+ // work that needs THIS conversation's history, which the isolated subagent
28
+ // cannot see). Kept short: it competes with the tool list for space.
29
+ const SUBAGENT_SECTION = `
30
+ ## Task tool (subagents)
31
+
32
+ The Task tool delegates a self-contained sub-task to a subagent that works in
33
+ its OWN separate chat with an ISOLATED context and returns ONLY a final
34
+ report. The subagent does NOT see this conversation, so the prompt you pass
35
+ must be fully self-contained (goal, exact questions, file paths, context).
36
+
37
+ Use it to PROTECT this chat's context and to parallelize independent work:
38
+
39
+ - Broad exploration whose raw hits would flood this chat ("find every use of
40
+ X", "map how module Y works end to end"): delegate it with
41
+ subagent_type "explore" (read-only) and keep only the report.
42
+ - A self-contained research or verification sub-task you can specify in a
43
+ paragraph: delegate it and continue the main line meanwhile.
44
+ - Several INDEPENDENT investigations: emit several Task calls in ONE batch.
45
+
46
+ Do NOT use it for:
47
+ - work that depends on this conversation's history (the subagent cannot see
48
+ it) - pass everything needed in the prompt, or do the work yourself;
49
+ - a trivial single read/edit you can do directly (a subagent costs a full
50
+ extra chat with its own system prompt and sends);
51
+ - edits that must run strictly in order with the rest of this chat.
52
+ `;
24
53
  /** Render the AGENTS.md / MEMORY / skills / commands blocks of the prompt. */
25
54
  export function renderContextSection(context) {
26
55
  if (!context)
@@ -112,6 +141,12 @@ export function renderToolSection(tools) {
112
141
  export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], context = null, selfImprovement = false, }) {
113
142
  const t = translate(locale);
114
143
  const toolDescriptions = renderToolSection(tools);
144
+ // Emit the subagent guidance ONLY when the Task tool is actually in the set
145
+ // (subagents enabled and not in plan mode), so the prompt never tells the
146
+ // model about a tool it does not have.
147
+ const subagentSection = tools.some((x) => x.name === 'Task')
148
+ ? SUBAGENT_SECTION
149
+ : '';
115
150
  const gitSection = gitContext
116
151
  ? `\n## Git context\n\n${gitContext}\n`
117
152
  : '\n## Git context\n\nNot a git repository (or git is not installed).\n';
@@ -186,7 +221,9 @@ Prefer the most specific tool; a wrong choice wastes a turn:
186
221
  directory -> LS.
187
222
  - Git operations -> the Git* tools, not Bash.
188
223
  - Reading a file whose path you already know -> Read, not Bash cat.
189
-
224
+ - A broad search/exploration that would flood this chat -> the Task tool
225
+ (a subagent in its own chat) if it is available, then work from its report.
226
+ ${subagentSection}
190
227
  ## MCP tools
191
228
 
192
229
  Some tools are provided by external MCP servers and their names look
package/dist/tools.js CHANGED
@@ -9,7 +9,7 @@ import { createExtraTools } from './extraTools.js';
9
9
  // user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
10
10
  // strings the OPERATOR sees (help, spinner, service messages) are localized —
11
11
  // those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
12
- export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
12
+ export function createTools(workdir, { undo, todos, readOnly = false, subagents = false, } = {}) {
13
13
  const root = path.resolve(workdir);
14
14
  const safe = (p) => safePath(root, p);
15
15
  // Extracts text from content/content_base64. base64 is needed because the
@@ -200,11 +200,34 @@ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
200
200
  parameters: { message: 'string' },
201
201
  fn: async ({ message }) => message,
202
202
  };
203
+ // `Task` is a SEAM handled by the agent loop (onSubagent), not an ordinary
204
+ // fn. Its body here is only a fallback for a direct unit-test call; the live
205
+ // path never reaches it because agent-loop intercepts the name first.
206
+ const taskTool = {
207
+ name: 'Task',
208
+ description: 'Delegate a self-contained sub-task to a SUBAGENT that works in its own ' +
209
+ 'separate chat with an isolated context, then returns ONLY a final ' +
210
+ 'report. Use it to keep heavy research out of the main context. ' +
211
+ 'subagent_type: "explore" (read-only: search/read, cannot modify) or ' +
212
+ '"general" (full tools). The subagent does NOT see this conversation, so ' +
213
+ '`prompt` must be fully self-contained: state the goal, the exact ' +
214
+ 'questions, and any file paths/context it needs. Returns the subagent ' +
215
+ 'report as text.',
216
+ parameters: {
217
+ description: 'string',
218
+ prompt: 'string',
219
+ subagent_type: 'string?',
220
+ },
221
+ fn: async () => 'Subagents are not available in this run. Do the work yourself.',
222
+ };
223
+ // In plan (read-only) mode `Task` is dropped too: a `general` subagent could
224
+ // otherwise write through it and defeat the read-only promise.
203
225
  const all = [
204
226
  ...baseTools,
205
227
  ...extraTools,
206
228
  ...gitTools,
207
229
  ...webTools,
230
+ ...(subagents && !readOnly ? [taskTool] : []),
208
231
  respondTool,
209
232
  ];
210
233
  return readOnly ? filterToolsForReadOnly(all) : all;