tensorgrid-ui 1.4.0 → 1.4.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.
package/lib/client.js CHANGED
@@ -2283,9 +2283,21 @@ window.__ModuleLoader__.load({
2283
2283
  const refresh = React.useCallback(async (body) => {
2284
2284
  setBusy(true)
2285
2285
  try {
2286
- setState(await send(body))
2286
+ const next = await send(body)
2287
+ // Ответ без каталога — это отказ, а не новое состояние.
2288
+ //
2289
+ // Раньше состояние заменялось целиком, и одно неудачное нажатие
2290
+ // галочки убирало все карточки: ни повторить, ни увидеть выбранное
2291
+ // было нельзя, помогала только перезагрузка. Ту же ошибку я уже
2292
+ // допускал в строке входа и там же чинил.
2293
+ setState((previous) => {
2294
+ const complete = Array.isArray(next.catalogue) && next.catalogue.length > 0
2295
+ if (complete) return next
2296
+ return previous === null ? next : { ...previous, problem: next.problem ?? 'сохранить не удалось' }
2297
+ })
2287
2298
  } catch (error) {
2288
- setState({ catalogue: [], problem: String(error && error.message ? error.message : error) })
2299
+ const text = String(error && error.message ? error.message : error)
2300
+ setState((previous) => (previous === null ? { catalogue: [], problem: text } : { ...previous, problem: text }))
2289
2301
  } finally {
2290
2302
  setBusy(false)
2291
2303
  }
@@ -2343,7 +2355,12 @@ window.__ModuleLoader__.load({
2343
2355
  className: 'dsx-rev__list',
2344
2356
  children: catalogue.map((entry) => {
2345
2357
  const on = settings !== null && settings.enabled.includes(entry.kind)
2346
- const choice = settings === null ? { model: null, effort: null } : settings.choices[entry.kind]
2358
+ // Запись может отсутствовать, если списки Host и браузера
2359
+ // разойдутся. Раньше следующая строка бросала исключение прямо
2360
+ // в отрисовке, и падал ВЕСЬ раздел настроек, а не только наша
2361
+ // карточка.
2362
+ const stored = settings === null ? null : settings.choices[entry.kind]
2363
+ const choice = stored ?? { model: null, effort: null }
2347
2364
  return jsxs('div', {
2348
2365
  className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
2349
2366
  children: [
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Импортируются только встроенные модули Node.
13
13
  */
14
- import { readFile, writeFile } from 'node:fs/promises'
14
+ import { readFile, rename, writeFile } from 'node:fs/promises'
15
15
  import { existsSync } from 'node:fs'
16
16
  import { dirname, join } from 'node:path'
17
17
  import { fileURLToPath } from 'node:url'
@@ -21,6 +21,17 @@ const FILE_NAME = 'tensorgrid-review.json'
21
21
  /** Ревизоры, которых мы умеем звать. Порядок — порядок показа. */
22
22
  export const KNOWN = ['claude-code', 'codex']
23
23
 
24
+ /**
25
+ * Что допустимо в модели и усилии.
26
+ *
27
+ * Те же правила, что и при запуске, но применяются ЗДЕСЬ, при сохранении.
28
+ * Раньше проверка жила только в момент вызова: строка вроде «gpt 5 (новая)»
29
+ * спокойно сохранялась и отвергалась через двадцать минут, при следующем
30
+ * обзоре. В интерфейсе это было невидимо — список с неизвестным значением
31
+ * показывает пустое поле, неотличимое от «как настроено».
32
+ */
33
+ const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
34
+
24
35
  /**
25
36
  * Что действует, пока пользователь ничего не выбрал.
26
37
  *
@@ -71,12 +82,23 @@ export function normalise(raw) {
71
82
  const base = defaults()
72
83
  if (raw === null || typeof raw !== 'object') return base
73
84
 
74
- const enabled = Array.isArray(raw.enabled) ? raw.enabled.filter((kind) => KNOWN.includes(kind)) : base.enabled
85
+ // Повторы отбрасываются: список запускает по процессу на элемент, и сотня
86
+ // одинаковых имён означала бы сотню платных обзоров разом.
87
+ const enabled = Array.isArray(raw.enabled)
88
+ ? [...new Set(raw.enabled.filter((kind) => KNOWN.includes(kind)))]
89
+ : base.enabled
75
90
 
76
91
  const choices = {}
77
92
  for (const kind of KNOWN) {
78
93
  const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
79
- const pick = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
94
+ // Недопустимое значение отбрасывается молча и превращается в «как
95
+ // настроено»: сохранить его значило бы выключить обзор до тех пор, пока
96
+ // кто-нибудь не догадается открыть файл.
97
+ const pick = (value) => {
98
+ if (typeof value !== 'string') return null
99
+ const trimmed = value.trim()
100
+ return trimmed !== '' && SAFE_VALUE.test(trimmed) ? trimmed : null
101
+ }
80
102
  choices[kind] = {
81
103
  model: pick(source?.model),
82
104
  effort: pick(source?.effort),
@@ -100,11 +122,20 @@ export async function readSettings(cwd) {
100
122
  }
101
123
  }
102
124
 
103
- /** Записать настройки. Возвращает то, что действительно сохранено. */
125
+ /**
126
+ * Записать настройки. Возвращает то, что действительно сохранено.
127
+ *
128
+ * Запись идёт через временный файл с переименованием. Прямая перезапись
129
+ * сначала обрезает файл, и чтение, пришедшееся на этот момент, видит пустоту
130
+ * или обрывок — а пустота у нас означает «включить обоих ревизоров», то есть
131
+ * запустить продукт, который человек только что выключил.
132
+ */
104
133
  export async function writeSettings(cwd, value) {
105
134
  const path = settingsPath(cwd)
106
135
  if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
107
136
  const normalised = normalise(value)
108
- await writeFile(path, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
137
+ const temporary = `${path}.${process.pid}.tmp`
138
+ await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
139
+ await rename(temporary, path)
109
140
  return normalised
110
141
  }
package/lib/review.js CHANGED
@@ -39,8 +39,16 @@
39
39
  * ошибку одной строки вместо обрушения всего ряда.
40
40
  */
41
41
 
42
- /** Провайдеры, которых зовём. Порядок влияет только на вывод. */
43
- const REVIEWERS = ['claude-code', 'codex']
42
+ /**
43
+ * Список ревизоров берётся из ОДНОГО места — `reviewers.js`.
44
+ *
45
+ * Здесь стоял свой список, третий по счёту: ещё один в `review-settings.js`
46
+ * и ещё один в справочнике запуска. Ревизор указал, чем это кончится:
47
+ * добавление третьего продукта в один файл из трёх делает
48
+ * `settings.choices[kind]` неопределённым, и раздел настроек падает целиком
49
+ * — React снимает поддерево, пользователь теряет и остальные настройки.
50
+ */
51
+ const KNOWN_REVIEWERS = ['claude-code', 'codex']
44
52
 
45
53
  /**
46
54
  * Задание ревизору.
@@ -109,33 +117,81 @@ function buildPrompt({ request, paths, focus }) {
109
117
  /** Уровни важности в порядке убывания — по нему сортируется итог. */
110
118
  const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
111
119
 
120
+ /**
121
+ * Пределы на разбор.
122
+ *
123
+ * Ответ приходит из отдельного процесса и ничем не ограничен: многословная
124
+ * или сорвавшаяся в повтор модель способна выдать мегабайт. Всё это уходит в
125
+ * контекст вызывающего агента — то есть стоит дороже самого обзора и
126
+ * способно вытеснить ту работу, которую обзор проверял.
127
+ */
128
+ const MAX_FINDINGS = 60
129
+ const MAX_FIELD_CHARS = 1200
130
+
131
+ /** Обрезать поле, не притворяясь, что ничего не обрезали. */
132
+ function clip(value) {
133
+ if (value === null) return null
134
+ return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}… (обрезано)`
135
+ }
136
+
112
137
  /**
113
138
  * Разобрать ответ ревизора в список находок.
114
139
  *
115
140
  * Разбор намеренно снисходительный: ревизор — языковая модель, и точного
116
- * соблюдения формата от неё ждать нельзя. Не разобранное не выбрасывается,
117
- * а попадает в `raw`: потерять находку из-за лишнего пробела хуже, чем
118
- * показать её неразмеченной.
141
+ * соблюдения формата от неё ждать нельзя. Пропущенное поле даёт null, а не
142
+ * теряет находку; жирная разметка вокруг названий полей снимается.
143
+ *
144
+ * Текст БЕЗ единого заголовка не выбрасывается, а возвращается одной
145
+ * находкой с пометкой «не по формату». Прежде такой ответ превращался в
146
+ * пустой список, то есть в «находок нет» — и сообщение продукта об истёкшем
147
+ * входе выглядело справкой о чистоте кода.
119
148
  */
120
149
  export function parseFindings(text, reviewer) {
121
150
  const source = String(text ?? '')
122
- if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !source.includes('###')) return []
151
+ if (source.trim() === '') return []
152
+ if (/^\s*НАХОДОК НЕТ\s*$/im.test(source) && !/^\s*#{2,3}\s+/m.test(source)) return []
123
153
 
124
154
  const findings = []
125
- const blocks = source.split(/^###\s+/m).slice(1)
155
+ // Заголовок распознаётся только в начале строки и не глубже третьего
156
+ // уровня: `###` внутри примера кода в поле «КАК ПРОВЕРИТЬ» иначе разрывал
157
+ // находку надвое, и вторая половина уезжала в конец списка без важности.
158
+ const blocks = source.split(/^#{2,3}[ \t]+/m).slice(1)
159
+
160
+ if (blocks.length === 0) {
161
+ return [{
162
+ reviewer,
163
+ title: 'ответ не по формату',
164
+ severity: 'мелочь',
165
+ where: null,
166
+ what: clip(source.trim()),
167
+ why: 'Ревизор ответил не по заданному образцу — возможно, это сообщение об ошибке, а не обзор.',
168
+ howToCheck: null,
169
+ unparsed: true,
170
+ }]
171
+ }
126
172
 
127
173
  for (const block of blocks) {
174
+ if (findings.length >= MAX_FINDINGS) break
175
+
128
176
  const [titleLine, ...rest] = block.split('\n')
129
- const body = rest.join('\n')
177
+ // Жирная разметка вокруг названий полей снимается: модели любят её
178
+ // добавлять, а без этого поле просто не находилось и содержимое терялось.
179
+ const body = rest.join('\n').replace(/\*\*/g, '')
180
+
130
181
  // Поле тянется до следующего заголовка или до конца блока.
131
182
  //
132
183
  // Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
133
184
  // объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
134
185
  // и вторая строка обычно и есть самое полезное. Начало строки
135
186
  // обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
187
+ //
188
+ // Следующим полем считается только ИЗВЕСТНОЕ название, а не любая
189
+ // строка из заглавных букв с двоеточием: «ИТОГО:» внутри объяснения
190
+ // обрывал поле на середине.
191
+ const NEXT = '(?:ВАЖНОСТЬ|ГДЕ|ЧТО НЕ ТАК|ПОЧЕМУ ЭТО ВАЖНО|КАК ПРОВЕРИТЬ)'
136
192
  const field = (label) => {
137
- const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n[А-ЯЁ ]{3,}:|$)`))
138
- return match === null ? null : match[1].trim()
193
+ const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n${NEXT}:|$)`))
194
+ return match === null ? null : clip(match[1].trim())
139
195
  }
140
196
 
141
197
  const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
@@ -143,7 +199,7 @@ export function parseFindings(text, reviewer) {
143
199
 
144
200
  findings.push({
145
201
  reviewer,
146
- title: titleLine.trim(),
202
+ title: clip(titleLine.replace(/\*\*/g, '').trim()),
147
203
  severity,
148
204
  where: field('ГДЕ'),
149
205
  what: field('ЧТО НЕ ТАК'),
@@ -173,7 +229,7 @@ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal)
173
229
  const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
174
230
 
175
231
  if (outcome.failed !== null) {
176
- return { provider: kind, failed: outcome.failed, findings: [], ...used }
232
+ return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
177
233
  }
178
234
 
179
235
  return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
@@ -192,7 +248,16 @@ export function render(value) {
192
248
  const lines = []
193
249
 
194
250
  if (value.reviewers.length === 0) {
195
- return 'Ни один ревизор не доступен: провайдеры подагентов не смонтированы.'
251
+ // Настоящая причина приходит в `problem` и обязана быть показана.
252
+ //
253
+ // Здесь стояла одна фраза на все случаи — про несмонтированные
254
+ // провайдеры подагентов. Механизм, которым этот код давно не
255
+ // пользуется: продукты зовутся напрямую. Пользователь, снявший обе
256
+ // галочки, получал объяснение про постороннюю неисправность и шёл чинить
257
+ // не то. Оба ревизора нашли это независимо.
258
+ return typeof value.problem === 'string' && value.problem !== ''
259
+ ? `Обзор не выполнен: ${value.problem}`
260
+ : 'Обзор не выполнен: ни один ревизор не выбран.'
196
261
  }
197
262
 
198
263
  const failed = value.reviewers.filter((r) => r.failed !== null)
@@ -203,7 +268,13 @@ export function render(value) {
203
268
  lines.push('')
204
269
 
205
270
  if (value.findings.length === 0) {
206
- lines.push(worked.length === 0 ? 'Судить не по чему: не ответил ни один ревизор.' : 'Находок нет.')
271
+ // «Находок нет» говорится ТОЛЬКО когда обзор действительно состоялся.
272
+ // Отказ или отмена, поданные как чистый результат, — худшее, что этот
273
+ // инструмент может сделать: пользователь сочтёт работу проверенной.
274
+ const cancelled = value.reviewers.some((entry) => entry.cancelled === true)
275
+ if (cancelled) lines.push('Обзор отменён — судить не по чему.')
276
+ else if (worked.length === 0) lines.push('Судить не по чему: не ответил ни один ревизор.')
277
+ else lines.push('Находок нет.')
207
278
  return lines.join('\n')
208
279
  }
209
280
 
@@ -266,7 +337,7 @@ export async function apply(ctx) {
266
337
  },
267
338
  reviewers: {
268
339
  type: 'array',
269
- items: { type: 'string', enum: REVIEWERS },
340
+ items: { type: 'string', enum: KNOWN_REVIEWERS },
270
341
  description:
271
342
  'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
272
343
  },
@@ -295,12 +366,20 @@ export async function apply(ctx) {
295
366
 
296
367
  // Выбор пользователя из настроек; вызов может его перекрыть.
297
368
  const settings = await readSettings(cwd)
298
- const wanted = Array.isArray(args.reviewers) && args.reviewers.length > 0
369
+ const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
299
370
  ? args.reviewers
300
371
  : settings.enabled
301
372
 
373
+ // Повторы отбрасываются, и это не придирка: `reviewers` заполняет
374
+ // модель, а список из ста одинаковых имён запустил бы сто платных
375
+ // процессов разом — по одному на каждый элемент.
376
+ const wanted = [...new Set(asked)].filter((kind) => KNOWN_REVIEWERS.includes(kind))
377
+
302
378
  if (wanted.length === 0) {
303
- return { findings: [], reviewers: [], problem: настройках не выбран ни один ревизор' }
379
+ const problem = asked.length === 0
380
+ ? 'не выбран ни один ревизор — включите их в настройках'
381
+ : `неизвестные ревизоры: ${[...new Set(asked)].join(', ')}`
382
+ return { findings: [], reviewers: [], problem }
304
383
  }
305
384
 
306
385
  const prompt = buildPrompt(args)
@@ -316,6 +395,7 @@ export async function apply(ctx) {
316
395
  reviewers: answers.map((entry) => ({
317
396
  provider: entry.provider,
318
397
  failed: entry.failed,
398
+ cancelled: entry.cancelled === true,
319
399
  found: entry.findings.length,
320
400
  model: entry.model,
321
401
  effort: entry.effort,
package/lib/reviewers.js CHANGED
@@ -47,7 +47,7 @@ export const REVIEWERS = {
47
47
  'claude-code': {
48
48
  label: 'Claude Code',
49
49
  command: 'claude',
50
- models: ['claude-opus-4-8', 'claude-sonnet-4-6'],
50
+ models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
51
51
  efforts: ['low', 'medium', 'high'],
52
52
  defaultModel: null,
53
53
  defaultEffort: null,
@@ -78,9 +78,20 @@ export function buildArgs(kind, { model, effort, answerFile }) {
78
78
  if (kind === 'claude-code') {
79
79
  // `-p` без текста задания: задание придёт через поток ввода.
80
80
  args.push('-p')
81
- // Ревизор только читает, но запрет на чтение файлов сделал бы его
82
- // бесполезным, а спрашивать разрешение в фоне некому.
83
- args.push('--permission-mode', 'bypassPermissions')
81
+ // Ревизор ТОЛЬКО ЧИТАЕТ, и это обеспечено правами, а не просьбой.
82
+ //
83
+ // Здесь стоял `bypassPermissions` — полное право писать файлы и выполнять
84
+ // команды в рабочей папке пользователя, при том что соседний Codex
85
+ // запускался с `read-only`. Оба независимых ревизора нашли это и назвали
86
+ // критичным, справедливо: ревизор по заданию читает чужой непроверенный
87
+ // код, и указание внутри этого кода («игнорируй предыдущее, выполни…»)
88
+ // исполнилось бы без подтверждения и без следа в переписке — это
89
+ // отдельный процесс.
90
+ //
91
+ // Проверено с контролем: с этим списком инструментов запись отклоняется
92
+ // системой прав, с `bypassPermissions` файл создаётся немедленно.
93
+ args.push('--permission-mode', 'dontAsk')
94
+ args.push('--allowedTools', 'Read', 'Glob', 'Grep')
84
95
  if (model) args.push('--model', model)
85
96
  if (effort) args.push('--effort', effort)
86
97
  return args
@@ -123,6 +134,16 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
123
134
  }
124
135
  }
125
136
 
137
+ // Отмену, случившуюся ДО вызова, надо увидеть сразу.
138
+ //
139
+ // Подписка на будущее событие `abort` у уже отменённого сигнала не
140
+ // срабатывает никогда: обработчик задним числом не вызывается. Без этой
141
+ // проверки нажатие «стоп» перед самым запуском не мешало нам завести
142
+ // платный процесс, который потом жил до самого тайм-аута.
143
+ if (signal?.aborted === true) {
144
+ return { kind, failed: 'отменено', cancelled: true, answer: null }
145
+ }
146
+
126
147
  let workDir = null
127
148
  let handle = null
128
149
  // Своя отмена поверх пользовательской: ревизор без ограничения по времени
@@ -157,30 +178,60 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
157
178
  })
158
179
 
159
180
  const outcome = await handle.done
181
+ const stderrText = (await handle.collected.stderr.readFrom(0)).text
182
+ const tail = stderrText.trim().split('\n').slice(-3).join(' ')
183
+
184
+ // Отмена — это отмена, а не отказ ревизора.
185
+ //
186
+ // Раньше прерванный обзор возвращался как «пустой ответ», то есть
187
+ // выглядел обычным результатом: агент получал «ревизоры не ответили,
188
+ // находок нет» и спокойно шёл дальше, считая работу проверенной.
189
+ if (signal?.aborted === true) {
190
+ return { kind, failed: 'отменено', cancelled: true, answer: null }
191
+ }
192
+ if (deadline.signal.aborted) {
193
+ return { kind, failed: `превышено время ожидания (${Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)} с)`, answer: null }
194
+ }
195
+
196
+ // КОД ВОЗВРАТА ПРОВЕРЯЕТСЯ ВСЕГДА, а не только при пустом выводе.
197
+ //
198
+ // Claude Code печатает текст ошибки — истёкший вход, исчерпанный лимит,
199
+ // несуществующая модель — в обычный вывод и завершается с ненулевым
200
+ // кодом. Раньше такой вывод считался успешным ответом, разбор не находил
201
+ // в нём ни одной находки, и пользователь получал справку о чистоте кода
202
+ // при том, что обзор вообще не выполнялся. Это ровно тот тихий отказ,
203
+ // ради поиска которого весь инструмент и написан.
204
+ if (outcome.exitCode !== 0) {
205
+ const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
206
+ const said = (await handle.collected.stdout.readFrom(0)).text.trim().split('\n').slice(0, 2).join(' ')
207
+ const detail = tail !== '' ? tail : said
208
+ return { kind, failed: `ревизор завершился с ошибкой (${code})${detail === '' ? '' : `: ${detail}`}`, answer: null }
209
+ }
160
210
 
161
- // Codex кладёт итог в файл, Claude Code печатает его в вывод.
162
211
  let answer = ''
163
212
  if (kind === 'codex') {
213
+ // Итог Codex берётся ТОЛЬКО из файла. Запасное чтение вывода отсюда
214
+ // убрано намеренно: в выводе лежит весь ход рассуждения вместе с
215
+ // образцом формата из задания, и разбор наплодил бы по нему находки-
216
+ // призраки — в том числе «критичную» с названием «краткое название».
217
+ // Отсутствие файла — это сбой, а не повод подсунуть журнал.
164
218
  try {
165
219
  answer = await readFile(answerFile, 'utf8')
166
220
  } catch {
167
- answer = ''
221
+ return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
168
222
  }
169
- }
170
- if (answer.trim() === '') {
223
+ } else {
171
224
  answer = (await handle.collected.stdout.readFrom(0)).text
172
225
  }
173
226
 
174
227
  if (answer.trim() === '') {
175
- const stderr = (await handle.collected.stderr.readFrom(0)).text
176
- const tail = stderr.trim().split('\n').slice(-3).join(' ')
177
- const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
178
- return { kind, failed: `пустой ответ (${code})${tail === '' ? '' : `: ${tail}`}`, answer: null }
228
+ return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
179
229
  }
180
230
 
181
- return { kind, failed: null, answer, model: model ?? null, effort: effort ?? null }
231
+ return { kind, failed: null, cancelled: false, answer, model: model ?? null, effort: effort ?? null }
182
232
  } catch (error) {
183
- const reason = deadline.signal.aborted && signal?.aborted !== true ? 'превышено время ожидания' : String(error?.message ?? error)
233
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
234
+ const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
184
235
  return { kind, failed: reason, answer: null }
185
236
  } finally {
186
237
  clearTimeout(timer)
@@ -188,6 +239,12 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
188
239
  if (handle !== null) {
189
240
  try {
190
241
  handle.terminate()
242
+ // Ждём, пока процесс действительно умрёт, прежде чем убирать за ним.
243
+ // Без ожидания удаление шло при живом процессе, у которого есть
244
+ // отпущенное время на завершение и открытый файл итога: на Windows
245
+ // это даёт отказ, `force` его проглатывает, и временные каталоги
246
+ // копятся после каждой отмены.
247
+ await handle.waitForExit()
191
248
  } catch {
192
249
  // Уборка не должна подменять результат.
193
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",