tensorgrid-ui 1.3.0 → 1.4.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/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,154 @@ 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
+ setState(await send(body))
2287
+ } catch (error) {
2288
+ setState({ catalogue: [], problem: String(error && error.message ? error.message : error) })
2289
+ } finally {
2290
+ setBusy(false)
2291
+ }
2292
+ }, [send])
2293
+
2294
+ React.useEffect(() => { refresh() }, [refresh])
2295
+
2296
+ const catalogue = state !== null && Array.isArray(state.catalogue) ? state.catalogue : []
2297
+ const settings = state !== null && state.settings ? state.settings : null
2298
+
2299
+ // Изменение любого поля сразу сохраняется: отдельная кнопка «применить»
2300
+ // добавляет состояние «изменено, но не сохранено», в котором легко
2301
+ // забыть нажать и потом удивляться.
2302
+ const save = React.useCallback((next) => { refresh(next) }, [refresh])
2303
+
2304
+ const toggle = (kind) => {
2305
+ if (settings === null) return
2306
+ const on = settings.enabled.includes(kind)
2307
+ const enabled = on ? settings.enabled.filter((k) => k !== kind) : [...settings.enabled, kind]
2308
+ save({ ...settings, enabled })
2309
+ }
2310
+
2311
+ const pick = (kind, field, value) => {
2312
+ if (settings === null) return
2313
+ const choices = { ...settings.choices, [kind]: { ...settings.choices[kind], [field]: value } }
2314
+ save({ ...settings, choices })
2315
+ }
2316
+
2317
+ const selector = (kind, field, options, current) =>
2318
+ jsxs('select', {
2319
+ className: 'dsx-rev__select',
2320
+ value: current === null ? '' : current,
2321
+ disabled: busy,
2322
+ onChange: (event) => pick(kind, field, event.target.value === '' ? null : event.target.value),
2323
+ children: [
2324
+ jsx('option', { value: '', children: t('review.asConfigured') }, ''),
2325
+ ...options.map((option) => jsx('option', { value: option, children: option }, option)),
2326
+ ],
2327
+ })
2328
+
2329
+ return jsxs('div', {
2330
+ className: 'dsx-subs',
2331
+ children: [
2332
+ jsxs('div', {
2333
+ className: 'dsx-subs__head',
2334
+ children: [
2335
+ jsx('div', { className: 'dsx-subs__title', children: t('review.title') }),
2336
+ jsx('div', { className: 'dsx-setting__hint', children: t('review.hint') }),
2337
+ ],
2338
+ }),
2339
+ state !== null && state.problem
2340
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: state.problem })
2341
+ : null,
2342
+ jsx('div', {
2343
+ className: 'dsx-rev__list',
2344
+ children: catalogue.map((entry) => {
2345
+ const on = settings !== null && settings.enabled.includes(entry.kind)
2346
+ const choice = settings === null ? { model: null, effort: null } : settings.choices[entry.kind]
2347
+ return jsxs('div', {
2348
+ className: 'dsx-rev__card' + (on ? ' dsx-rev__card--on' : ''),
2349
+ children: [
2350
+ jsxs('label', {
2351
+ className: 'dsx-rev__head',
2352
+ children: [
2353
+ jsx('input', {
2354
+ type: 'checkbox',
2355
+ checked: on,
2356
+ disabled: busy,
2357
+ onChange: () => toggle(entry.kind),
2358
+ }),
2359
+ jsx('span', { className: 'dsx-rev__name', children: entry.label }),
2360
+ ],
2361
+ }),
2362
+ jsxs('div', {
2363
+ className: 'dsx-rev__fields',
2364
+ children: [
2365
+ jsxs('label', {
2366
+ className: 'dsx-rev__field',
2367
+ children: [
2368
+ jsx('span', { children: t('review.model') }),
2369
+ selector(entry.kind, 'model', entry.models, choice.model),
2370
+ ],
2371
+ }),
2372
+ jsxs('label', {
2373
+ className: 'dsx-rev__field',
2374
+ children: [
2375
+ jsx('span', { children: t('review.effort') }),
2376
+ selector(entry.kind, 'effort', entry.efforts, choice.effort),
2377
+ ],
2378
+ }),
2379
+ ],
2380
+ }),
2381
+ ],
2382
+ }, entry.kind)
2383
+ }),
2384
+ }),
2385
+ settings !== null && settings.enabled.length === 0
2386
+ ? jsx('div', { className: 'dsx-setting__hint dsx-setting__hint--warn', children: t('review.none') })
2387
+ : null,
2388
+ jsx('div', { className: 'dsx-subs__note', children: t('review.note') }),
2389
+ ],
2390
+ })
2391
+ }
2220
2392
  // ── Обновления ───────────────────────────────────────────────────────
2221
2393
  //
2222
2394
  // Маршрут поднимает host-половина пакета. Путь относительный, токен не
@@ -2444,6 +2616,21 @@ window.__ModuleLoader__.load({
2444
2616
  ),
2445
2617
  )
2446
2618
 
2619
+ // Ревизоры — соседний раздел: и подписки, и обзор про то, чем и как
2620
+ // работает агент, а не про внешний вид.
2621
+ ctx.slots.inject('settings.section', () =>
2622
+ ctx.slots.register(
2623
+ {
2624
+ name: 'settings.section',
2625
+ id: 'tensorgrid-review',
2626
+ order: 13,
2627
+ locale: LOCALE_NS,
2628
+ label: () => ctx.locale.translate(LOCALE_NS, 'review.nav'),
2629
+ },
2630
+ ReviewRow,
2631
+ ),
2632
+ )
2633
+
2447
2634
  ctx.slots.inject('settings.general.item', () =>
2448
2635
  ctx.slots.register(
2449
2636
  { 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,110 @@
1
+ /**
2
+ * Настройки обзора: кого звать, какой моделью и с каким усилием.
3
+ *
4
+ * Хранятся файлом рядом с профилем, а не в браузере. Причина простая:
5
+ * выбирает человек в интерфейсе, а читает Host в момент вызова инструмента —
6
+ * значит хранилище должно быть общим для обеих сторон. Память браузера для
7
+ * этого не годится, Host её не видит.
8
+ *
9
+ * Файл лежит там же, где отметка об установке, и ищется тем же подъёмом
10
+ * вверх: так путь не зависит от того, насколько глубоко pnpm разложил пакет.
11
+ *
12
+ * Импортируются только встроенные модули Node.
13
+ */
14
+ import { readFile, 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
+ * означал бы, что половина находок не появится у того, кто в настройки не
29
+ * заглядывал. Модель и усилие пустые — берутся собственные настройки
30
+ * продукта, то есть то, чем человек уже пользуется.
31
+ */
32
+ export function defaults() {
33
+ return {
34
+ enabled: [...KNOWN],
35
+ choices: { 'claude-code': { model: null, effort: null }, codex: { model: null, effort: null } },
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Каталог профиля: поднимаемся, пока не найдём отметку об установке.
41
+ * @param startDir - откуда начинать подъём.
42
+ */
43
+ function findProfileRoot(startDir) {
44
+ let dir = startDir
45
+ for (let depth = 0; depth < 10; depth += 1) {
46
+ if (existsSync(join(dir, 'tensorgrid-installed.json'))) return dir
47
+ const parent = dirname(dir)
48
+ if (parent === dir) break
49
+ dir = parent
50
+ }
51
+ return null
52
+ }
53
+
54
+ /**
55
+ * Где лежит файл настроек.
56
+ *
57
+ * Сначала ищем от собственного расположения — так работает установленный
58
+ * пакет. Если отметки нет (запуск из репозитория, проверка), кладём рядом с
59
+ * рабочей папкой, чтобы проверяемый код вёл себя как настоящий.
60
+ *
61
+ * @param cwd - рабочая папка сессии, запасной ориентир.
62
+ */
63
+ export function settingsPath(cwd) {
64
+ const here = dirname(fileURLToPath(import.meta.url))
65
+ const root = findProfileRoot(here) ?? (typeof cwd === 'string' && cwd !== '' ? findProfileRoot(cwd) : null)
66
+ return root === null ? null : join(root, FILE_NAME)
67
+ }
68
+
69
+ /** Привести прочитанное к ожидаемой форме: файл правит человек, и правит с ошибками. */
70
+ export function normalise(raw) {
71
+ const base = defaults()
72
+ if (raw === null || typeof raw !== 'object') return base
73
+
74
+ const enabled = Array.isArray(raw.enabled) ? raw.enabled.filter((kind) => KNOWN.includes(kind)) : base.enabled
75
+
76
+ const choices = {}
77
+ for (const kind of KNOWN) {
78
+ const source = raw.choices !== null && typeof raw.choices === 'object' ? raw.choices[kind] : undefined
79
+ const pick = (value) => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
80
+ choices[kind] = {
81
+ model: pick(source?.model),
82
+ effort: pick(source?.effort),
83
+ }
84
+ }
85
+
86
+ // Пустой список допустим только как осознанный выбор «никого не звать»;
87
+ // отличить его от испорченного файла нельзя, поэтому возвращаем как есть.
88
+ return { enabled, choices }
89
+ }
90
+
91
+ /** Прочитать настройки. Отсутствие файла — не ошибка, а «ничего не выбрано». */
92
+ export async function readSettings(cwd) {
93
+ const path = settingsPath(cwd)
94
+ if (path === null) return defaults()
95
+ try {
96
+ const text = await readFile(path, 'utf8')
97
+ return normalise(JSON.parse(text.replace(/^\uFEFF/, '')))
98
+ } catch {
99
+ return defaults()
100
+ }
101
+ }
102
+
103
+ /** Записать настройки. Возвращает то, что действительно сохранено. */
104
+ export async function writeSettings(cwd, value) {
105
+ const path = settingsPath(cwd)
106
+ if (path === null) throw new Error('не найден каталог профиля — некуда сохранить настройки обзора')
107
+ const normalised = normalise(value)
108
+ await writeFile(path, `${JSON.stringify(normalised, null, 2)}\n`, 'utf8')
109
+ return normalised
110
+ }
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
  * криком, и их находит первый же пользователь. Тихие проявляются только в
@@ -151,47 +155,28 @@ export function parseFindings(text, reviewer) {
151
155
  return findings
152
156
  }
153
157
 
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
158
  /**
161
- * Позвать одного ревизора и вернуть его находки.
159
+ * Позвать одного ревизора и разобрать его находки.
162
160
  *
163
161
  * Отказ одного НЕ отменяет обзор: половина ответа полезнее отказа, и
164
162
  * пользователь должен видеть, кто именно не ответил.
165
163
  */
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
- }
164
+ async function askReviewer(ctx, runReviewer, kind, prompt, cwd, choice, signal) {
165
+ const outcome = await runReviewer(ctx, kind, {
166
+ prompt,
167
+ cwd,
168
+ model: choice?.model ?? null,
169
+ effort: choice?.effort ?? null,
170
+ signal,
171
+ })
178
172
 
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
- }
173
+ const used = { model: choice?.model ?? null, effort: choice?.effort ?? null }
174
+
175
+ if (outcome.failed !== null) {
176
+ return { provider: kind, failed: outcome.failed, findings: [], ...used }
194
177
  }
178
+
179
+ return { provider: kind, failed: null, findings: parseFindings(outcome.answer, kind), ...used }
195
180
  }
196
181
 
197
182
  /** Отсортировать находки: сначала опасные, внутри уровня — по ревизору. */
@@ -246,10 +231,10 @@ export function render(value) {
246
231
  }
247
232
 
248
233
  export const name = 'tensorgrid-second-opinion'
249
- export const inject = ['tools', 'subagents']
234
+ export const inject = ['tools', 'subprocess']
250
235
 
251
236
  /**
252
- * @param ctx - контекст агент-плоскости с реестрами `tools` и `subagents`.
237
+ * @param ctx - контекст агент-плоскости со службами `tools` и `subprocess`.
253
238
  */
254
239
  export async function apply(ctx) {
255
240
  const { defineTool } = await import('@deepseek-ai/dsh-tools')
@@ -298,17 +283,24 @@ export async function apply(ctx) {
298
283
  render: (_args, value) => [{ type: 'text', text: render(value) }],
299
284
  },
300
285
  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
- }
286
+ const { runReviewer } = await import('./reviewers.js')
287
+ const { readSettings } = await import('./review-settings.js')
288
+
289
+ // Рабочая папка берётся у сессии, как это делают поставочные
290
+ // провайдеры: ревизор должен смотреть тот же код, что и автор.
291
+ const cwd = exec.agent?.session?.header?.cwd
292
+ if (typeof cwd !== 'string' || cwd === '') {
293
+ return { findings: [], reviewers: [], problem: 'у сессии нет рабочей папки — обзору нечего смотреть' }
294
+ }
295
+
296
+ // Выбор пользователя из настроек; вызов может его перекрыть.
297
+ const settings = await readSettings(cwd)
298
+ const wanted = Array.isArray(args.reviewers) && args.reviewers.length > 0
299
+ ? args.reviewers
300
+ : settings.enabled
301
+
302
+ if (wanted.length === 0) {
303
+ return { findings: [], reviewers: [], problem: 'в настройках не выбран ни один ревизор' }
312
304
  }
313
305
 
314
306
  const prompt = buildPrompt(args)
@@ -316,12 +308,18 @@ export async function apply(ctx) {
316
308
  // Спрашиваем ОБОИХ разом: они независимы, и последовательный опрос
317
309
  // удвоил бы ожидание без всякой пользы.
318
310
  const answers = await Promise.all(
319
- usable.map((provider) => askReviewer(ctx, provider, prompt, parent, exec.signal)),
311
+ wanted.map((kind) => askReviewer(ctx, runReviewer, kind, prompt, cwd, settings.choices[kind], exec.signal)),
320
312
  )
321
313
 
322
314
  return {
323
315
  findings: ordered(answers.flatMap((entry) => entry.findings)),
324
- reviewers: answers.map((entry) => ({ provider: entry.provider, failed: entry.failed, found: entry.findings.length })),
316
+ reviewers: answers.map((entry) => ({
317
+ provider: entry.provider,
318
+ failed: entry.failed,
319
+ found: entry.findings.length,
320
+ model: entry.model,
321
+ effort: entry.effort,
322
+ })),
325
323
  }
326
324
  },
327
325
  }),
@@ -0,0 +1,199 @@
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-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
+ args.push('--permission-mode', 'bypassPermissions')
84
+ if (model) args.push('--model', model)
85
+ if (effort) args.push('--effort', effort)
86
+ return args
87
+ }
88
+
89
+ if (kind === 'codex') {
90
+ args.push('exec')
91
+ // Обзор ничего не меняет: только чтение. Это и защита, и честность —
92
+ // ревизор не должен «починить» по дороге то, что взялся оценивать.
93
+ args.push('--sandbox', 'read-only')
94
+ // Рабочая папка может не быть репозиторием, и это не повод отказываться.
95
+ args.push('--skip-git-repo-check')
96
+ args.push('--output-last-message', answerFile)
97
+ if (model) args.push('-m', model)
98
+ // Значение читается как TOML, поэтому строка в кавычках.
99
+ if (effort) args.push('-c', `model_reasoning_effort="${effort}"`)
100
+ return args
101
+ }
102
+
103
+ throw new Error(`неизвестный ревизор: ${kind}`)
104
+ }
105
+
106
+ /**
107
+ * Запустить одного ревизора и вернуть его ответ.
108
+ *
109
+ * Отказ не бросается наружу: обзор двумя ревизорами не должен пропадать
110
+ * целиком из-за одного неответившего. Возвращается либо ответ, либо причина.
111
+ *
112
+ * @param ctx - контекст со службой `subprocess`.
113
+ * @param kind - имя ревизора.
114
+ * @param options - задание, рабочая папка, модель, усилие, сигнал отмены.
115
+ */
116
+ export async function runReviewer(ctx, kind, { prompt, cwd, model, effort, signal, timeoutMs }) {
117
+ const spec = REVIEWERS[kind]
118
+ if (spec === undefined) return { kind, failed: `неизвестный ревизор: ${kind}`, answer: null }
119
+
120
+ for (const [label, value] of [['модель', model], ['усилие', effort]]) {
121
+ if (value !== undefined && value !== null && value !== '' && !SAFE_VALUE.test(String(value))) {
122
+ return { kind, failed: `недопустимое значение (${label}): ${String(value)}`, answer: null }
123
+ }
124
+ }
125
+
126
+ let workDir = null
127
+ let handle = null
128
+ // Своя отмена поверх пользовательской: ревизор без ограничения по времени
129
+ // держал бы обзор бесконечно.
130
+ const deadline = new AbortController()
131
+ const timer = setTimeout(() => deadline.abort(), timeoutMs ?? DEFAULT_TIMEOUT_MS)
132
+ const onOuterAbort = () => deadline.abort()
133
+ if (signal !== undefined) signal.addEventListener('abort', onOuterAbort, { once: true })
134
+
135
+ try {
136
+ workDir = await mkdtemp(join(tmpdir(), 'tensorgrid-review-'))
137
+ const answerFile = join(workDir, 'answer.txt')
138
+
139
+ let executable
140
+ try {
141
+ executable = await ctx.subprocess.resolveExecutable(spec.command, undefined, deadline.signal)
142
+ } catch (error) {
143
+ return { kind, failed: `${spec.command} не найден: ${String(error?.message ?? error)}`, answer: null }
144
+ }
145
+
146
+ handle = ctx.subprocess.spawn({
147
+ argv: [executable, ...buildArgs(kind, { model, effort, answerFile })],
148
+ cwd,
149
+ stdio: {
150
+ // Задание через поток ввода: кавычки и переводы строк проходят как есть.
151
+ stdin: { data: prompt },
152
+ stdout: { maxBytes: STDOUT_CAP },
153
+ stderr: { maxBytes: STDERR_CAP },
154
+ },
155
+ graceMs: 5000,
156
+ signal: deadline.signal,
157
+ })
158
+
159
+ const outcome = await handle.done
160
+
161
+ // Codex кладёт итог в файл, Claude Code печатает его в вывод.
162
+ let answer = ''
163
+ if (kind === 'codex') {
164
+ try {
165
+ answer = await readFile(answerFile, 'utf8')
166
+ } catch {
167
+ answer = ''
168
+ }
169
+ }
170
+ if (answer.trim() === '') {
171
+ answer = (await handle.collected.stdout.readFrom(0)).text
172
+ }
173
+
174
+ 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 }
179
+ }
180
+
181
+ return { kind, failed: null, answer, model: model ?? null, effort: effort ?? null }
182
+ } catch (error) {
183
+ const reason = deadline.signal.aborted && signal?.aborted !== true ? 'превышено время ожидания' : String(error?.message ?? error)
184
+ return { kind, failed: reason, answer: null }
185
+ } finally {
186
+ clearTimeout(timer)
187
+ if (signal !== undefined) signal.removeEventListener('abort', onOuterAbort)
188
+ if (handle !== null) {
189
+ try {
190
+ handle.terminate()
191
+ } catch {
192
+ // Уборка не должна подменять результат.
193
+ }
194
+ }
195
+ if (workDir !== null) {
196
+ await rm(workDir, { recursive: true, force: true }).catch(() => {})
197
+ }
198
+ }
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tensorgrid-ui",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "TENSOR GRID — фирменный интерфейс поверх DeepSeek Harness: палитра, живой ambient-слой, айдентика и русский язык-пакет",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",