tensorgrid-ui 1.3.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
@@ -177,6 +177,14 @@ window.__ModuleLoader__.load({
177
177
  'intensity.vivid': 'Vivid',
178
178
  'accent.title': 'Obsidian / Ion accent',
179
179
  'accent.hint': 'Leading tone of the interface and the glow',
180
+ 'review.nav': 'Reviewers',
181
+ 'review.title': 'Second opinion',
182
+ 'review.hint': 'Independent reviewers examine finished work and report what is wrong. They run as separate products and do not see this conversation.',
183
+ 'review.model': 'Model',
184
+ 'review.effort': 'Effort',
185
+ 'review.asConfigured': 'as configured',
186
+ 'review.none': 'No reviewer is selected — a review will find nothing.',
187
+ 'review.note': 'Two reviewers are enabled by default because they find different things: asking one guarantees missing half. Leaving a model empty uses whatever that product is already set up with.',
180
188
  'auth.nav': 'Subscriptions',
181
189
  'auth.title': 'Provider sign-in',
182
190
  'auth.others': 'Only providers offering a subscription sign-in are listed. The rest — Z.AI among them — offer an API key only; configure those under Models.',
@@ -224,6 +232,14 @@ window.__ModuleLoader__.load({
224
232
  'intensity.vivid': '强烈',
225
233
  'accent.title': 'Obsidian / Ion 主色',
226
234
  'accent.hint': '界面与光晕的主色调',
235
+ 'review.nav': '审阅者',
236
+ 'review.title': '第二意见',
237
+ 'review.hint': '独立审阅者检查已完成的工作并报告问题。它们作为独立产品运行,看不到本次对话。',
238
+ 'review.model': '模型',
239
+ 'review.effort': '推理强度',
240
+ 'review.asConfigured': '按产品设置',
241
+ 'review.none': '未选择任何审阅者——审阅将一无所获。',
242
+ 'review.note': '默认启用两位审阅者,因为他们发现的问题不同:只用一位必然漏掉一半。模型留空则沿用该产品自身的设置。',
227
243
  'auth.nav': '订阅',
228
244
  'auth.title': '登录服务商',
229
245
  'auth.others': '这里只列出提供订阅登录的服务商。其余(包括 Z.AI)只提供 API 密钥,请在「模型」中配置。',
@@ -1568,6 +1584,14 @@ window.__ModuleLoader__.load({
1568
1584
  'intensity.vivid': 'Ярко',
1569
1585
  'accent.title': 'Акцент Obsidian / Ion',
1570
1586
  'accent.hint': 'Ведущий тон интерфейса и свечения',
1587
+ 'review.nav': 'Ревизоры',
1588
+ 'review.title': 'Второе мнение',
1589
+ 'review.hint': 'Независимые ревизоры смотрят готовую работу и сообщают, что в ней не так. Работают как отдельные продукты и нашей переписки не видят.',
1590
+ 'review.model': 'Модель',
1591
+ 'review.effort': 'Усилие',
1592
+ 'review.asConfigured': 'как настроено',
1593
+ 'review.none': 'Не выбран ни один ревизор — обзор ничего не найдёт.',
1594
+ 'review.note': 'По умолчанию включены оба: они находят разное, и один гарантированно упускает половину. Пустая модель означает «как настроено у самого продукта».',
1571
1595
  'auth.nav': 'Подписки',
1572
1596
  'auth.title': 'Вход к провайдерам',
1573
1597
  'auth.others': 'Здесь только провайдеры, у которых есть вход по подписке. Остальные — в том числе Z.AI — предлагают лишь ключ API; их место в разделе «Модели».',
@@ -1716,7 +1740,7 @@ window.__ModuleLoader__.load({
1716
1740
  // поэтому строка остаётся согласованной при любой палитре.
1717
1741
  '.dsx-setting{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;}',
1718
1742
  '.dsx-setting__title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;}',
1719
- '.dsx-subs{display:flex;flex-direction:column;gap:14px;padding:4px 0}.dsx-subs__head{display:flex;flex-direction:column;gap:4px}.dsx-subs__title{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary)}.dsx-subs__note{padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1743
+ '.dsx-rev__list{display:flex;flex-direction:column;gap:8px}.dsx-rev__card{padding:10px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);opacity:.6;transition:opacity .15s ease,border-color .15s ease}.dsx-rev__card--on{opacity:1;border-color:var(--dsw-alias-border-l2)}.dsx-rev__head{display:flex;align-items:center;gap:8px;cursor:pointer}.dsx-rev__name{font-weight:600;color:var(--dsw-alias-label-primary)}.dsx-rev__fields{display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;padding-left:22px}.dsx-rev__field{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-secondary);font-size:12px}.dsx-rev__select{padding:3px 6px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:12px}.dsx-subs{display:flex;flex-direction:column;gap:14px;padding:4px 0}.dsx-subs__head{display:flex;flex-direction:column;gap:4px}.dsx-subs__title{font-weight:600;font-size:15px;color:var(--dsw-alias-label-primary)}.dsx-subs__note{padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}.dsx-setting--stack{flex-direction:column;align-items:stretch;gap:10px}.dsx-auth__list{display:flex;flex-direction:column;gap:6px}.dsx-auth__row{display:flex;align-items:center;justify-content:space-between;gap:12px}.dsx-auth__name{color:var(--dsw-alias-label-primary)}.dsx-auth__badge{margin-left:8px;padding:1px 6px;border-radius:999px;font-size:11px;color:var(--dsw-alias-state-success-primary);border:1px solid var(--dsw-alias-border-l2)}.dsx-auth__panel{display:flex;flex-direction:column;gap:8px;padding:10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1)}.dsx-auth__notice,.dsx-auth__prompt{display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary)}.dsx-auth__link{color:var(--dsw-alias-brand-primary);word-break:break-all}.dsx-auth__code{font-family:ui-monospace,monospace;letter-spacing:.08em;color:var(--dsw-alias-label-primary)}.dsx-auth__input{flex:1;min-width:0;padding:4px 8px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary)}.dsx-setting__hint--strong{color:var(--dsw-alias-label-primary)}.dsx-setting__hint--warn{color:var(--dsw-alias-state-warn-primary)}.dsx-setting__hint{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:2px;}',
1720
1744
  '.dsx-setting__control{display:inline-flex;padding:2px;gap:2px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-2);flex:none;}',
1721
1745
  '.dsx-seg{appearance:none;border:0;cursor:pointer;padding:4px 10px;border-radius:6px;font-size:12px;line-height:18px;background:transparent;color:var(--dsw-alias-label-secondary);transition:background .16s ease,color .16s ease;}',
1722
1746
  '.dsx-seg:hover{color:var(--dsw-alias-label-primary);}',
@@ -2217,6 +2241,171 @@ window.__ModuleLoader__.load({
2217
2241
  })
2218
2242
  }
2219
2243
 
2244
+ // ── Ревизоры ─────────────────────────────────────────────────────────
2245
+ //
2246
+ // Раздел настроек, где выбирают, кто смотрит работу вторым мнением,
2247
+ // какой моделью и с каким усилием.
2248
+ //
2249
+ // Справочник моделей приходит с Host, а не зашит здесь: иначе список
2250
+ // расширят в одной половине и забудут в другой. Пустой выбор модели
2251
+ // означает «как настроено у самого продукта» — человек уже настроил
2252
+ // свой Claude Code и Codex, и навязывать поверх нечего.
2253
+ const REVIEW_PATH = '/api/tensorgrid.review'
2254
+
2255
+ function ReviewRow({ t }) {
2256
+ const [state, setState] = React.useState(null)
2257
+ const [busy, setBusy] = React.useState(false)
2258
+
2259
+ const translate = React.useRef(t)
2260
+ translate.current = t
2261
+
2262
+ const send = React.useCallback(async (body) => {
2263
+ const response = body === undefined
2264
+ ? await fetch(REVIEW_PATH)
2265
+ : await fetch(REVIEW_PATH, {
2266
+ method: 'POST',
2267
+ headers: { 'content-type': 'application/json' },
2268
+ body: JSON.stringify(body),
2269
+ })
2270
+ const type = response.headers.get('content-type') || ''
2271
+ if (!type.includes('application/json')) {
2272
+ const say = translate.current
2273
+ return {
2274
+ catalogue: [],
2275
+ problem: response.status === 401 || response.status === 404
2276
+ ? say('auth.needsRestart')
2277
+ : say('auth.badResponse').replace('{status}', String(response.status)),
2278
+ }
2279
+ }
2280
+ return response.json()
2281
+ }, [])
2282
+
2283
+ const refresh = React.useCallback(async (body) => {
2284
+ setBusy(true)
2285
+ try {
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
+ })
2298
+ } catch (error) {
2299
+ const text = String(error && error.message ? error.message : error)
2300
+ setState((previous) => (previous === null ? { catalogue: [], problem: text } : { ...previous, problem: text }))
2301
+ } finally {
2302
+ setBusy(false)
2303
+ }
2304
+ }, [send])
2305
+
2306
+ React.useEffect(() => { refresh() }, [refresh])
2307
+
2308
+ const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2309
+ const settings = state !== null && state.settings ? state.settings : null
2310
+
2311
+ // Изменение любого поля сразу сохраняется: отдельная кнопка «применить»
2312
+ // добавляет состояние «изменено, но не сохранено», в котором легко
2313
+ // забыть нажать и потом удивляться.
2314
+ const save = React.useCallback((next) => { refresh(next) }, [refresh])
2315
+
2316
+ const toggle = (kind) => {
2317
+ if (settings === null) return
2318
+ const on = settings.enabled.includes(kind)
2319
+ const enabled = on ? settings.enabled.filter((k) => k !== kind) : [...settings.enabled, kind]
2320
+ save({ ...settings, enabled })
2321
+ }
2322
+
2323
+ const pick = (kind, field, value) => {
2324
+ if (settings === null) return
2325
+ const choices = { ...settings.choices, [kind]: { ...settings.choices[kind], [field]: value } }
2326
+ save({ ...settings, choices })
2327
+ }
2328
+
2329
+ const selector = (kind, field, options, current) =>
2330
+ jsxs('select', {
2331
+ className: 'dsx-rev__select',
2332
+ value: current === null ? '' : current,
2333
+ disabled: busy,
2334
+ onChange: (event) => pick(kind, field, event.target.value === '' ? null : event.target.value),
2335
+ children: [
2336
+ jsx('option', { value: '', children: t('review.asConfigured') }, ''),
2337
+ ...options.map((option) => jsx('option', { value: option, children: option }, option)),
2338
+ ],
2339
+ })
2340
+
2341
+ return jsxs('div', {
2342
+ className: 'dsx-subs',
2343
+ children: [
2344
+ jsxs('div', {
2345
+ className: 'dsx-subs__head',
2346
+ children: [
2347
+ jsx('div', { className: 'dsx-subs__title', children: t('review.title') }),
2348
+ jsx('div', { className: 'dsx-setting__hint', children: t('review.hint') }),
2349
+ ],
2350
+ }),
2351
+ state !== null && state.problem
2352
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2353
+ : null,
2354
+ jsx('div', {
2355
+ className: 'dsx-rev__list',
2356
+ children: catalogue.map((entry) => {
2357
+ const on = settings !== null && settings.enabled.includes(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 }
2364
+ return jsxs('div', {
2365
+ className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
2366
+ children: [
2367
+ jsxs('label', {
2368
+ className: 'dsx-rev__head',
2369
+ children: [
2370
+ jsx('input', {
2371
+ type: 'checkbox',
2372
+ checked: on,
2373
+ disabled: busy,
2374
+ onChange: () => toggle(entry.kind),
2375
+ }),
2376
+ jsx('span', { className: 'dsx-rev__name', children: entry.label }),
2377
+ ],
2378
+ }),
2379
+ jsxs('div', {
2380
+ className: 'dsx-rev__fields',
2381
+ children: [
2382
+ jsxs('label', {
2383
+ className: 'dsx-rev__field',
2384
+ children: [
2385
+ jsx('span', { children: t('review.model') }),
2386
+ selector(entry.kind, 'model', entry.models, choice.model),
2387
+ ],
2388
+ }),
2389
+ jsxs('label', {
2390
+ className: 'dsx-rev__field',
2391
+ children: [
2392
+ jsx('span', { children: t('review.effort') }),
2393
+ selector(entry.kind, 'effort', entry.efforts, choice.effort),
2394
+ ],
2395
+ }),
2396
+ ],
2397
+ }),
2398
+ ],
2399
+ }, entry.kind)
2400
+ }),
2401
+ }),
2402
+ settings !== null && settings.enabled.length === 0
2403
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2404
+ : null,
2405
+ jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
2406
+ ],
2407
+ })
2408
+ }
2220
2409
  // ── Обновления ───────────────────────────────────────────────────────
2221
2410
  //
2222
2411
  // Маршрут поднимает host-половина пакета. Путь относительный, токен не
@@ -2444,6 +2633,21 @@ window.__ModuleLoader__.load({
2444
2633
  ),
2445
2634
  )
2446
2635
 
2636
+ // Ревизоры — соседний раздел: и подписки, и обзор про то, чем и как
2637
+ // работает агент, а не про внешний вид.
2638
+ ctx.slots.inject('settings.section', () =>
2639
+ ctx.slots.register(
2640
+ {
2641
+ name: 'settings.section',
2642
+ id: 'tensorgrid-review',
2643
+ order: 13,
2644
+ locale: LOCALE_NS,
2645
+ label: () => ctx.locale.translate(LOCALE_NS, 'review.nav'),
2646
+ },
2647
+ ReviewRow,
2648
+ ),
2649
+ )
2650
+
2447
2651
  ctx.slots.inject('settings.general.item', () =>
2448
2652
  ctx.slots.register(
2449
2653
  { name: 'settings.general.item', id: 'obsidian-ion-update', order: 11, locale: LOCALE_NS },
@@ -215,6 +215,60 @@ export async function handleUpdate(req, res, state, loadModule) {
215
215
  }
216
216
  }
217
217
 
218
+ /**
219
+ * Обслужить `/api/tensorgrid.review` — настройки обзора.
220
+ *
221
+ * GET отдаёт выбранное и справочник для показа: кого можно звать, какие
222
+ * модели и усилия у каждого. Справочник приходит с Host, а не зашит в
223
+ * браузер: иначе список расширят в одной половине и забудут в другой.
224
+ *
225
+ * POST сохраняет выбор. Форма проверяется на Host — файл правит и человек
226
+ * руками, и надеяться, что дошло только отправленное браузером, нельзя.
227
+ */
228
+ export async function handleReview(req, res, loadModule) {
229
+ try {
230
+ const settings = await loadModule('./review-settings.js')
231
+ const { REVIEWERS } = await loadModule('./reviewers.js')
232
+
233
+ const catalogue = Object.entries(REVIEWERS).map(([kind, spec]) => ({
234
+ kind,
235
+ label: spec.label,
236
+ models: spec.models,
237
+ efforts: spec.efforts,
238
+ }))
239
+
240
+ if (req.method === 'GET') {
241
+ reply(res, 200, { catalogue, settings: await settings.readSettings(null) })
242
+ return
243
+ }
244
+
245
+ if (req.method !== 'POST') {
246
+ reply(res, 405, { problem: 'поддерживаются GET и POST' })
247
+ return
248
+ }
249
+
250
+ if (!acceptsJson(req)) {
251
+ reply(res, 415, { problem: 'ожидается content-type: application/json' })
252
+ return
253
+ }
254
+
255
+ if (!sameOrigin(req)) {
256
+ reply(res, 403, { problem: 'запрос с чужой страницы' })
257
+ return
258
+ }
259
+
260
+ const body = await readJsonBody(req)
261
+ if (body === null) {
262
+ reply(res, 400, { problem: 'тело запроса не разобрано' })
263
+ return
264
+ }
265
+
266
+ reply(res, 200, { catalogue, settings: await settings.writeSettings(null, body) })
267
+ } catch (error) {
268
+ reply(res, 500, { problem: String(error?.message ?? error) })
269
+ }
270
+ }
271
+
218
272
  /** Обслужить `/api/tensorgrid.auth`. */
219
273
  export async function handleAuth(req, res, services, loadModule) {
220
274
  try {
package/lib/index.js CHANGED
@@ -240,6 +240,21 @@ export function apply(ctx) {
240
240
  }),
241
241
  )
242
242
 
243
+ web.effect(() =>
244
+ web.webServer.register({
245
+ kind: 'exact',
246
+ path: '/api/tensorgrid.review',
247
+ async handler(req, res) {
248
+ try {
249
+ const runtime = await loadModule('./http-runtime.js')
250
+ await runtime.handleReview(req, res, loadModule)
251
+ } catch (error) {
252
+ fail(res, error)
253
+ }
254
+ },
255
+ }),
256
+ )
257
+
243
258
  web.effect(() =>
244
259
  web.webServer.register({
245
260
  kind: 'exact',
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Настройки обзора: кого звать, какой моделью и с каким усилием.
3
+ *
4
+ * Хранятся файлом рядом с профилем, а не в браузере. Причина простая:
5
+ * выбирает человек в интерфейсе, а читает Host в момент вызова инструмента —
6
+ * значит хранилище должно быть общим для обеих сторон. Память браузера для
7
+ * этого не годится, Host её не видит.
8
+ *
9
+ * Файл лежит там же, где отметка об установке, и ищется тем же подъёмом
10
+ * вверх: так путь не зависит от того, насколько глубоко pnpm разложил пакет.
11
+ *
12
+ * Импортируются только встроенные модули Node.
13
+ */
14
+ import { readFile, rename, writeFile } from 'node:fs/promises'
15
+ import { existsSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ const FILE_NAME = 'tensorgrid-review.json'
20
+
21
+ /** Ревизоры, которых мы умеем звать. Порядок — порядок показа. */
22
+ export const KNOWN = ['claude-code', 'codex']
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
+
35
+ /**
36
+ * Что действует, пока пользователь ничего не выбрал.
37
+ *
38
+ * Оба ревизора включены намеренно: они находят РАЗНОЕ, и один по умолчанию
39
+ * означал бы, что половина находок не появится у того, кто в настройки не
40
+ * заглядывал. Модель и усилие пустые — берутся собственные настройки
41
+ * продукта, то есть то, чем человек уже пользуется.
42
+ */
43
+ export function defaults() {
44
+ return {
45
+ enabled: [...KNOWN],
46
+ choices: { 'claude-code': { model: null, effort: null }, codex: { model: null, effort: null } },
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Каталог профиля: поднимаемся, пока не найдём отметку об установке.
52
+ * @param startDir - откуда начинать подъём.
53
+ */
54
+ function findProfileRoot(startDir) {
55
+ let dir = startDir
56
+ for (let depth = 0; depth < 10; depth += 1) {
57
+ if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
58
+ const parent = dirname(dir)
59
+ if (parent === dir) break
60
+ dir = parent
61
+ }
62
+ return null
63
+ }
64
+
65
+ /**
66
+ * Где лежит файл настроек.
67
+ *
68
+ * Сначала ищем от собственного расположения — так работает установленный
69
+ * пакет. Если отметки нет (запуск из репозитория, проверка), кладём рядом с
70
+ * рабочей папкой, чтобы проверяемый код вёл себя как настоящий.
71
+ *
72
+ * @param cwd - рабочая папка сессии, запасной ориентир.
73
+ */
74
+ export function settingsPath(cwd) {
75
+ const here = dirname(fileURLToPath(import.meta.url))
76
+ const root = findProfileRoot(here) ?? (typeof cwd === 'string' && cwd !== '' ? findProfileRoot(cwd) : null)
77
+ return root === null ? null : join(root, FILE_NAME)
78
+ }
79
+
80
+ /** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
81
+ export function normalise(raw) {
82
+ const base = defaults()
83
+ if (raw === null || typeof raw !== 'object') return base
84
+
85
+ // Повторы отбрасываются: список запускает по процессу на элемент, и сотня
86
+ // одинаковых имён означала бы сотню платных обзоров разом.
87
+ const enabled = Array.isArray(raw.enabled)
88
+ ? [...new Set(raw.enabled.filter((kind) => KNOWN.includes(kind)))]
89
+ : base.enabled
90
+
91
+ const choices = {}
92
+ for (const kind of KNOWN) {
93
+ const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
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
+ }
102
+ choices[kind] = {
103
+ model: pick(source?.model),
104
+ effort: pick(source?.effort),
105
+ }
106
+ }
107
+
108
+ // Пустой список допустим только как осознанный выбор «никого не звать»;
109
+ // отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
110
+ return { enabled, choices }
111
+ }
112
+
113
+ /** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
114
+ export async function readSettings(cwd) {
115
+ const path = settingsPath(cwd)
116
+ if (path === null) return defaults()
117
+ try {
118
+ const text = await readFile(path, 'utf8')
119
+ return normalise(JSON.parse(text.replace(/^\uFEFF/, '')))
120
+ } catch {
121
+ return defaults()
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Записать настройки. Возвращает то, что действительно сохранено.
127
+ *
128
+ * Запись идёт через временный файл с переименованием. Прямая перезапись
129
+ * сначала обрезает файл, и чтение, пришедшееся на этот момент, видит пустоту
130
+ * или обрывок — а пустота у нас означает «включить обоих ревизоров», то есть
131
+ * запустить продукт, который человек только что выключил.
132
+ */
133
+ export async function writeSettings(cwd, value) {
134
+ const path = settingsPath(cwd)
135
+ if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
136
+ const normalised = normalise(value)
137
+ const temporary = `${path}.${process.pid}.tmp`
138
+ await writeFile(temporary, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
139
+ await rename(temporary, path)
140
+ return normalised
141
+ }
package/lib/review.js CHANGED
@@ -2,8 +2,12 @@
2
2
  * «Второе мнение» — независимый обзор работы чужими исполнителями.
3
3
  *
4
4
  * Это строка АГЕНТ-ПЛОСКОСТИ: инструмент регистрируется в реестр пресета, а
5
- * не профиля. Провайдеры подагентов живут на Host и монтируются отдельно;
6
- * наличие провайдера само по себе прав агенту не даёт, их даёт эта строка.
5
+ * не профиля.
6
+ *
7
+ * Продукты вызываются НАПРЯМУЮ через `ctx.subprocess`, а не через готовые
8
+ * провайдеры подагентов. Причина в `lib/reviewers.js`: провайдеры не
9
+ * передают дочернему продукту модель и усилие, а без них выбор из
10
+ * интерфейса невозможен.
7
11
  *
8
12
  * Зачем это нужно, коротко. Ошибки бывают громкие и тихие. Громкие падают с
9
13
  * криком, и их находит первый же пользователь. Тихие проявляются только в
@@ -35,8 +39,16 @@
35
39
  * ошибку одной строки вместо обрушения всего ряда.
36
40
  */
37
41
 
38
- /** Провайдеры, которых зовём. Порядок влияет только на вывод. */
39
- 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']
40
52
 
41
53
  /**
42
54
  * Задание ревизору.
@@ -105,33 +117,81 @@ function buildPrompt({ request, paths, focus }) {
105
117
  /** Уровни важности в порядке убывания — по нему сортируется итог. */
106
118
  const SEVERITY_ORDER = ['критично', 'серьёзно', 'мелочь']
107
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
+
108
137
  /**
109
138
  * Разобрать ответ ревизора в список находок.
110
139
  *
111
140
  * Разбор намеренно снисходительный: ревизор — языковая модель, и точного
112
- * соблюдения формата от неё ждать нельзя. Не разобранное не выбрасывается,
113
- * а попадает в `raw`: потерять находку из-за лишнего пробела хуже, чем
114
- * показать её неразмеченной.
141
+ * соблюдения формата от неё ждать нельзя. Пропущенное поле даёт null, а не
142
+ * теряет находку; жирная разметка вокруг названий полей снимается.
143
+ *
144
+ * Текст БЕЗ единого заголовка не выбрасывается, а возвращается одной
145
+ * находкой с пометкой «не по формату». Прежде такой ответ превращался в
146
+ * пустой список, то есть в «находок нет» — и сообщение продукта об истёкшем
147
+ * входе выглядело справкой о чистоте кода.
115
148
  */
116
149
  export function parseFindings(text, reviewer) {
117
150
  const source = String(text ?? '')
118
- 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 []
119
153
 
120
154
  const findings = []
121
- 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
+ }
122
172
 
123
173
  for (const block of blocks) {
174
+ if (findings.length >= MAX_FINDINGS) break
175
+
124
176
  const [titleLine, ...rest] = block.split('\n')
125
- const body = rest.join('\n')
177
+ // Жирная разметка вокруг названий полей снимается: модели любят её
178
+ // добавлять, а без этого поле просто не находилось и содержимое терялось.
179
+ const body = rest.join('\n').replace(/\*\*/g, '')
180
+
126
181
  // Поле тянется до следующего заголовка или до конца блока.
127
182
  //
128
183
  // Флаг `m` здесь недопустим: с ним `$` означает конец СТРОКИ, и
129
184
  // объяснение обрывалось на первой же — а ревизор объясняет развёрнуто,
130
185
  // и вторая строка обычно и есть самое полезное. Начало строки
131
186
  // обеспечивается явным `(?:^|\n)`, конец блока — `$` без `m`.
187
+ //
188
+ // Следующим полем считается только ИЗВЕСТНОЕ название, а не любая
189
+ // строка из заглавных букв с двоеточием: «ИТОГО:» внутри объяснения
190
+ // обрывал поле на середине.
191
+ const NEXT = '(?:ВАЖНОСТЬ|ГДЕ|ЧТО НЕ ТАК|ПОЧЕМУ ЭТО ВАЖНО|КАК ПРОВЕРИТЬ)'
132
192
  const field = (label) => {
133
- const match = body.match(new RegExp(`(?:^|\\n)${label}:\\s*([\\s\\S]+?)(?=\\n[А-ЯЁ ]{3,}:|$)`))
134
- 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())
135
195
  }
136
196
 
137
197
  const severityRaw = (field('ВАЖНОСТЬ') ?? '').toLowerCase()
@@ -139,7 +199,7 @@ export function parseFindings(text, reviewer) {
139
199
 
140
200
  findings.push({
141
201
  reviewer,
142
- title: titleLine.trim(),
202
+ title: clip(titleLine.replace(/\*\*/g, '').trim()),
143
203
  severity,
144
204
  where: field('ГДЕ'),
145
205
  what: field('ЧТО НЕ ТАК'),
@@ -151,47 +211,28 @@ export function parseFindings(text, reviewer) {
151
211
  return findings
152
212
  }
153
213
 
154
- /** Вытащить текст из блоков ответа подагента. */
155
- function textOf(output) {
156
- if (!Array.isArray(output)) return ''
157
- return output.filter((block) => block?.type === 'text').map((block) => String(block.text ?? '')).join('')
158
- }
159
-
160
214
  /**
161
- * Позвать одного ревизора и вернуть его находки.
215
+ * Позвать одного ревизора и разобрать его находки.
162
216
  *
163
217
  * Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
164
218
  * пользователь должен видеть, кто именно не ответил.
165
219
  */
166
- async function askReviewer(ctx, provider, prompt, parent, signal) {
167
- let run
168
- try {
169
- run = await ctx.subagents.start(provider, {
170
- label: `обзор: ${provider}`,
171
- prompt: [{ type: 'text', text: prompt }],
172
- parent,
173
- signal,
174
- })
175
- } catch (error) {
176
- return { provider, failed: String(error?.message ?? error), findings: [] }
177
- }
220
+ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal) {
221
+ const outcome = await runReviewer(ctx, kind, {
222
+ prompt,
223
+ cwd,
224
+ model: choice?.model ?? null,
225
+ effort: choice?.effort ?? null,
226
+ signal,
227
+ })
178
228
 
179
- try {
180
- const result = await run.result
181
- const answer = textOf(result?.output)
182
- if (answer.trim() === '') {
183
- return { provider, failed: 'пустой ответ', findings: [] }
184
- }
185
- return { provider, failed: null, findings: parseFindings(answer, provider), answer }
186
- } catch (error) {
187
- return { provider, failed: String(error?.message ?? error), findings: [] }
188
- } finally {
189
- try {
190
- await run.dispose()
191
- } catch {
192
- // Неудача уборки не должна подменять результат обзора.
193
- }
229
+ const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
230
+
231
+ if (outcome.failed !== null) {
232
+ return { provider: kind, failed: outcome.failed, cancelled: outcome.cancelled === true, findings: [], ...used }
194
233
  }
234
+
235
+ return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
195
236
  }
196
237
 
197
238
  /** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
@@ -207,7 +248,16 @@ export function render(value) {
207
248
  const lines = []
208
249
 
209
250
  if (value.reviewers.length === 0) {
210
- return 'Ни один ревизор не доступен: провайдеры подагентов не смонтированы.'
251
+ // Настоящая причина приходит в `problem` и обязана быть показана.
252
+ //
253
+ // Здесь стояла одна фраза на все случаи — про несмонтированные
254
+ // провайдеры подагентов. Механизм, которым этот код давно не
255
+ // пользуется: продукты зовутся напрямую. Пользователь, снявший обе
256
+ // галочки, получал объяснение про постороннюю неисправность и шёл чинить
257
+ // не то. Оба ревизора нашли это независимо.
258
+ return typeof value.problem === 'string' && value.problem !== ''
259
+ ? `Обзор не выполнен: ${value.problem}`
260
+ : 'Обзор не выполнен: ни один ревизор не выбран.'
211
261
  }
212
262
 
213
263
  const failed = value.reviewers.filter((r) => r.failed !== null)
@@ -218,7 +268,13 @@ export function render(value) {
218
268
  lines.push('')
219
269
 
220
270
  if (value.findings.length === 0) {
221
- 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('Находок нет.')
222
278
  return lines.join('\n')
223
279
  }
224
280
 
@@ -246,10 +302,10 @@ export function render(value) {
246
302
  }
247
303
 
248
304
  export const name = 'tensorgrid-second-opinion'
249
- export const inject = ['tools', 'subagents']
305
+ export const inject = ['tools', 'subprocess']
250
306
 
251
307
  /**
252
- * @param ctx - контекст агент-плоскости с реестрами `tools` и `subagents`.
308
+ * @param ctx - контекст агент-плоскости со службами `tools` и `subprocess`.
253
309
  */
254
310
  export async function apply(ctx) {
255
311
  const { defineTool } = await import('@deepseek-ai/dsh-tools')
@@ -281,7 +337,7 @@ export async function apply(ctx) {
281
337
  },
282
338
  reviewers: {
283
339
  type: 'array',
284
- items: { type: 'string', enum: REVIEWERS },
340
+ items: { type: 'string', enum: KNOWN_REVIEWERS },
285
341
  description:
286
342
  'Which reviewers to ask. Defaults to both — they find different things, and asking one guarantees missing half.',
287
343
  },
@@ -298,17 +354,32 @@ export async function apply(ctx) {
298
354
  render: (_args, value) => [{ type: 'text', text: render(value) }],
299
355
  },
300
356
  async execute(args, exec) {
301
- const parent = exec.agent
302
- const available = ctx.subagents.list()
303
- const wanted = Array.isArray(args.reviewers) && args.reviewers.length > 0 ? args.reviewers : REVIEWERS
304
- const usable = wanted.filter((provider) => available.includes(provider))
305
-
306
- if (usable.length === 0) {
307
- return {
308
- findings: [],
309
- reviewers: [],
310
- problem: `нет доступных ревизоров; провайдеры на Host: ${available.join(', ') || 'нет'}`,
311
- }
357
+ const { runReviewer } = await import('./reviewers.js')
358
+ const { readSettings } = await import('./review-settings.js')
359
+
360
+ // Рабочая папка берётся у сессии, как это делают поставочные
361
+ // провайдеры: ревизор должен смотреть тот же код, что и автор.
362
+ const cwd = exec.agent?.session?.header?.cwd
363
+ if (typeof cwd !== 'string' || cwd === '') {
364
+ return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
365
+ }
366
+
367
+ // Выбор пользователя из настроек; вызов может его перекрыть.
368
+ const settings = await readSettings(cwd)
369
+ const asked = Array.isArray(args.reviewers) && args.reviewers.length > 0
370
+ ? args.reviewers
371
+ : settings.enabled
372
+
373
+ // Повторы отбрасываются, и это не придирка: `reviewers` заполняет
374
+ // модель, а список из ста одинаковых имён запустил бы сто платных
375
+ // процессов разом — по одному на каждый элемент.
376
+ const wanted = [...new Set(asked)].filter((kind) => KNOWN_REVIEWERS.includes(kind))
377
+
378
+ if (wanted.length === 0) {
379
+ const problem = asked.length === 0
380
+ ? 'не выбран ни один ревизор — включите их в настройках'
381
+ : `неизвестные ревизоры: ${[...new Set(asked)].join(', ')}`
382
+ return { findings: [], reviewers: [], problem }
312
383
  }
313
384
 
314
385
  const prompt = buildPrompt(args)
@@ -316,12 +387,19 @@ export async function apply(ctx) {
316
387
  // Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
317
388
  // удвоил бы ожидание без всякой пользы.
318
389
  const answers = await Promise.all(
319
- usable.map((provider) => askReviewer(ctx, provider, prompt, parent, exec.signal)),
390
+ wanted.map((kind) => askReviewer(ctx, runReviewer, kind, prompt, cwd, settings.choices[kind], exec.signal)),
320
391
  )
321
392
 
322
393
  return {
323
394
  findings: ordered(answers.flatMap((entry) => entry.findings)),
324
- reviewers: answers.map((entry) => ({ provider: entry.provider, failed: entry.failed, found: entry.findings.length })),
395
+ reviewers: answers.map((entry) => ({
396
+ provider: entry.provider,
397
+ failed: entry.failed,
398
+ cancelled: entry.cancelled === true,
399
+ found: entry.findings.length,
400
+ model: entry.model,
401
+ effort: entry.effort,
402
+ })),
325
403
  }
326
404
  },
327
405
  }),
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Запуск ревизоров — Claude Code и Codex — с выбором модели и усилия.
3
+ *
4
+ * ПОЧЕМУ НЕ ЧЕРЕЗ ПРОВАЙДЕРОВ ПОДАГЕНТОВ
5
+ *
6
+ * Готовые провайдеры `codex` и `claude-code` запускают те же продукты, но
7
+ * берут из вызова только задание, отмену и родителя. Модель и усилие они не
8
+ * передают: у них это задаётся окружением в строке композиции, то есть один
9
+ * раз при старте. Выбор из интерфейса так не сделать — пришлось бы монтировать
10
+ * отдельного провайдера на каждое сочетание и перезапускать приложение.
11
+ *
12
+ * Поэтому продукты вызываются напрямую, через `ctx.subprocess` — ту же службу,
13
+ * которой пользуются сами провайдеры. Она даёт поиск команды в PATH, очистку
14
+ * окружения от чужих учётных данных, управление деревом процессов и
15
+ * остановку по сигналу. Своего здесь — только сборка аргументов.
16
+ *
17
+ * ДВЕ ТОНКОСТИ, СТОИВШИЕ ВРЕМЕНИ
18
+ *
19
+ * Задание передаётся через ПОТОК ВВОДА, а не аргументом. Обе команды так
20
+ * умеют, а длинное задание с кавычками в аргументе командной строки ломается:
21
+ * сегодня это уже случилось — кавычки в тексте разорвали разбор аргументов.
22
+ *
23
+ * Codex печатает в вывод весь ход рассуждения — в одном из прогонов вышло
24
+ * 138 КБ на страницу ответа. Итог забирается отдельным файлом через
25
+ * `--output-last-message`. Claude Code в режиме `-p` и так печатает только
26
+ * ответ.
27
+ */
28
+ import { mkdtemp, readFile, rm } from 'node:fs/promises'
29
+ import { tmpdir } from 'node:os'
30
+ import { join } from 'node:path'
31
+
32
+ /** Сколько ждать ревизора. Обзор большого кода у Codex занимал 450 секунд. */
33
+ const DEFAULT_TIMEOUT_MS = 900000
34
+
35
+ /** Предел собираемого вывода: ответ ревизора — текст, а не поток данных. */
36
+ const STDOUT_CAP = 1024 * 1024
37
+ const STDERR_CAP = 64 * 1024
38
+
39
+ /**
40
+ * Что известно про каждого ревизора.
41
+ *
42
+ * `models` и `efforts` — не ограничение, а подсказка для интерфейса: список
43
+ * показывается пользователю, но передать можно и не перечисленное. Жёсткий
44
+ * список устарел бы при первом же выпуске нового имени модели.
45
+ */
46
+ export const REVIEWERS = {
47
+ 'claude-code': {
48
+ label: 'Claude Code',
49
+ command: 'claude',
50
+ models: ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'],
51
+ efforts: ['low', 'medium', 'high'],
52
+ defaultModel: null,
53
+ defaultEffort: null,
54
+ },
55
+ codex: {
56
+ label: 'Codex',
57
+ command: 'codex',
58
+ models: ['gpt-6-astra', 'gpt-5.6-sol'],
59
+ efforts: ['low', 'medium', 'high', 'xhigh'],
60
+ defaultModel: null,
61
+ defaultEffort: null,
62
+ },
63
+ }
64
+
65
+ /** Значения уходят в аргументы команды — пропускаем только безобидное. */
66
+ const SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
67
+
68
+ /**
69
+ * Собрать аргументы запуска.
70
+ *
71
+ * @param kind - имя ревизора.
72
+ * @param options - модель, усилие, файл для итога.
73
+ * @returns аргументы без имени программы.
74
+ */
75
+ export function buildArgs(kind, { model, effort, answerFile }) {
76
+ const args = []
77
+
78
+ if (kind === 'claude-code') {
79
+ // `-p` без текста задания: задание придёт через поток ввода.
80
+ args.push('-p')
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')
95
+ if (model) args.push('--model', model)
96
+ if (effort) args.push('--effort', effort)
97
+ return args
98
+ }
99
+
100
+ if (kind === 'codex') {
101
+ args.push('exec')
102
+ // Обзор ничего не меняет: только чтение. Это и защита, и честность —
103
+ // ревизор не должен «починить» по дороге то, что взялся оценивать.
104
+ args.push('--sandbox', 'read-only')
105
+ // Рабочая папка может не быть репозиторием, и это не повод отказываться.
106
+ args.push('--skip-git-repo-check')
107
+ args.push('--output-last-message', answerFile)
108
+ if (model) args.push('-m', model)
109
+ // Значение читается как TOML, поэтому строка в кавычках.
110
+ if (effort) args.push('-c', `model_reasoning_effort="${effort}"`)
111
+ return args
112
+ }
113
+
114
+ throw new Error(`неизвестный ревизор: ${kind}`)
115
+ }
116
+
117
+ /**
118
+ * Запустить одного ревизора и вернуть его ответ.
119
+ *
120
+ * Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
121
+ * целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
122
+ *
123
+ * @param ctx - контекст со службой `subprocess`.
124
+ * @param kind - имя ревизора.
125
+ * @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
126
+ */
127
+ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
128
+ const spec = REVIEWERS[kind]
129
+ if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
130
+
131
+ for (const [label, value] of [['модель', model], ['усилие', effort]]) {
132
+ if (value !== undefined && value !== null && value !== '' && !SAFE_VALUE.test(String(value))) {
133
+ return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
134
+ }
135
+ }
136
+
137
+ // Отмену, случившуюся ДО вызова, надо увидеть сразу.
138
+ //
139
+ // Подписка на будущее событие `abort` у уже отменённого сигнала не
140
+ // срабатывает никогда: обработчик задним числом не вызывается. Без этой
141
+ // проверки нажатие «стоп» перед самым запуском не мешало нам завести
142
+ // платный процесс, который потом жил до самого тайм-аута.
143
+ if (signal?.aborted === true) {
144
+ return { kind, failed: 'отменено', cancelled: true, answer: null }
145
+ }
146
+
147
+ let workDir = null
148
+ let handle = null
149
+ // Своя отмена поверх пользовательской: ревизор без ограничения по времени
150
+ // держал бы обзор бесконечно.
151
+ const deadline = new AbortController()
152
+ const timer = setTimeout(() => deadline.abort(), timeoutMs ?? DEFAULT_TIMEOUT_MS)
153
+ const onOuterAbort = () => deadline.abort()
154
+ if (signal !== undefined) signal.addEventListener('abort', onOuterAbort, { once: true })
155
+
156
+ try {
157
+ workDir = await mkdtemp(join(tmpdir(), 'tensorgrid-review-'))
158
+ const answerFile = join(workDir, 'answer.txt')
159
+
160
+ let executable
161
+ try {
162
+ executable = await ctx.subprocess.resolveExecutable(spec.command, undefined, deadline.signal)
163
+ } catch (error) {
164
+ return { kind, failed: `${spec.command} не найден: ${String(error?.message ?? error)}`, answer: null }
165
+ }
166
+
167
+ handle = ctx.subprocess.spawn({
168
+ argv: [executable, ...buildArgs(kind, { model, effort, answerFile })],
169
+ cwd,
170
+ stdio: {
171
+ // Задание через поток ввода: кавычки и переводы строк проходят как есть.
172
+ stdin: { data: prompt },
173
+ stdout: { maxBytes: STDOUT_CAP },
174
+ stderr: { maxBytes: STDERR_CAP },
175
+ },
176
+ graceMs: 5000,
177
+ signal: deadline.signal,
178
+ })
179
+
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
+ }
210
+
211
+ let answer = ''
212
+ if (kind === 'codex') {
213
+ // Итог Codex берётся ТОЛЬКО из файла. Запасное чтение вывода отсюда
214
+ // убрано намеренно: в выводе лежит весь ход рассуждения вместе с
215
+ // образцом формата из задания, и разбор наплодил бы по нему находки-
216
+ // призраки — в том числе «критичную» с названием «краткое название».
217
+ // Отсутствие файла — это сбой, а не повод подсунуть журнал.
218
+ try {
219
+ answer = await readFile(answerFile, 'utf8')
220
+ } catch {
221
+ return { kind, failed: `Codex не оставил итогового ответа${tail === '' ? '' : `: ${tail}`}`, answer: null }
222
+ }
223
+ } else {
224
+ answer = (await handle.collected.stdout.readFrom(0)).text
225
+ }
226
+
227
+ if (answer.trim() === '') {
228
+ return { kind, failed: `пустой ответ${tail === '' ? '' : `: ${tail}`}`, answer: null }
229
+ }
230
+
231
+ return { kind, failed: null, cancelled: false, answer, model: model ?? null, effort: effort ?? null }
232
+ } catch (error) {
233
+ if (signal?.aborted === true) return { kind, failed: 'отменено', cancelled: true, answer: null }
234
+ const reason = deadline.signal.aborted ? 'превышено время ожидания' : String(error?.message ?? error)
235
+ return { kind, failed: reason, answer: null }
236
+ } finally {
237
+ clearTimeout(timer)
238
+ if (signal !== undefined) signal.removeEventListener('abort', onOuterAbort)
239
+ if (handle !== null) {
240
+ try {
241
+ handle.terminate()
242
+ // Ждём, пока процесс действительно умрёт, прежде чем убирать за ним.
243
+ // Без ожидания удаление шло при живом процессе, у которого есть
244
+ // отпущенное время на завершение и открытый файл итога: на Windows
245
+ // это даёт отказ, `force` его проглатывает, и временные каталоги
246
+ // копятся после каждой отмены.
247
+ await handle.waitForExit()
248
+ } catch {
249
+ // Уборка не должна подменять результат.
250
+ }
251
+ }
252
+ if (workDir !== null) {
253
+ await rm(workDir, { recursive: true, force: true }).catch(() => {})
254
+ }
255
+ }
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",