tensorgrid-ui 1.4.1 → 1.10.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.
package/lib/reviewers.js CHANGED
@@ -44,26 +44,136 @@ const STDERR_CAP = 64 * 1024
44
44
  * список устарел бы при первом же выпуске нового имени модели.
45
45
  */
46
46
  export const REVIEWERS = {
47
+ // ── Наши ревизоры ────────────────────────────────────────────────────
48
+ //
49
+ // Работают не отдельными продуктами, а нашими подагентами на нашем же
50
+ // маршруте моделей. Их ДВА, и это не прихоть: два разных СЕМЕЙСТВА
51
+ // моделей и есть то разнообразие, ради которого раньше звали чужое.
52
+ //
53
+ // Чистый опыт показал, что обвязка почти ничего не решает: одна модель в
54
+ // Claude Code и у нас дала совпадение 13 находок из 14. Разное находят
55
+ // разные МОДЕЛИ, а не разные обвязки. А обе подписки — Anthropic и
56
+ // OpenAI — доступны прямо здесь, со своими списками моделей и без
57
+ // поштучной оплаты.
58
+ //
59
+ // Список моделей пуст намеренно: он приходит живым из маршрута. Внешние
60
+ // продукты понимают только свои имена, а здесь доступно всё подключённое,
61
+ // включая сотни моделей через OpenRouter.
62
+ internal: {
63
+ label: 'Наш ревизор',
64
+ kind: 'internal',
65
+ models: [],
66
+ efforts: ['low', 'medium', 'high', 'max'],
67
+ defaultProvider: 'anthropic',
68
+ defaultModel: 'claude-opus-5',
69
+ },
70
+ 'internal-alt': {
71
+ label: 'Наш ревизор, вторая модель',
72
+ kind: 'internal',
73
+ models: [],
74
+ efforts: ['low', 'medium', 'high', 'max'],
75
+ // Намеренно ДРУГОЕ семейство: два прогона одной модели дадут почти
76
+ // одинаковые находки, и второй ревизор не окупит своего времени.
77
+ defaultProvider: 'openai-codex',
78
+ defaultModel: 'gpt-6-astra',
79
+ },
80
+
81
+ // ── Внешние продукты ─────────────────────────────────────────────────
82
+ //
83
+ // Оставлены, но НЕ требуются: в умолчания не входят, в установку не
84
+ // включены, и без них всё работает.
85
+ //
86
+ // Причина не в том, что они плохи, а в том, что не добавляют. По деньгам
87
+ // — подписка работает и у нас. По находкам — совпадение 13 из 14. А
88
+ // стоят они двух лишних установок сотруднику и почти всех дефектов,
89
+ // которые пришлось чинить: аргументы, поток ввода, забор ответа,
90
+ // обёртки Windows, ключи авторизации, отмена, тайм-аут.
91
+ //
92
+ // Кому они уже установлены — пусть включает и пользуется.
47
93
  'claude-code': {
48
94
  label: 'Claude Code',
95
+ kind: 'external',
96
+ optional: true,
49
97
  command: 'claude',
98
+ // Список — подсказка, а НЕ ограничение: интерфейс даёт ввести и своё имя.
99
+ // Жёсткий перечень устарел бы при первом же выпуске новой модели, а
100
+ // выяснить у продукта его собственный список нечем.
50
101
  models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
51
102
  efforts: ['low', 'medium', 'high'],
52
- defaultModel: null,
53
- defaultEffort: null,
103
+ // Имена, которыми продукт авторизуется. См. `authEnvFor` ниже.
104
+ authEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_BASE_URL'],
54
105
  },
55
106
  codex: {
56
107
  label: 'Codex',
108
+ kind: 'external',
109
+ optional: true,
57
110
  command: 'codex',
58
111
  models: ['gpt-6-astra', 'gpt-5.6-sol'],
59
112
  efforts: ['low', 'medium', 'high', 'xhigh'],
60
- defaultModel: null,
61
- defaultEffort: null,
113
+ authEnv: ['OPENAI_API_KEY', 'CODEX_HOME'],
62
114
  },
63
115
  }
64
116
 
65
- /** Значения уходят в аргументы команды — пропускаем только безобидное. */
117
+ /**
118
+ * ЕДИНСТВЕННЫЙ источник списка ревизоров.
119
+ *
120
+ * Список жил в трёх файлах, и ревизор указал, чем это кончится: добавление
121
+ * продукта в один файл из трёх делает запись выбора неопределённой и роняет
122
+ * весь раздел настроек. Я тогда починил половину и отчитался за целое —
123
+ * следующий обзор это и поймал.
124
+ */
125
+ export const KNOWN = Object.keys(REVIEWERS)
126
+
127
+ /**
128
+ * Что допустимо в модели и усилии.
129
+ *
130
+ * Строгий вариант — для ВНЕШНИХ ревизоров: значение уходит в аргументы
131
+ * командной строки, и косая черта там ни в одном имени не нужна, а вот
132
+ * ведущий дефис превратил бы значение в ключ команды.
133
+ *
134
+ * Для НАШЕГО ревизора черта обязательна: маршрут называет модели вида
135
+ * `anthropic/claude-opus-5`, а через OpenRouter — `x-ai/grok-4.6`. Туда
136
+ * значение уходит не в командную строку, а в опции вызова, поэтому
137
+ * послабление безопасно. Одна общая проверка отвергала бы половину
138
+ * подключённых моделей — что и обнаружилось при первой же сверке.
139
+ */
66
140
  const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
141
+ const SAFE_ROUTE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._\-/]{0,95}$/
142
+
143
+ /**
144
+ * Что разрешено НАШЕМУ ревизору.
145
+ *
146
+ * Список РАЗРЕШЕНИЙ, а не запретов, и это принципиально. Запрет пришлось бы
147
+ * дополнять при каждом новом инструменте харнесса: забыли один раз — и
148
+ * ревизор снова умеет писать. Разрешение ошибается в безопасную сторону:
149
+ * незнакомый инструмент по умолчанию недоступен.
150
+ *
151
+ * Те же три возможности, что у Claude Code: читать файл, искать по именам,
152
+ * искать по содержимому. Больше обзору не нужно ничего.
153
+ */
154
+ const REVIEW_TOOLS = { allow: ['Read', 'Glob', 'Grep'] }
155
+
156
+ /**
157
+ * Вернуть ревизору его собственные ключи авторизации.
158
+ *
159
+ * Служба запуска вычищает из окружения ребёнка всё, что похоже на секрет —
160
+ * `KEY`, `TOKEN`, `SECRET`, `PASSWORD`. Для чужого процесса это правильно:
161
+ * наши ключи ему незачем. Но ревизор авторизуется как раз такими именами, и
162
+ * у пользователя, который входит ключом, а не подпиской, обзор молча не
163
+ * работал бы — при том что та же команда из консоли отвечает.
164
+ *
165
+ * Поэтому возвращаем ТОЧЕЧНО: только имена этого продукта и только если они
166
+ * заданы. Ничего чужого, никаких шаблонов.
167
+ */
168
+ function authEnvFor(kind) {
169
+ const names = REVIEWERS[kind]?.authEnv ?? []
170
+ const env = {}
171
+ for (const name of names) {
172
+ const value = process.env[name]
173
+ if (typeof value === 'string' && value !== '') env[name] = value
174
+ }
175
+ return env
176
+ }
67
177
 
68
178
  /**
69
179
  * Собрать аргументы запуска.
@@ -114,22 +224,99 @@ export function buildArgs(kind, { model, effort, answerFile }) {
114
224
  throw new Error(`неизвестный ревизор: ${kind}`)
115
225
  }
116
226
 
227
+ /**
228
+ * Позвать нашего собственного ревизора — подагента на нашем маршруте.
229
+ *
230
+ * Отличается от внешних принципиально: не отдельный продукт, а агент в этом
231
+ * же процессе. Отсюда и возможности, которых нет у остальных: любая модель
232
+ * из подключённых, включая сотни через OpenRouter, и усилие рассуждения из
233
+ * тех же настроек, что у основного агента.
234
+ *
235
+ * Рабочую папку он берёт у родителя сам — она и есть папка чата, что
236
+ * совпадает с ограничением доступа для внешних.
237
+ */
238
+ async function runInternal(ctx, kind, { prompt, provider, model, effort, signal }) {
239
+ const subagents = ctx.get === undefined ? ctx.subagents : ctx.get('subagents')
240
+ if (subagents === undefined) {
241
+ return { kind, failed: 'реестр подагентов не смонтирован', answer: null }
242
+ }
243
+ if (typeof subagents.list === 'function' && !subagents.list().includes('spawn')) {
244
+ return { kind, failed: 'провайдер spawn недоступен', answer: null }
245
+ }
246
+
247
+ const agentOptions = {}
248
+ // Провайдер и модель приходят ПОРОЗНЬ и порознь же передаются.
249
+ //
250
+ // Соблазнительно вывести провайдера из имени модели по первой косой
251
+ // черте — и это неверно. У OpenRouter `x-ai/grok-4.6` целиком является
252
+ // именем модели, а провайдер называется `openrouter`. Деление по черте
253
+ // дало бы несуществующего провайдера `x-ai`, и маршрут отказал бы.
254
+ if (provider) agentOptions.provider = String(provider)
255
+ if (model) agentOptions.model = String(model)
256
+ if (effort) agentOptions.reasoningEffort = String(effort)
257
+
258
+ let run = null
259
+ try {
260
+ run = await subagents.start('spawn', {
261
+ label: 'обзор: наш ревизор',
262
+ prompt: [{ type: 'text', text: prompt }],
263
+ parent: ctx.reviewParent,
264
+ signal,
265
+ // ТОЛЬКО ЧТЕНИЕ — так же, как у внешних ревизоров.
266
+ //
267
+ // Без этого ревизор наследовал бы весь набор инструментов родителя,
268
+ // включая запись файлов и запуск команд. Ровно та дыра, которую оба
269
+ // внешних ревизора назвали критичной у Claude Code: я убрал её там и
270
+ // немедленно завёл здесь, просто не передав ограничения.
271
+ //
272
+ // Асимметрия недопустима: обзор, который «ничего не меняет», не должен
273
+ // иметь права изменить всё — тем более что ревизор по заданию читает
274
+ // чужой непроверенный код и указание внутри этого кода исполнилось бы
275
+ // без подтверждения.
276
+ toolFilter: REVIEW_TOOLS,
277
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
278
+ })
279
+ const result = await run.result
280
+ const answer = (result?.output ?? [])
281
+ .filter((block) => block?.type === 'text')
282
+ .map((block) => String(block.text ?? ''))
283
+ .join('')
284
+
285
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
286
+ if (answer.trim() === '') return { kind, failed: 'пустой ответ', answer: null }
287
+ return { kind, failed: null, cancelled: false, truncated: false, answer, model: model ?? null, effort: effort ?? null }
288
+ } catch (error) {
289
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
290
+ return { kind, failed: String(error?.message ?? error), answer: null }
291
+ } finally {
292
+ if (run !== null) {
293
+ try {
294
+ await run.dispose()
295
+ } catch {
296
+ // Уборка не должна подменять результат обзора.
297
+ }
298
+ }
299
+ }
300
+ }
301
+
117
302
  /**
118
303
  * Запустить одного ревизора и вернуть его ответ.
119
304
  *
120
305
  * Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
121
306
  * целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
122
307
  *
123
- * @param ctx - контекст со службой `subprocess`.
308
+ * @param ctx - контекст со службой `subprocess`; для нашего ревизора ещё и
309
+ * `subagents`, а в `reviewParent` — агент, от имени которого идёт вызов.
124
310
  * @param kind - имя ревизора.
125
311
  * @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
126
312
  */
127
- export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
313
+ export async function runReviewer(ctx, kind, { prompt, cwd, provider, model, effort, signal, timeoutMs }) {
128
314
  const spec = REVIEWERS[kind]
129
315
  if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
130
316
 
131
- for (const [label, value] of [['модель', model], ['усилие', effort]]) {
132
- if (value !== undefined && value !== null && value !== '' && !SAFE_VALUE.test(String(value))) {
317
+ const allowed = spec.kind === 'internal' ? SAFE_ROUTE_VALUE : SAFE_VALUE
318
+ for (const [label, value] of [['провайдер', provider], ['модель', model], ['усилие', effort]]) {
319
+ if (value !== undefined && value !== null && value !== '' && !allowed.test(String(value))) {
133
320
  return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
134
321
  }
135
322
  }
@@ -144,8 +331,15 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
144
331
  return { kind, failed: 'отменено', cancelled: true, answer: null }
145
332
  }
146
333
 
334
+ // Наш ревизор — не отдельный продукт, а подагент на нашем маршруте.
335
+ // Ни команды, ни аргументов, ни временных файлов ему не нужно.
336
+ if (spec.kind === 'internal') {
337
+ return runInternal(ctx, kind, { prompt, provider, model, effort, signal })
338
+ }
339
+
147
340
  let workDir = null
148
341
  let handle = null
342
+ let truncated = false
149
343
  // Своя отмена поверх пользовательской: ревизор без ограничения по времени
150
344
  // держал бы обзор бесконечно.
151
345
  const deadline = new AbortController()
@@ -175,6 +369,9 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
175
369
  },
176
370
  graceMs: 5000,
177
371
  signal: deadline.signal,
372
+ // Точечный возврат собственных ключей продукта: служба вычищает всё
373
+ // похожее на секрет, а ревизор именно ими и авторизуется.
374
+ env: authEnvFor(kind),
178
375
  })
179
376
 
180
377
  const outcome = await handle.done
@@ -221,14 +418,24 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
221
418
  return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
222
419
  }
223
420
  } else {
224
- answer = (await handle.collected.stdout.readFrom(0)).text
421
+ // Признак потери читается, а не игнорируется.
422
+ //
423
+ // Собранный вывод хранит только ХВОСТ потока: длинный ответ обрезается
424
+ // с НАЧАЛА, а список ревизор начинает с самого опасного. Молча отдать
425
+ // такой обрывок как полный обзор — значит потерять именно то, ради
426
+ // чего обзор затевался.
427
+ const read = await handle.collected.stdout.readFrom(0)
428
+ answer = read.text
429
+ if (read.lossy === true) {
430
+ truncated = true
431
+ }
225
432
  }
226
433
 
227
434
  if (answer.trim() === '') {
228
435
  return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
229
436
  }
230
437
 
231
- return { kind, failed: null, cancelled: false, answer, model: model ?? null, effort: effort ?? null }
438
+ return { kind, failed: null, cancelled: false, truncated, answer, model: model ?? null, effort: effort ?? null }
232
439
  } catch (error) {
233
440
  if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
234
441
  const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
@@ -239,12 +446,23 @@ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signa
239
446
  if (handle !== null) {
240
447
  try {
241
448
  handle.terminate()
242
- // Ждём, пока процесс действительно умрёт, прежде чем убирать за ним.
243
- // Без ожидания удаление шло при живом процессе, у которого есть
244
- // отпущенное время на завершение и открытый файл итога: на Windows
245
- // это даёт отказ, `force` его проглатывает, и временные каталоги
246
- // копятся после каждой отмены.
247
- await handle.waitForExit()
449
+ // Ждём смерти процесса, но НЕ бесконечно.
450
+ //
451
+ // Ждать нужно: без ожидания уборка идёт при живом процессе с открытым
452
+ // файлом итога, на Windows это отказ, `force` его проглатывает, и
453
+ // временные каталоги копятся после каждой отмены.
454
+ //
455
+ // Ограничивать тоже нужно: `waitForExit` ждёт опустошения всего
456
+ // управляемого диапазона, а не только самой команды. Потомок,
457
+ // который не умирает, держал бы вызов после тайм-аута — в
458
+ // неотменяемом `finally`, где «стоп» уже не поможет.
459
+ const cleanup = new AbortController()
460
+ const cleanupTimer = setTimeout(() => cleanup.abort(), 10000)
461
+ try {
462
+ await handle.waitForExit(cleanup.signal)
463
+ } finally {
464
+ clearTimeout(cleanupTimer)
465
+ }
248
466
  } catch {
249
467
  // Уборка не должна подменять результат.
250
468
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.4.1",
3
+ "version": "1.10.0",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -16,8 +16,18 @@
16
16
  },
17
17
  "./package.json": "./package.json"
18
18
  },
19
+ "bin": {
20
+ "tensorgrid-ui": "lib/cli.mjs"
21
+ },
19
22
  "files": [
20
- "lib"
23
+ "lib",
24
+ "install.ps1",
25
+ "preset",
26
+ "profile",
27
+ "brand/tensorgrid.ico",
28
+ "tools/round-ico-corners.mjs",
29
+ "env/snapshot.json",
30
+ "LICENSE"
21
31
  ],
22
32
  "peerDependencies": {
23
33
  "@deepseek-ai/dsh-tools": "*"