tensorgrid-ui 1.4.0 → 1.6.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/reviewers.js CHANGED
@@ -46,24 +46,89 @@ const STDERR_CAP = 64 * 1024
46
46
  export const REVIEWERS = {
47
47
  'claude-code': {
48
48
  label: 'Claude Code',
49
+ kind: 'external',
49
50
  command: 'claude',
50
- models: ['claude-opus-4-8', 'claude-sonnet-4-6'],
51
+ // Список — подсказка, а НЕ ограничение: интерфейс даёт ввести и своё имя.
52
+ // Жёсткий перечень устарел бы при первом же выпуске новой модели, а
53
+ // выяснить у продукта его собственный список нечем.
54
+ models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
51
55
  efforts: ['low', 'medium', 'high'],
52
- defaultModel: null,
53
- defaultEffort: null,
56
+ // Имена, которыми продукт авторизуется. См. `authEnvFor` ниже.
57
+ authEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
54
58
  },
55
59
  codex: {
56
60
  label: 'Codex',
61
+ kind: 'external',
57
62
  command: 'codex',
58
63
  models: ['gpt-6-astra', 'gpt-5.6-sol'],
59
64
  efforts: ['low', 'medium', 'high', 'xhigh'],
60
- defaultModel: null,
61
- defaultEffort: null,
65
+ authEnv: ['OPENAI_API_KEY', 'CODEX_HOME'],
66
+ },
67
+ internal: {
68
+ label: 'Наш ревизор',
69
+ // Единственный, кто работает НЕ отдельным продуктом, а нашим подагентом
70
+ // на нашем же маршруте моделей.
71
+ //
72
+ // Он появился из опыта: та же модель в другой обвязке нашла почти
73
+ // непересекающийся набор находок. Значит третий угол зрения даёт не
74
+ // только другая модель, но и другое окружение.
75
+ //
76
+ // И он единственный, чей список моделей берётся ЖИВЫМ из маршрута:
77
+ // Claude Code и Codex понимают только свои имена, а здесь доступно всё,
78
+ // что подключено — включая сотни моделей через OpenRouter.
79
+ kind: 'internal',
80
+ models: [],
81
+ efforts: ['low', 'medium', 'high', 'max'],
62
82
  },
63
83
  }
64
84
 
65
- /** Значения уходят в аргументы команды — пропускаем только безобидное. */
85
+ /**
86
+ * ЕДИНСТВЕННЫЙ источник списка ревизоров.
87
+ *
88
+ * Список жил в трёх файлах, и ревизор указал, чем это кончится: добавление
89
+ * продукта в один файл из трёх делает запись выбора неопределённой и роняет
90
+ * весь раздел настроек. Я тогда починил половину и отчитался за целое —
91
+ * следующий обзор это и поймал.
92
+ */
93
+ export const KNOWN = Object.keys(REVIEWERS)
94
+
95
+ /**
96
+ * Что допустимо в модели и усилии.
97
+ *
98
+ * Строгий вариант — для ВНЕШНИХ ревизоров: значение уходит в аргументы
99
+ * командной строки, и косая черта там ни в одном имени не нужна, а вот
100
+ * ведущий дефис превратил бы значение в ключ команды.
101
+ *
102
+ * Для НАШЕГО ревизора черта обязательна: маршрут называет модели вида
103
+ * `anthropic/claude-opus-5`, а через OpenRouter — `x-ai/grok-4.6`. Туда
104
+ * значение уходит не в командную строку, а в опции вызова, поэтому
105
+ * послабление безопасно. Одна общая проверка отвергала бы половину
106
+ * подключённых моделей — что и обнаружилось при первой же сверке.
107
+ */
66
108
  const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
109
+ const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
110
+
111
+ /**
112
+ * Вернуть ревизору его собственные ключи авторизации.
113
+ *
114
+ * Служба запуска вычищает из окружения ребёнка всё, что похоже на секрет —
115
+ * `KEY`, `TOKEN`, `SECRET`, `PASSWORD`. Для чужого процесса это правильно:
116
+ * наши ключи ему незачем. Но ревизор авторизуется как раз такими именами, и
117
+ * у пользователя, который входит ключом, а не подпиской, обзор молча не
118
+ * работал бы — при том что та же команда из консоли отвечает.
119
+ *
120
+ * Поэтому возвращаем ТОЧЕЧНО: только имена этого продукта и только если они
121
+ * заданы. Ничего чужого, никаких шаблонов.
122
+ */
123
+ function authEnvFor(kind) {
124
+ const names = REVIEWERS[kind]?.authEnv ?? []
125
+ const env = {}
126
+ for (const name of names) {
127
+ const value = process.env[name]
128
+ if (typeof value === 'string' && value !== '') env[name] = value
129
+ }
130
+ return env
131
+ }
67
132
 
68
133
  /**
69
134
  * Собрать аргументы запуска.
@@ -78,9 +143,20 @@ export function buildArgs(kind, { model, effort, answerFile }) {
78
143
  if (kind === 'claude-code') {
79
144
  // `-p` без текста задания: задание придёт через поток ввода.
80
145
  args.push('-p')
81
- // Ревизор только читает, но запрет на чтение файлов сделал бы его
82
- // бесполезным, а спрашивать разрешение в фоне некому.
83
- args.push('--permission-mode', 'bypassPermissions')
146
+ // Ревизор ТОЛЬКО ЧИТАЕТ, и это обеспечено правами, а не просьбой.
147
+ //
148
+ // Здесь стоял `bypassPermissions` — полное право писать файлы и выполнять
149
+ // команды в рабочей папке пользователя, при том что соседний Codex
150
+ // запускался с `read-only`. Оба независимых ревизора нашли это и назвали
151
+ // критичным, справедливо: ревизор по заданию читает чужой непроверенный
152
+ // код, и указание внутри этого кода («игнорируй предыдущее, выполни…»)
153
+ // исполнилось бы без подтверждения и без следа в переписке — это
154
+ // отдельный процесс.
155
+ //
156
+ // Проверено с контролем: с этим списком инструментов запись отклоняется
157
+ // системой прав, с `bypassPermissions` файл создаётся немедленно.
158
+ args.push('--permission-mode', 'dontAsk')
159
+ args.push('--allowedTools', 'Read', 'Glob', 'Grep')
84
160
  if (model) args.push('--model', model)
85
161
  if (effort) args.push('--effort', effort)
86
162
  return args
@@ -103,28 +179,110 @@ export function buildArgs(kind, { model, effort, answerFile }) {
103
179
  throw new Error(`неизвестный ревизор: ${kind}`)
104
180
  }
105
181
 
182
+ /**
183
+ * Позвать нашего собственного ревизора — подагента на нашем маршруте.
184
+ *
185
+ * Отличается от внешних принципиально: не отдельный продукт, а агент в этом
186
+ * же процессе. Отсюда и возможности, которых нет у остальных: любая модель
187
+ * из подключённых, включая сотни через OpenRouter, и усилие рассуждения из
188
+ * тех же настроек, что у основного агента.
189
+ *
190
+ * Рабочую папку он берёт у родителя сам — она и есть папка чата, что
191
+ * совпадает с ограничением доступа для внешних.
192
+ */
193
+ async function runInternal(ctx, kind, { prompt, provider, model, effort, signal }) {
194
+ const subagents = ctx.get === undefined ? ctx.subagents : ctx.get('subagents')
195
+ if (subagents === undefined) {
196
+ return { kind, failed: 'реестр подагентов не смонтирован', answer: null }
197
+ }
198
+ if (typeof subagents.list === 'function' && !subagents.list().includes('spawn')) {
199
+ return { kind, failed: 'провайдер spawn недоступен', answer: null }
200
+ }
201
+
202
+ const agentOptions = {}
203
+ // Провайдер и модель приходят ПОРОЗНЬ и порознь же передаются.
204
+ //
205
+ // Соблазнительно вывести провайдера из имени модели по первой косой
206
+ // черте — и это неверно. У OpenRouter `x-ai/grok-4.6` целиком является
207
+ // именем модели, а провайдер называется `openrouter`. Деление по черте
208
+ // дало бы несуществующего провайдера `x-ai`, и маршрут отказал бы.
209
+ if (provider) agentOptions.provider = String(provider)
210
+ if (model) agentOptions.model = String(model)
211
+ if (effort) agentOptions.reasoningEffort = String(effort)
212
+
213
+ let run = null
214
+ try {
215
+ run = await subagents.start('spawn', {
216
+ label: 'обзор: наш ревизор',
217
+ prompt: [{ type: 'text', text: prompt }],
218
+ parent: ctx.reviewParent,
219
+ signal,
220
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
221
+ })
222
+ const result = await run.result
223
+ const answer = (result?.output ?? [])
224
+ .filter((block) => block?.type === 'text')
225
+ .map((block) => String(block.text ?? ''))
226
+ .join('')
227
+
228
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
229
+ if (answer.trim() === '') return { kind, failed: 'пустой ответ', answer: null }
230
+ return { kind, failed: null, cancelled: false, truncated: false, answer, model: model ?? null, effort: effort ?? null }
231
+ } catch (error) {
232
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
233
+ return { kind, failed: String(error?.message ?? error), answer: null }
234
+ } finally {
235
+ if (run !== null) {
236
+ try {
237
+ await run.dispose()
238
+ } catch {
239
+ // Уборка не должна подменять результат обзора.
240
+ }
241
+ }
242
+ }
243
+ }
244
+
106
245
  /**
107
246
  * Запустить одного ревизора и вернуть его ответ.
108
247
  *
109
248
  * Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
110
249
  * целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
111
250
  *
112
- * @param ctx - контекст со службой `subprocess`.
251
+ * @param ctx - контекст со службой `subprocess`; для нашего ревизора ещё и
252
+ * `subagents`, а в `reviewParent` — агент, от имени которого идёт вызов.
113
253
  * @param kind - имя ревизора.
114
254
  * @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
115
255
  */
116
- export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
256
+ export async function runReviewer(ctx, kind, { prompt, cwd, provider, model, effort, signal, timeoutMs }) {
117
257
  const spec = REVIEWERS[kind]
118
258
  if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
119
259
 
120
- for (const [label, value] of [['модель', model], ['усилие', effort]]) {
121
- if (value !== undefined && value !== null && value !== '' && !SAFE_VALUE.test(String(value))) {
260
+ const allowed = spec.kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
261
+ for (const [label, value] of [['провайдер', provider], ['модель', model], ['усилие', effort]]) {
262
+ if (value !== undefined && value !== null && value !== '' && !allowed.test(String(value))) {
122
263
  return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
123
264
  }
124
265
  }
125
266
 
267
+ // Отмену, случившуюся ДО вызова, надо увидеть сразу.
268
+ //
269
+ // Подписка на будущее событие `abort` у уже отменённого сигнала не
270
+ // срабатывает никогда: обработчик задним числом не вызывается. Без этой
271
+ // проверки нажатие «стоп» перед самым запуском не мешало нам завести
272
+ // платный процесс, который потом жил до самого тайм-аута.
273
+ if (signal?.aborted === true) {
274
+ return { kind, failed: 'отменено', cancelled: true, answer: null }
275
+ }
276
+
277
+ // Наш ревизор — не отдельный продукт, а подагент на нашем маршруте.
278
+ // Ни команды, ни аргументов, ни временных файлов ему не нужно.
279
+ if (spec.kind === 'internal') {
280
+ return runInternal(ctx, kind, { prompt, provider, model, effort, signal })
281
+ }
282
+
126
283
  let workDir = null
127
284
  let handle = null
285
+ let truncated = false
128
286
  // Своя отмена поверх пользовательской: ревизор без ограничения по времени
129
287
  // держал бы обзор бесконечно.
130
288
  const deadline = new AbortController()
@@ -154,33 +312,76 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
154
312
  },
155
313
  graceMs: 5000,
156
314
  signal: deadline.signal,
315
+ // Точечный возврат собственных ключей продукта: служба вычищает всё
316
+ // похожее на секрет, а ревизор именно ими и авторизуется.
317
+ env: authEnvFor(kind),
157
318
  })
158
319
 
159
320
  const outcome = await handle.done
321
+ const stderrText = (await handle.collected.stderr.readFrom(0)).text
322
+ const tail = stderrText.trim().split('\n').slice(-3).join(' ')
323
+
324
+ // Отмена — это отмена, а не отказ ревизора.
325
+ //
326
+ // Раньше прерванный обзор возвращался как «пустой ответ», то есть
327
+ // выглядел обычным результатом: агент получал «ревизоры не ответили,
328
+ // находок нет» и спокойно шёл дальше, считая работу проверенной.
329
+ if (signal?.aborted === true) {
330
+ return { kind, failed: 'отменено', cancelled: true, answer: null }
331
+ }
332
+ if (deadline.signal.aborted) {
333
+ return { kind, failed: `превышено время ожидания (${Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)} с)`, answer: null }
334
+ }
335
+
336
+ // КОД ВОЗВРАТА ПРОВЕРЯЕТСЯ ВСЕГДА, а не только при пустом выводе.
337
+ //
338
+ // Claude Code печатает текст ошибки — истёкший вход, исчерпанный лимит,
339
+ // несуществующая модель — в обычный вывод и завершается с ненулевым
340
+ // кодом. Раньше такой вывод считался успешным ответом, разбор не находил
341
+ // в нём ни одной находки, и пользователь получал справку о чистоте кода
342
+ // при том, что обзор вообще не выполнялся. Это ровно тот тихий отказ,
343
+ // ради поиска которого весь инструмент и написан.
344
+ if (outcome.exitCode !== 0) {
345
+ const code = outcome.exitCode === null ? `сигнал ${String(outcome.signal)}` : `код ${outcome.exitCode}`
346
+ const said = (await handle.collected.stdout.readFrom(0)).text.trim().split('\n').slice(0, 2).join(' ')
347
+ const detail = tail !== '' ? tail : said
348
+ return { kind, failed: `ревизор завершился с ошибкой (${code})${detail === '' ? '' : `: ${detail}`}`, answer: null }
349
+ }
160
350
 
161
- // Codex кладёт итог в файл, Claude Code печатает его в вывод.
162
351
  let answer = ''
163
352
  if (kind === 'codex') {
353
+ // Итог Codex берётся ТОЛЬКО из файла. Запасное чтение вывода отсюда
354
+ // убрано намеренно: в выводе лежит весь ход рассуждения вместе с
355
+ // образцом формата из задания, и разбор наплодил бы по нему находки-
356
+ // призраки — в том числе «критичную» с названием «краткое название».
357
+ // Отсутствие файла — это сбой, а не повод подсунуть журнал.
164
358
  try {
165
359
  answer = await readFile(answerFile, 'utf8')
166
360
  } catch {
167
- answer = ''
361
+ return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
362
+ }
363
+ } else {
364
+ // Признак потери читается, а не игнорируется.
365
+ //
366
+ // Собранный вывод хранит только ХВОСТ потока: длинный ответ обрезается
367
+ // с НАЧАЛА, а список ревизор начинает с самого опасного. Молча отдать
368
+ // такой обрывок как полный обзор — значит потерять именно то, ради
369
+ // чего обзор затевался.
370
+ const read = await handle.collected.stdout.readFrom(0)
371
+ answer = read.text
372
+ if (read.lossy === true) {
373
+ truncated = true
168
374
  }
169
- }
170
- if (answer.trim() === '') {
171
- answer = (await handle.collected.stdout.readFrom(0)).text
172
375
  }
173
376
 
174
377
  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 }
378
+ return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
179
379
  }
180
380
 
181
- return { kind, failed: null, answer, model: model ?? null, effort: effort ?? null }
381
+ return { kind, failed: null, cancelled: false, truncated, answer, model: model ?? null, effort: effort ?? null }
182
382
  } catch (error) {
183
- const reason = deadline.signal.aborted && signal?.aborted !== true ? 'превышено время ожидания' : String(error?.message ?? error)
383
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
384
+ const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
184
385
  return { kind, failed: reason, answer: null }
185
386
  } finally {
186
387
  clearTimeout(timer)
@@ -188,6 +389,23 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
188
389
  if (handle !== null) {
189
390
  try {
190
391
  handle.terminate()
392
+ // Ждём смерти процесса, но НЕ бесконечно.
393
+ //
394
+ // Ждать нужно: без ожидания уборка идёт при живом процессе с открытым
395
+ // файлом итога, на Windows это отказ, `force` его проглатывает, и
396
+ // временные каталоги копятся после каждой отмены.
397
+ //
398
+ // Ограничивать тоже нужно: `waitForExit` ждёт опустошения всего
399
+ // управляемого диапазона, а не только самой команды. Потомок,
400
+ // который не умирает, держал бы вызов после тайм-аута — в
401
+ // неотменяемом `finally`, где «стоп» уже не поможет.
402
+ const cleanup = new AbortController()
403
+ const cleanupTimer = setTimeout(() => cleanup.abort(), 10000)
404
+ try {
405
+ await handle.waitForExit(cleanup.signal)
406
+ } finally {
407
+ clearTimeout(cleanupTimer)
408
+ }
191
409
  } catch {
192
410
  // Уборка не должна подменять результат.
193
411
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.4.0",
3
+ "version": "1.6.1",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",