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/client.js +326 -28
- package/lib/http-runtime.js +87 -4
- package/lib/index.js +17 -1
- package/lib/review-log.js +277 -0
- package/lib/review-settings.js +137 -11
- package/lib/review.js +299 -46
- package/lib/reviewers.js +242 -24
- package/package.json +1 -1
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
|
-
|
|
51
|
+
// Список — подсказка, а НЕ ограничение: интерфейс даёт ввести и своё имя.
|
|
52
|
+
// Жёсткий перечень устарел бы при первом же выпуске новой модели, а
|
|
53
|
+
// выяснить у продукта его собственный список нечем.
|
|
54
|
+
models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
|
|
51
55
|
efforts: ['low', 'medium', 'high'],
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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