zames_pro 2.82.49 → 2.82.51

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 (57) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +159 -26
  3. package/SECURITY.md +50 -0
  4. package/dist/agent-loop.js +35 -5
  5. package/dist/answer-parse.js +1 -1
  6. package/dist/attach-flow.js +7 -3
  7. package/dist/attach-refs.js +30 -6
  8. package/dist/attachments.js +9 -0
  9. package/dist/browser-chats.js +9 -2
  10. package/dist/browser-dom.js +1 -1
  11. package/dist/browser-pure.js +9 -3
  12. package/dist/browser.js +1 -1
  13. package/dist/changelog.js +1 -1
  14. package/dist/checkpoint.js +1 -1
  15. package/dist/cli-args.js +1 -1
  16. package/dist/config-command.js +1 -1
  17. package/dist/config-commands.js +5 -2
  18. package/dist/config-live.js +10 -0
  19. package/dist/config.js +13 -1
  20. package/dist/context-command.js +6 -0
  21. package/dist/context.js +27 -4
  22. package/dist/diff.js +52 -9
  23. package/dist/extraTools.js +7 -0
  24. package/dist/glm-chats.js +18 -1
  25. package/dist/glm-net-capture.js +23 -5
  26. package/dist/goal.js +6 -1
  27. package/dist/i18n/deepseek.js +3 -3
  28. package/dist/i18n/self-review.js +4 -0
  29. package/dist/i18n.js +8 -2
  30. package/dist/index.js +90 -101
  31. package/dist/input/layout.js +33 -8
  32. package/dist/input/suggest.js +1 -1
  33. package/dist/input.js +1 -1
  34. package/dist/limits-command.js +42 -0
  35. package/dist/mcp.js +23 -4
  36. package/dist/model-command.js +21 -0
  37. package/dist/path-token.js +36 -9
  38. package/dist/permissions.js +32 -3
  39. package/dist/provider-command.js +29 -0
  40. package/dist/provider-handoff.js +18 -1
  41. package/dist/queue-command.js +15 -0
  42. package/dist/qwen-chats.js +13 -6
  43. package/dist/qwen-net-capture.js +13 -6
  44. package/dist/resume-command.js +38 -0
  45. package/dist/send-telemetry.js +15 -3
  46. package/dist/send-throttle.js +3 -1
  47. package/dist/status-command.js +68 -0
  48. package/dist/subagent.js +5 -12
  49. package/dist/tool-summary.js +8 -1
  50. package/dist/tools.js +13 -1
  51. package/dist/tui/StatusBar.js +1 -1
  52. package/dist/tui/log-window.js +41 -4
  53. package/dist/undo.js +11 -1
  54. package/dist/web.js +16 -6
  55. package/llms-full.txt +679 -0
  56. package/llms.txt +102 -0
  57. package/package.json +84 -23
@@ -89,7 +89,7 @@ function stripRefPunct(tok) {
89
89
  t = t.slice(0, -1);
90
90
  return t;
91
91
  }
92
- // Find all `@path` references in a task text (B5). Returns the unique paths in
92
+ // Find all `@path` references in a task text. Returns the unique paths in
93
93
  // order of appearance. A reference is an '@' at a word boundary followed by a
94
94
  // token that looks like a file name/path (a known extension). Implemented
95
95
  // without regular expressions, like extractPathToken, to keep the source free
@@ -107,35 +107,62 @@ export function extractAtFileRefs(text) {
107
107
  continue;
108
108
  }
109
109
  const prev = i > 0 ? chars[i - 1] : '';
110
- // A comma separates list items: `@a.ts,@b.ts` is TWO references. The
111
- // comma itself is not a boundary (it is stripped as prose punctuation),
112
- // so without this the second `@` never started a new scan and its file was
113
- // never inlined.
114
- if (prev && !isRefBoundary(prev) && prev !== ',') {
110
+ // The at-sign must START a reference: at the very beginning, after
111
+ // whitespace/a bracket/a quote, or right after a LIST SEPARATOR (a comma
112
+ // or a semicolon). A second at-sign WITHOUT a separator (`@a.ts@b.ts`) is
113
+ // an email/decorator shape and is skipped — the glued path cannot be
114
+ // inlined anyway.
115
+ if (prev && !isRefBoundary(prev) && prev !== ',' && prev !== ';') {
115
116
  i++;
116
117
  continue;
117
118
  }
118
119
  let j = i + 1;
119
120
  let tok = '';
121
+ // The token is rejected WHOLE when it contains an at-sign that is NOT a
122
+ // list separator — `@Component` / `user@host` are a decorator and an
123
+ // email. Trimming the prefix at that at-sign would otherwise inline a
124
+ // bogus path.
125
+ let hadInnerAt = false;
126
+ // A token that contains an at-sign (`@Component`, `user@host`) is a
127
+ // decorator / an email, NOT a file reference. The scan used to stop at
128
+ // that at-sign and hand the PREFIX (`Component` … no) or, worse, the part
129
+ // BEFORE it to the inliner. Now the whole token is rejected instead.
130
+ const isRef = (t) => t.length > 0 && !t.includes('@');
120
131
  while (j < n && !isRefEnd(chars[j])) {
121
132
  // A COMMA directly followed by another `@` starts the NEXT reference:
122
133
  // `@a.ts,@b.ts` is a list, and scanning to whitespace made the whole
123
134
  // `a.ts,@b.ts` one path, so neither file was inlined.
124
135
  if (chars[j] === ',' && chars[j + 1] === '@')
125
136
  break;
137
+ // A SEMICOLON or a second at-sign also separates list items. Scanning
138
+ // to whitespace made `a.ts;@b.ts` ONE path whose extension consumed the
139
+ // separator, so `looksLikeToken` failed and BOTH files were dropped.
140
+ if ((chars[j] === ';' || chars[j] === '@') && chars[j + 1] === '@')
141
+ break;
142
+ // An at-sign followed by an ordinary char is part of the token (an
143
+ // email/decorator) — remember it so the token is rejected below.
144
+ if (chars[j] === '@')
145
+ hadInnerAt = true;
126
146
  tok += chars[j];
127
147
  j++;
128
148
  }
129
149
  const cleaned = stripRefPunct(tok);
130
- if (cleaned && looksLikeToken(cleaned) && !seen.has(cleaned)) {
150
+ if (!hadInnerAt &&
151
+ isRef(cleaned) &&
152
+ looksLikeToken(cleaned) &&
153
+ !seen.has(cleaned)) {
131
154
  seen.add(cleaned);
132
155
  out.push(cleaned);
133
156
  }
134
157
  // The break above leaves `j` ON the comma; step past it so the next `@`
135
158
  // is seen as the start of a reference. When the scan ended on a
136
159
  // terminator instead, continue AFTER it.
137
- if (chars[j] === ',' && chars[j + 1] === '@')
138
- i = j + 1;
160
+ // The scan stopped ON a separator that split the token (`,`, `;` or a
161
+ // second at-sign): resume AT it so the next part is scanned as its own
162
+ // reference. Continuing past it dropped the second file of a list.
163
+ const sep = j < n ? chars[j] : '';
164
+ if (j > i + 1 && (sep === ',' || sep === ';' || sep === '@'))
165
+ i = j;
139
166
  else
140
167
  i = j > i + 1 ? j : i + 1;
141
168
  }
@@ -156,14 +156,43 @@ export function decidePermission(policy, tool, args) {
156
156
  // the natural shape for execFile-style MCP tools). Accepting only strings
157
157
  // let a `command` deny be bypassed by passing the words as an array.
158
158
  const commands = [];
159
- for (const k of COMMAND_KEYS) {
160
- const v = a[k];
159
+ // Collect a value as a command string: a string is one command, an ARRAY
160
+ // (argv) is joined into one line so a rule can match the words in order.
161
+ const addCommand = (v) => {
161
162
  if (typeof v === 'string')
162
163
  commands.push(v);
163
164
  else if (Array.isArray(v)) {
164
- commands.push(v.filter((x) => typeof x === 'string').join(' '));
165
+ const parts = v.filter((x) => typeof x === 'string');
166
+ if (parts.length)
167
+ commands.push(parts.join(' '));
165
168
  }
169
+ };
170
+ for (const k of COMMAND_KEYS) {
171
+ if (k in a)
172
+ addCommand(a[k]);
166
173
  }
174
+ // MCP tools (and a model that nests its payload) send the command one or
175
+ // more levels down — `{args: {command}}`, `{input: {params: {cmd}}}`.
176
+ // Checking only the TOP level let a `command` deny rule be bypassed by
177
+ // wrapping the same words in an object, so nested objects and arrays are
178
+ // scanned too (depth-bounded like the path scan).
179
+ const walkCommands = (v, depth) => {
180
+ if (depth > 4 || v === null || v === undefined)
181
+ return;
182
+ if (Array.isArray(v)) {
183
+ for (const x of v)
184
+ walkCommands(x, depth + 1);
185
+ return;
186
+ }
187
+ if (typeof v !== 'object')
188
+ return;
189
+ for (const [key, val] of Object.entries(v)) {
190
+ if (COMMAND_KEYS.includes(key))
191
+ addCommand(val);
192
+ walkCommands(val, depth + 1);
193
+ }
194
+ };
195
+ walkCommands(a, 0);
167
196
  const paths = collectPathCandidates(tool, args);
168
197
  for (const r of policy.rules) {
169
198
  // An UNPARSABLE regex in any field of the rule makes the rule fail
@@ -0,0 +1,29 @@
1
+ import { providerHasModelPicker, providerLabel, isProviderId, PROVIDER_IDS, } from './providers.js';
2
+ import { translate } from './i18n.js';
3
+ /** Parse the text after `/provider` into an intent. `current` is the ACTIVE id. */
4
+ export function parseProviderIntent(arg, current) {
5
+ const a = String(arg ?? '')
6
+ .trim()
7
+ .toLowerCase();
8
+ if (!a || a === 'status')
9
+ return { kind: 'status' };
10
+ if (a === 'login')
11
+ return { kind: 'login' };
12
+ if (isProviderId(a))
13
+ return { kind: 'switch', provider: a, same: a === current };
14
+ return { kind: 'usage' };
15
+ }
16
+ /** The usage line for an unknown `/provider` argument. */
17
+ export function providerUsageMessage(t = translate('en')) {
18
+ return t('provider.usage', { providers: PROVIDER_IDS.join('|') });
19
+ }
20
+ /**
21
+ * Why a provider with no model picker cannot be selected via `/model`.
22
+ * (Kept here as well as in model-command so the two commands share the
23
+ * capability check instead of each hardcoding a provider id.)
24
+ */
25
+ export function providerWithoutPickerMessage(provider, t = translate('en')) {
26
+ if (providerHasModelPicker(provider))
27
+ return null;
28
+ return t('model.unsupported', { provider: providerLabel(provider) });
29
+ }
@@ -133,10 +133,27 @@ function sameDir(a, b) {
133
133
  };
134
134
  return real(x) === real(y);
135
135
  }
136
- /** Remove the handoff once it has been posted (or is stale). Best-effort. */
136
+ /** Remove the handoff once it has been posted (or is stale). Best-effort.
137
+ *
138
+ * The GLOBAL file and every per-workdir SIDE file are removed. Clearing only
139
+ * the global one left a displaced record readable through its side file, so
140
+ * an already-consumed summary was posted again on a later switch in that
141
+ * project ("the summary came back from the dead"). Side files are small and
142
+ * their whole purpose is a pending handoff, so removing all of them is safe. */
137
143
  export function clearProviderHandoff() {
138
144
  try {
139
145
  fs.unlinkSync(HANDOFF_FILE);
140
146
  }
141
147
  catch { }
148
+ try {
149
+ for (const f of fs.readdirSync(ZAMES_HOME)) {
150
+ if (!f.startsWith('provider-handoff-') || !f.endsWith('.json'))
151
+ continue;
152
+ try {
153
+ fs.unlinkSync(path.join(ZAMES_HOME, f));
154
+ }
155
+ catch { }
156
+ }
157
+ }
158
+ catch { }
142
159
  }
@@ -0,0 +1,15 @@
1
+ import { formatQueueList } from './commands.js';
2
+ /**
3
+ * The full output of `/queue` (minus the `clear` SIDE EFFECT, which the caller
4
+ * performs on its own live queue). `sub` is the parsed subcommand or null when
5
+ * it was not recognized; `count` is the live queue length.
6
+ */
7
+ export function renderQueueView(sub, count, queue) {
8
+ if (!sub)
9
+ return { kind: 'usage' };
10
+ if (sub === 'clear')
11
+ return { kind: 'cleared', n: count };
12
+ if (!count || !queue.length)
13
+ return { kind: 'empty' };
14
+ return { kind: 'list', lines: formatQueueList(queue) };
15
+ }
@@ -187,12 +187,19 @@ export function parseQwenHistory(json) {
187
187
  arr = asArr(d.messages);
188
188
  else if (d.chat && typeof d.chat === 'object') {
189
189
  const chat = d.chat;
190
- const hist = chat.history && typeof chat.history === 'object'
191
- ? chat.history
192
- : null;
193
- if (hist && hist.messages)
194
- arr = asArr(hist.messages);
195
- else if (chat.messages)
190
+ // `history` may itself be the message LIST (an array), not only an
191
+ // object with a `messages` field: the array form was read as
192
+ // `history.messages` -> undefined and the whole restored dialogue came
193
+ // back EMPTY even though the payload carried it.
194
+ const hist = chat.history;
195
+ if (Array.isArray(hist))
196
+ arr = hist;
197
+ else if (hist && typeof hist === 'object') {
198
+ const h = hist;
199
+ if (h.messages)
200
+ arr = asArr(h.messages);
201
+ }
202
+ if (!arr && chat.messages)
196
203
  arr = asArr(chat.messages);
197
204
  }
198
205
  }
@@ -97,6 +97,12 @@ function pickAnswer(node) {
97
97
  if (typeof node !== 'object')
98
98
  return '';
99
99
  const obj = node;
100
+ // A plain-JSON reply can carry the SAME phase marker as an SSE delta
101
+ // (`thinking_summary`). The reasoning guard used to live only in the
102
+ // choices[] branch, so a top-level `{phase:"thinking_summary",content}`
103
+ // (or the same object one envelope deeper) was returned as the ANSWER.
104
+ if (isThinkingPhase(obj))
105
+ return '';
100
106
  if (Array.isArray(obj.choices)) {
101
107
  let out = '';
102
108
  for (const ch of obj.choices) {
@@ -220,13 +226,14 @@ export function isRateLimitText(body) {
220
226
  const b = String(body || '');
221
227
  if (!b)
222
228
  return false;
223
- // `quota_limit` is a SEPARATE code Qwen returns in the SSE error field
224
- // ("The service is currently experiencing high demand. Please try again
225
- // later.") — a TRANSIENT capacity limit, so it belongs with the rate-limit
226
- // family (back off and retry), not with the guest DAILY limit.
227
- if (/quota_limit/i.test(b))
229
+ const errNotice = /event:\s*error|"type"\s*:\s*"error"|"error"\s*:/i.test(b);
230
+ // The `quota_limit` CODE only means something in an ERROR payload: a normal
231
+ // ANSWER may MENTION `quota_limit` (the operator asked how to handle that
232
+ // API error), and this check used to run BEFORE the answer-content guard, so
233
+ // such an answer made ask() back off and re-send a good turn.
234
+ if (/quota_limit/i.test(b) && (errNotice || !hasAnswerContent(b)))
228
235
  return true;
229
- if (/event:\s*error|"type"\s*:\s*"error"|"error"\s*:/i.test(b)) {
236
+ if (errNotice) {
230
237
  return /too many requests|rate limit|too frequent|please try again later|high demand|слишком часто/i.test(b);
231
238
  }
232
239
  // No structured error marker: only treat the body as a notice when it has
@@ -0,0 +1,38 @@
1
+ import { translate } from './i18n.js';
2
+ // Pure half of the session commands (/sessions, /resume-id). The resume itself
3
+ // drives a browser; deciding WHICH session may be opened — the filter, the id
4
+ // normalization, a foreign-provider refusal — is pure and lives here.
5
+ /** Parse the text after `/resume-id` into a chat id, or null when empty. */
6
+ export function parseResumeId(arg) {
7
+ const id = String(arg ?? '').trim();
8
+ return id ? id : null;
9
+ }
10
+ /** Keep the sessions matching a `/sessions <query>` filter (title or workdir). */
11
+ export function filterSessions(sessions, filter) {
12
+ const q = String(filter ?? '')
13
+ .trim()
14
+ .toLowerCase();
15
+ if (!q)
16
+ return sessions.slice();
17
+ return sessions.filter((s) => (s.title || '').toLowerCase().includes(q) ||
18
+ (s.workdir || '').toLowerCase().includes(q));
19
+ }
20
+ /**
21
+ * A chat id only exists on the backend that created it, so opening a session
22
+ * saved by ANOTHER provider would fail inside the browser with a confusing
23
+ * error. Old files carry no `provider` field and belong to DeepSeek (the only
24
+ * backend that existed then).
25
+ *
26
+ * Returns a user-facing refusal, or null when the session may be opened.
27
+ */
28
+ export function resumeProviderRefusal(session, activeProvider, t = translate('en')) {
29
+ if (!session)
30
+ return null;
31
+ const owner = session.provider || 'deepseek';
32
+ if (owner === activeProvider)
33
+ return null;
34
+ return t('sessions.provider_mismatch', {
35
+ owner,
36
+ active: activeProvider,
37
+ });
38
+ }
@@ -43,7 +43,13 @@ export function parseSendEvents(raw) {
43
43
  export function summarizeSends(events, now = Date.now()) {
44
44
  const within = (ms) => events.filter((e) => {
45
45
  const t = Date.parse(e.at);
46
- return Number.isFinite(t) && now - t <= ms;
46
+ // The event must be in the PAST: a future stamp (clock skew on the
47
+ // machine that wrote the log, a hand-edited line) satisfies
48
+ // `now - t <= ms` for EVERY window and inflated all three counters.
49
+ if (!Number.isFinite(t))
50
+ return false;
51
+ const age = now - t;
52
+ return age >= 0 && age <= ms;
47
53
  }).length;
48
54
  // `blockMs` may arrive as a NUMBER (the writer) or a string (an older
49
55
  // build, a hand-edited file): a numeric string is accepted so the average
@@ -62,9 +68,15 @@ export function summarizeSends(events, now = Date.now()) {
62
68
  avgBlockMs,
63
69
  };
64
70
  }
65
- /** `~/.zames/logs/sends-<provider>.jsonl`. */
71
+ /** `~/.zames/logs/sends-<provider>.jsonl`. The provider id comes from the
72
+ * CONFIG (a hand-edited file, `/provider`), so it must not be able to escape
73
+ * the log directory: a separator or `..` used to build a path OUTSIDE
74
+ * `~/.zames/logs` (a subdirectory at best, another file at worst — and the
75
+ * rotation DELETES files it finds there). Everything outside a plain file-name
76
+ * alphabet is replaced with `_`. */
66
77
  export function sendLogPath(logsDir, provider) {
67
- return path.join(logsDir, 'sends-' + provider + '.jsonl');
78
+ const safe = String(provider || 'unknown').replace(/[^A-Za-z0-9._-]/g, '_');
79
+ return path.join(logsDir, 'sends-' + safe + '.jsonl');
68
80
  }
69
81
  /** Keep at most `keep` sends-*.jsonl files (rotate the oldest away). */
70
82
  export function rotateSendLogs(logsDir, keep = 5) {
@@ -87,7 +87,9 @@ export function applySendEvent(state, event, cfg, now = Date.now()) {
87
87
  next.blockedAt = state.blocked ? state.blockedAt || stamp : stamp;
88
88
  next.okStreak = 0;
89
89
  next.forcedWaitMs =
90
- typeof event.retryAfterMs === 'number' && event.retryAfterMs > 0
90
+ typeof event.retryAfterMs === 'number' &&
91
+ Number.isFinite(event.retryAfterMs) &&
92
+ event.retryAfterMs > 0
91
93
  ? event.retryAfterMs
92
94
  : cfg.rateLimitWaitMs;
93
95
  next.forcedWaitAt = now;
@@ -0,0 +1,68 @@
1
+ import { translate } from './i18n.js';
2
+ /** Render `/status` as an array of lines (already localized). */
3
+ export function formatStatus(s, t = translate('en')) {
4
+ const yes = t('common.yes');
5
+ const no = t('common.no');
6
+ const none = t('common.none');
7
+ const onoff = (b) => (b ? t('common.on') : t('common.off'));
8
+ const lines = [];
9
+ lines.push(t('status.workdir', { v: s.workdir }));
10
+ lines.push(t('status.review_mode', { v: s.reviewMode || t('status.review_none') }));
11
+ if (s.reviewMode && s.origDir) {
12
+ lines.push(t('status.orig_dir', { v: s.origDir }));
13
+ }
14
+ lines.push(t('status.chat', { v: s.chatId || none }));
15
+ lines.push(t('status.fresh_next', { v: s.freshNext ? yes : no }));
16
+ lines.push(t('status.prompt_next', { v: s.promptNext ? yes : no }));
17
+ lines.push(t('status.last_chat', { v: s.lastChat || none }));
18
+ lines.push(t('status.sessions', { v: s.sessionsDir }));
19
+ lines.push(t('status.dev', { v: s.devMode ? t('common.on') : t('common.off') }));
20
+ lines.push(t('status.max_iter', { v: s.maxIterations }));
21
+ lines.push(t('status.headless', { v: s.headless ? yes : no }));
22
+ lines.push(t('status.debug', { v: s.debug ? yes : no }));
23
+ lines.push(t('status.toggles', {
24
+ think: s.deepThinking ? yes : no,
25
+ search: s.webSearch ? yes : no,
26
+ }));
27
+ lines.push(t('status.undo', { v: onoff(s.undo) }));
28
+ lines.push(t('status.checkpoint', { v: onoff(s.checkpoint) }));
29
+ lines.push(t('status.transcript', { v: s.transcriptFile || t('common.off') }));
30
+ lines.push(t('status.goal', { v: s.goal || none }));
31
+ lines.push(t('status.locale', { v: s.localeName }));
32
+ lines.push(t('status.mcp', { v: s.mcpToolCount === null ? none : s.mcpToolCount }));
33
+ lines.push(t('status.tokens', {
34
+ v: s.tokenUsage === null ? t('common.unknown') : s.tokenUsage,
35
+ }));
36
+ return lines;
37
+ }
38
+ /**
39
+ * The `/doctor` rows that depend on a LIVE PROBE rather than on the config:
40
+ * git availability + branch, clipboard, MCP, the auth marker and the UI health
41
+ * result. `renderDoctor` (commands.ts) renders the full table from these.
42
+ */
43
+ export function doctorProbeSummary(p) {
44
+ const rows = [];
45
+ rows.push({
46
+ status: p.gitOk ? 'ok' : 'warn',
47
+ label: p.gitOk ? 'git: ' + (p.gitBranch || 'detached') : 'git: absent',
48
+ });
49
+ rows.push({
50
+ status: p.authSaved ? 'ok' : 'warn',
51
+ label: p.authSaved ? 'auth: saved' : 'auth: not saved',
52
+ });
53
+ rows.push({
54
+ status: p.mcpTools > 0 ? 'ok' : 'warn',
55
+ label: 'mcp: ' +
56
+ String(p.mcpServers) +
57
+ ' servers / ' +
58
+ String(p.mcpTools) +
59
+ ' tools',
60
+ });
61
+ if (p.uiMissingCritical && p.uiMissingCritical.length) {
62
+ rows.push({
63
+ status: 'warn',
64
+ label: 'ui: missing ' + p.uiMissingCritical.join(', '),
65
+ });
66
+ }
67
+ return rows;
68
+ }
package/dist/subagent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { loadHooks, runLifecycleHooks } from './hooks.js';
2
2
  import { markInternalChat } from './sessions.js';
3
- // Subagent runner (BACKLOG N36). A `Task` tool call from the model is handled
3
+ // Subagent runner. A `Task` tool call from the model is handled
4
4
  // by the agent loop as a SEAM (like onAutoCompact): it calls back into the
5
5
  // caller, which owns the whole lifecycle. This module builds that callback.
6
6
  //
@@ -101,17 +101,10 @@ export function createSubagentRunner(opts) {
101
101
  // parent chat id) returned BEFORE anything ran, and charging those
102
102
  // attempts burned the whole budget on runs that never started.
103
103
  used++;
104
- // The model is told to fill `description` (a short label for the log/UI).
105
- // Without logging it every subagent turn in the transcript was anonymous,
106
- // so a long run could not be attributed to a sub-task.
107
- try {
108
- transcript?.log('subagent_start', {
109
- type: req.type,
110
- description: req.description || '',
111
- promptChars: String(req.prompt || '').length,
112
- });
113
- }
114
- catch { }
104
+ // NOTE: `subagent_start` is NOT logged here. agent-loop already writes it
105
+ // for the `Task` seam (with the description and the prompt), so logging it
106
+ // here too put a DUPLICATE event for the same run into the transcript —
107
+ // /export rendered every delegation twice. The seam owns the event.
115
108
  const subTools = buildTools(req.type === 'explore');
116
109
  const taskText = subagentPersona(req.type) + '\n\n--- SUB-TASK ---\n' + req.prompt;
117
110
  // Save the parent's send hooks: the nested loop OVERWRITES them on
@@ -101,5 +101,12 @@ export function looksLikeToolError(result) {
101
101
  head.startsWith('subagent failed') ||
102
102
  head.startsWith('subagent error') ||
103
103
  head.includes('process killed') ||
104
- head.startsWith('(aborted by the operator)'));
104
+ head.startsWith('(aborted by the operator)') ||
105
+ // A SANDBOX refusal: `safePath` throws
106
+ // `Access outside the working directory is forbidden: …` and the shell
107
+ // guard throws `Sandbox: …` (eval+cd, an unresolvable cd target, chroot).
108
+ // Both mean the call did NOTHING, yet neither prefix matched and the
109
+ // operator saw a green check next to a refused call.
110
+ head.includes('outside the working directory is forbidden') ||
111
+ head.includes('sandbox:'));
105
112
  }
package/dist/tools.js CHANGED
@@ -70,6 +70,15 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
70
70
  if (v === undefined || v === null) {
71
71
  throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
72
72
  }
73
+ // The contract says `path: string`/`command: string`; nothing used to
74
+ // enforce it, so an OBJECT became "[object Object]" and the file tools
75
+ // CREATED a file with that literal name (the same family as the old
76
+ // `undefined` file). A garbled tool call is exactly this shape.
77
+ if (typeof v === 'object' || typeof v === 'function') {
78
+ throw new Error(`Invalid required argument ${name}: expected a string, got ` +
79
+ JSON.stringify(v) +
80
+ '. Re-send the call with a plain string value.');
81
+ }
73
82
  const s = String(v);
74
83
  if (s === '' || s === 'undefined' || s === 'null') {
75
84
  throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
@@ -417,7 +426,10 @@ export function filterToolsForReadOnly(tools) {
417
426
  export function filterToolsByAllowList(tools, allowed) {
418
427
  if (!allowed || !allowed.length)
419
428
  return tools;
420
- const allow = new Set(allowed.map((n) => n.toLowerCase()));
429
+ // `allowed-tools:` is human-written: an entry may carry surrounding spaces
430
+ // (a quoted value, a trailing comma). Lower-casing alone left `read `, which
431
+ // matched nothing and silently reduced the skill to `respond` only.
432
+ const allow = new Set(allowed.map((n) => String(n).trim().toLowerCase()).filter(Boolean));
421
433
  allow.add('respond');
422
434
  return tools.filter((t) => allow.has(t.name.toLowerCase()));
423
435
  }
@@ -27,7 +27,7 @@ function stateLabel(state, locale) {
27
27
  * status). Measured with the shared `visLen` so wide chars (CJK/emoji) count
28
28
  * as 2 and the emoji's text-presentation selector (\uFE0E) as 0 — a plain
29
29
  * `.length` counted an emoji as 1 and a CJK badge was clipped on a narrow
30
- * terminal (BACKLOG T0.7). Exported so the width arithmetic is unit-tested
30
+ * terminal. Exported so the width arithmetic is unit-tested
31
31
  * without rendering. */
32
32
  export function badgeTextWidth(state) {
33
33
  let w = 0;
@@ -20,6 +20,26 @@ function visWidth(s) {
20
20
  i++;
21
21
  i++;
22
22
  }
23
+ else if (s[i] === ']') {
24
+ // OSC sequence: `ESC ] ... (BEL | ESC \)`. OSC-8 hyperlinks
25
+ // (produced by renderMarkdown when the terminal supports them) wrap
26
+ // the LABEL in such a sequence, and the OLD parser did not skip it:
27
+ // every URL character counted as a visible column, so the viewport
28
+ // believed a short link line filled many rows and the log jumped.
29
+ i++;
30
+ while (i < s.length) {
31
+ const c = s.charCodeAt(i);
32
+ if (c === 7) {
33
+ i++;
34
+ break;
35
+ }
36
+ if (c === 27 && s[i + 1] === String.fromCharCode(92)) {
37
+ i += 2;
38
+ break;
39
+ }
40
+ i++;
41
+ }
42
+ }
23
43
  continue;
24
44
  }
25
45
  const cp = s.codePointAt(i);
@@ -73,21 +93,38 @@ export function windowByRows(entries, height, scroll, cols) {
73
93
  let skip = Math.max(0, Math.floor(scroll));
74
94
  const picked = [];
75
95
  let used = 0;
96
+ // The pane above the panel is flexGrow:1 and overflow="hidden" with
97
+ // justifyContent="flex-end", so only the TAIL of a too-tall entry stays
98
+ // visible. `used` must therefore count the VISIBLE rows of the entry being
99
+ // kept, not its full height: counting the full height while scrolling
100
+ // INSIDE a tall entry exceeded the reserved height and pushed the status /
101
+ // input rows off a short terminal (the caret then drifted under the box).
76
102
  for (let i = entries.length - 1; i >= 0; i--) {
77
103
  const r = entryRows(entries[i], cols);
104
+ let visibleRows = r;
78
105
  if (skip > 0) {
79
106
  if (skip >= r) {
80
107
  skip -= r;
81
108
  continue;
82
109
  }
83
- // Partially scrolled into this entry: keep the hidden part accounted.
110
+ // Partially scrolled into this entry: the rows above the fold are
111
+ // hidden and must not be counted against the viewport.
112
+ visibleRows = r - skip;
84
113
  skip = 0;
85
114
  }
86
- if (used + r > cap && picked.length > 0)
115
+ // The pane is flexGrow:1 + overflow="hidden" + flex-end, so a tall entry
116
+ // is CLIPPED to its tail and never grows the window. A clipped entry is
117
+ // therefore always kept — dropping it blanked the pane while the operator
118
+ // scrolled inside a long answer (the old code required a free row for it).
119
+ const clipped = r > cap || visibleRows < r;
120
+ if (!clipped && used + visibleRows > cap)
87
121
  break;
88
122
  picked.push(entries[i]);
89
- used += r;
90
- if (used >= cap)
123
+ used += visibleRows;
124
+ // Only an EXACT fill stops the walk. A clipped entry already fills the
125
+ // viewport visually but leaves room in the budget for the shorter entries
126
+ // above it, which used to be dropped right after a tall one.
127
+ if (!clipped && used >= cap)
91
128
  break;
92
129
  }
93
130
  picked.reverse();
package/dist/undo.js CHANGED
@@ -17,7 +17,17 @@ export class UndoStore {
17
17
  }
18
18
  async _readIndex() {
19
19
  try {
20
- return JSON.parse(await fs.readFile(INDEX, 'utf-8'));
20
+ const data = JSON.parse(await fs.readFile(INDEX, 'utf-8'));
21
+ // The index is a JSON FILE: a crash, a hand edit or an old format can
22
+ // leave VALID JSON that is not an array (`{}`, `null`, `"x"`, `42`),
23
+ // or an array with junk entries. Returning it as-is made /undo throw
24
+ // `history.pop is not a function` and /undo-list throw
25
+ // `history.slice is not a function` out of the command dispatcher.
26
+ if (!Array.isArray(data))
27
+ return [];
28
+ return data.filter((r) => !!r &&
29
+ typeof r === 'object' &&
30
+ typeof r.originalPath === 'string');
21
31
  }
22
32
  catch {
23
33
  return [];
package/dist/web.js CHANGED
@@ -654,22 +654,32 @@ export function parseDuckDuckGoResults(body, limit) {
654
654
  // single regex made the snippet group OPTIONAL with an unbounded gap, so a
655
655
  // result without a snippet picked up the FOLLOWING result's snippet (wrong
656
656
  // text under a URL). A row scan ties every snippet to its own result.
657
- const anchorRe = /<a[^>]*class="[^"]*result__(a|snippet)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
657
+ // A result anchor is `<a … class="… result__a|result__snippet …" …>`. The
658
+ // attribute VALUES may be single-quoted (HTML allows both, and a page served
659
+ // through a proxy does), so each quote style is accepted rather than only
660
+ // `class="…"`. Rejecting them made a valid page parse to zero results and
661
+ // WebSearch answer "the output format may have changed".
662
+ const anchorRe = /<a[^>]*class=(?:"([^"]*)"|'([^']*)')[^>]*>([\s\S]*?)<\/a>/gi;
658
663
  const hrefOf = (tag) => {
659
- const m = tag.match(/href="([^"]*)"/i);
660
- return m ? m[1] : '';
664
+ const m = tag.match(/href=(?:"([^"]*)"|'([^']*)')/i);
665
+ return m ? (m[1] ?? m[2] ?? '') : '';
661
666
  };
662
667
  let cur = null;
663
668
  let m;
664
669
  while ((m = anchorRe.exec(body))) {
665
- const kind = m[1].toLowerCase();
670
+ const classes = m[1] ?? m[2] ?? '';
671
+ const kindMatch = /result__(a|snippet)/i.exec(classes);
672
+ if (!kindMatch)
673
+ continue;
674
+ const kind = kindMatch[1].toLowerCase();
675
+ const text = m[3] || '';
666
676
  if (kind === 'a') {
667
677
  if (cur && cur.title && /^https?:\/\//i.test(cur.url)) {
668
678
  results.push(cur);
669
679
  if (results.length >= limit)
670
680
  return results;
671
681
  }
672
- cur = { title: htmlToText(m[2] || '').trim(), url: '', snippet: '' };
682
+ cur = { title: htmlToText(text).trim(), url: '', snippet: '' };
673
683
  // The href lives in the OPENING tag, which m[0] starts with.
674
684
  let href = hrefOf(m[0].slice(0, m[0].indexOf('>') + 1));
675
685
  const uddg = href.match(/[?&]uddg=([^&]+)/);
@@ -685,7 +695,7 @@ export function parseDuckDuckGoResults(body, limit) {
685
695
  continue;
686
696
  }
687
697
  if (cur && !cur.snippet)
688
- cur.snippet = htmlToText(m[2] || '').trim();
698
+ cur.snippet = htmlToText(text).trim();
689
699
  }
690
700
  if (cur && cur.title && /^https?:\/\//i.test(cur.url))
691
701
  results.push(cur);