team-chat 0.0.0-stage → 0.3.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.
Files changed (122) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +458 -2
  3. package/agents/rules.md +52 -0
  4. package/agents/runtimes/codex.md +14 -0
  5. package/bin/bridge +3 -0
  6. package/bin/runner +3 -0
  7. package/cli/lib/agents.js +97 -0
  8. package/cli/lib/docs.js +151 -0
  9. package/cli/lib/local.js +41 -0
  10. package/cli/lib/server.js +52 -0
  11. package/cli/lib/util.js +107 -0
  12. package/cli/team-chat.js +383 -0
  13. package/host/bridge/attempts.js +43 -0
  14. package/host/bridge/bridge.js +326 -0
  15. package/host/bridge/config.js +105 -0
  16. package/host/bridge/knowledge-outbox.js +96 -0
  17. package/host/bridge/launcher.js +126 -0
  18. package/host/bridge/main.js +87 -0
  19. package/host/bridge/progress.js +74 -0
  20. package/host/bridge/prompt.js +97 -0
  21. package/host/bridge/run-archive.js +200 -0
  22. package/host/bridge/thread.js +44 -0
  23. package/host/bridge/triage.js +97 -0
  24. package/host/bridge/workdir.js +41 -0
  25. package/host/knowledge/card.js +43 -0
  26. package/host/knowledge/errors.js +50 -0
  27. package/host/knowledge/lesson.js +153 -0
  28. package/host/knowledge/process.js +160 -0
  29. package/host/knowledge/scrub.js +29 -0
  30. package/host/knowledge/shell.js +74 -0
  31. package/host/knowledge/stack.js +229 -0
  32. package/host/knowledge/transcript.js +82 -0
  33. package/host/lib/chat-api.js +120 -0
  34. package/host/lib/log.js +6 -0
  35. package/host/lib/pid-lock.js +65 -0
  36. package/host/lib/shell-words.js +45 -0
  37. package/host/runner/bridge-process.js +82 -0
  38. package/host/runner/config.js +81 -0
  39. package/host/runner/main.js +69 -0
  40. package/host/runner/retention.js +40 -0
  41. package/host/runner/runner.js +161 -0
  42. package/package.json +67 -5
  43. package/server/dist/src/app.js +25 -0
  44. package/server/dist/src/app.module.js +50 -0
  45. package/server/dist/src/attachments/attachment-store.js +111 -0
  46. package/server/dist/src/attachments/attachments.controller.js +67 -0
  47. package/server/dist/src/attachments/mime.js +84 -0
  48. package/server/dist/src/attachments/uploads.interceptor.js +72 -0
  49. package/server/dist/src/auth/participant.guard.js +53 -0
  50. package/server/dist/src/bridges/bridge-restart.service.js +80 -0
  51. package/server/dist/src/bridges/bridges.controller.js +47 -0
  52. package/server/dist/src/chats/chats.controller.js +83 -0
  53. package/server/dist/src/chats/chats.service.js +128 -0
  54. package/server/dist/src/collector/collector.controller.js +71 -0
  55. package/server/dist/src/collector/collector.module.js +23 -0
  56. package/server/dist/src/collector/collector.service.js +111 -0
  57. package/server/dist/src/common/clock.js +20 -0
  58. package/server/dist/src/common/errors.js +37 -0
  59. package/server/dist/src/common/format.js +14 -0
  60. package/server/dist/src/common/http.js +17 -0
  61. package/server/dist/src/common/slug.js +28 -0
  62. package/server/dist/src/common/tokens.js +19 -0
  63. package/server/dist/src/common/validation.js +41 -0
  64. package/server/dist/src/config/config.js +39 -0
  65. package/server/dist/src/conversation.module.js +41 -0
  66. package/server/dist/src/db/database.js +73 -0
  67. package/server/dist/src/db/database.module.js +51 -0
  68. package/server/dist/src/db/schema.js +212 -0
  69. package/server/dist/src/decisions/decisions.controller.js +55 -0
  70. package/server/dist/src/decisions/decisions.service.js +109 -0
  71. package/server/dist/src/documents/documents.controller.js +87 -0
  72. package/server/dist/src/documents/documents.service.js +189 -0
  73. package/server/dist/src/health/health.controller.js +29 -0
  74. package/server/dist/src/knowledge/knowledge.controller.js +83 -0
  75. package/server/dist/src/knowledge/knowledge.module.js +25 -0
  76. package/server/dist/src/knowledge/knowledge.service.js +189 -0
  77. package/server/dist/src/local/docs-sync.service.js +97 -0
  78. package/server/dist/src/local/local.controller.js +197 -0
  79. package/server/dist/src/local/local.guard.js +55 -0
  80. package/server/dist/src/local/local.module.js +30 -0
  81. package/server/dist/src/local/setup.service.js +154 -0
  82. package/server/dist/src/local/transfer.service.js +151 -0
  83. package/server/dist/src/main.js +33 -0
  84. package/server/dist/src/mcp/chat-tools.js +282 -0
  85. package/server/dist/src/mcp/mcp.controller.js +58 -0
  86. package/server/dist/src/mcp/mcp.module.js +24 -0
  87. package/server/dist/src/mentions/mention-state.js +80 -0
  88. package/server/dist/src/mentions/mentions.controller.js +100 -0
  89. package/server/dist/src/mentions/mentions.service.js +203 -0
  90. package/server/dist/src/messages/message.presenter.js +102 -0
  91. package/server/dist/src/messages/messages.service.js +149 -0
  92. package/server/dist/src/messages/thread.controller.js +179 -0
  93. package/server/dist/src/participants/directory.service.js +68 -0
  94. package/server/dist/src/participants/participants.module.js +24 -0
  95. package/server/dist/src/participants/participants.service.js +84 -0
  96. package/server/dist/src/settings/settings.service.js +42 -0
  97. package/server/dist/src/sharing/consent.js +16 -0
  98. package/server/dist/src/sharing/sharing.module.js +22 -0
  99. package/server/dist/src/sharing/sharing.service.js +175 -0
  100. package/server/dist/src/team/runner.service.js +75 -0
  101. package/server/dist/src/team/team.controller.js +178 -0
  102. package/server/dist/src/team/team.module.js +25 -0
  103. package/server/dist/src/team/team.service.js +280 -0
  104. package/server/dist/src/web/template.js +57 -0
  105. package/server/dist/src/web/web-login.service.js +74 -0
  106. package/server/dist/src/web/web.controller.js +203 -0
  107. package/server/dist/src/web/web.module.js +25 -0
  108. package/server/drizzle/0000_init.sql +169 -0
  109. package/server/drizzle/0001_seed_roles.sql +25 -0
  110. package/server/drizzle/0002_knowledge.sql +17 -0
  111. package/server/drizzle/0003_sharing.sql +30 -0
  112. package/server/drizzle/meta/0000_snapshot.json +1224 -0
  113. package/server/drizzle/meta/0001_snapshot.json +1224 -0
  114. package/server/drizzle/meta/0002_snapshot.json +1359 -0
  115. package/server/drizzle/meta/0003_snapshot.json +1545 -0
  116. package/server/drizzle/meta/_journal.json +34 -0
  117. package/server/package.json +1 -0
  118. package/server/views/chat.html +865 -0
  119. package/server/views/documents.html +204 -0
  120. package/server/views/login.html +31 -0
  121. package/server/views/setup.html +60 -0
  122. package/server/views/team.html +369 -0
@@ -0,0 +1,229 @@
1
+ // С каким стеком работал агент — по манифестам проекта: языки, фреймворки, базы, тесты,
2
+ // с версиями. Только названия и версии: содержимое проекта сюда не попадает.
3
+ //
4
+ // Каталог проекта может держать несколько репозиториев (у QBazar их три) — тогда стек
5
+ // берётся у тех, чьи файлы агент трогал: задача про витрину не про бэкенд.
6
+
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ const MANIFESTS = ['package.json', 'composer.json', 'pyproject.toml', 'requirements.txt', 'go.mod', 'Cargo.toml', 'Gemfile',
12
+ 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml', 'Dockerfile'];
13
+
14
+ const JS_FRAMEWORKS = {
15
+ next: 'Next.js', react: 'React', vue: 'Vue', nuxt: 'Nuxt', '@angular/core': 'Angular', svelte: 'Svelte', '@sveltejs/kit': 'SvelteKit',
16
+ express: 'Express', fastify: 'Fastify', hono: 'Hono', '@nestjs/core': 'NestJS', koa: 'Koa', 'react-native': 'React Native', expo: 'Expo',
17
+ electron: 'Electron', '@refinedev/core': 'Refine', vite: 'Vite', tailwindcss: 'Tailwind CSS', 'drizzle-orm': 'Drizzle',
18
+ prisma: 'Prisma', '@prisma/client': 'Prisma', typeorm: 'TypeORM', sequelize: 'Sequelize', mongoose: 'Mongoose',
19
+ '@modelcontextprotocol/sdk': 'MCP SDK',
20
+ };
21
+ const JS_DATABASES = {
22
+ pg: 'PostgreSQL', postgres: 'PostgreSQL', '@electric-sql/pglite': 'PostgreSQL (PGlite)', mysql2: 'MySQL', mysql: 'MySQL',
23
+ mongodb: 'MongoDB', mongoose: 'MongoDB', redis: 'Redis', ioredis: 'Redis', 'better-sqlite3': 'SQLite', sqlite3: 'SQLite',
24
+ };
25
+ const JS_TESTS = { jest: 'Jest', vitest: 'Vitest', mocha: 'Mocha', '@playwright/test': 'Playwright', cypress: 'Cypress' };
26
+
27
+ const PHP_PACKAGES = {
28
+ 'laravel/framework': ['frameworks', 'Laravel'], 'symfony/framework-bundle': ['frameworks', 'Symfony'],
29
+ 'filament/filament': ['frameworks', 'Filament'], 'livewire/livewire': ['frameworks', 'Livewire'],
30
+ 'inertiajs/inertia-laravel': ['frameworks', 'Inertia'], 'phpunit/phpunit': ['tests', 'PHPUnit'], 'pestphp/pest': ['tests', 'Pest'],
31
+ };
32
+ const PY_PACKAGES = {
33
+ django: ['frameworks', 'Django'], fastapi: ['frameworks', 'FastAPI'], flask: ['frameworks', 'Flask'],
34
+ sqlalchemy: ['frameworks', 'SQLAlchemy'], pytest: ['tests', 'pytest'], 'psycopg2': ['databases', 'PostgreSQL'], 'psycopg': ['databases', 'PostgreSQL'],
35
+ };
36
+
37
+ /** Образы контейнеров → что это. */
38
+ const IMAGES = [
39
+ [/^(?:library\/)?mysql$/, 'databases', 'MySQL'], [/^(?:library\/)?mariadb$/, 'databases', 'MariaDB'],
40
+ [/^(?:library\/)?postgres$|^postgis\/postgis$|^pgvector\/pgvector$/, 'databases', 'PostgreSQL'],
41
+ [/^(?:library\/)?mongo$/, 'databases', 'MongoDB'], [/^(?:library\/)?redis$|^valkey\/valkey$/, 'databases', 'Redis'],
42
+ [/elasticsearch$/, 'databases', 'Elasticsearch'], [/opensearch$/, 'databases', 'OpenSearch'], [/clickhouse/, 'databases', 'ClickHouse'],
43
+ [/kafka$/, 'infra', 'Kafka'], [/^(?:library\/)?rabbitmq$/, 'infra', 'RabbitMQ'], [/^(?:library\/)?nginx$/, 'infra', 'Nginx'],
44
+ [/^(?:library\/)?(?:php)$/, 'languages', 'PHP'], [/^(?:library\/)?node$/, 'languages', 'Node.js'], [/^(?:library\/)?python$/, 'languages', 'Python'],
45
+ ];
46
+
47
+ /**
48
+ * @param {string} workdir каталог проекта
49
+ * @param {string[]} touched файлы, которые агент читал или менял
50
+ */
51
+ export function detectStack(workdir, touched = []) {
52
+ const stack = new Stack();
53
+ for (const root of projectRoots(workdir, touched)) {
54
+ const read = name => readText(path.join(root, name));
55
+ readPackageJson(stack, read);
56
+ readComposer(stack, read);
57
+ readPython(stack, read);
58
+ readGo(stack, read);
59
+ readRust(stack, read);
60
+ readRuby(stack, read);
61
+ readContainers(stack, read);
62
+ }
63
+ stack.os = osName();
64
+ return stack.result();
65
+ }
66
+
67
+ class Stack {
68
+ groups = { languages: new Map(), frameworks: new Map(), databases: new Map(), tests: new Map() };
69
+ infra = new Set();
70
+ os = null;
71
+
72
+ /** Одно название — одна запись; версия — первая известная. */
73
+ add(group, name, version = null) {
74
+ if (group === 'infra') return void this.infra.add(name);
75
+ const known = this.groups[group].get(name);
76
+ if (!known || (!known.version && version)) this.groups[group].set(name, { name, version: shortVersion(version) });
77
+ }
78
+
79
+ result() {
80
+ const list = group => [...this.groups[group].values()];
81
+ return {
82
+ languages: list('languages'), frameworks: list('frameworks'), databases: list('databases'),
83
+ infra: [...this.infra], tests: list('tests').map(t => t.name), os: this.os,
84
+ };
85
+ }
86
+ }
87
+
88
+ // ------------------------------------------------------------- какие репозитории смотреть
89
+
90
+ /** Корни проектов: сам каталог, если в нём манифест; иначе — репозитории внутри, которых касался агент. */
91
+ function projectRoots(workdir, touched) {
92
+ if (!workdir || !isDir(workdir)) return [];
93
+ if (hasManifest(workdir)) return [workdir];
94
+ const repos = fs.readdirSync(workdir, { withFileTypes: true })
95
+ .filter(d => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
96
+ .map(d => path.join(workdir, d.name))
97
+ .filter(dir => hasManifest(dir) || isDir(path.join(dir, '.git')));
98
+ const used = repos.filter(repo => touched.some(file => path.resolve(workdir, file).startsWith(repo + path.sep)));
99
+ return used.length ? used : repos;
100
+ }
101
+
102
+ function hasManifest(dir) {
103
+ return MANIFESTS.some(name => fs.existsSync(path.join(dir, name)));
104
+ }
105
+
106
+ // ------------------------------------------------------------- манифесты
107
+
108
+ function readPackageJson(stack, read) {
109
+ const pkg = json(read('package.json'));
110
+ if (!pkg) return;
111
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
112
+ const lock = json(read('package-lock.json'))?.packages ?? {};
113
+ const version = name => lock[`node_modules/${name}`]?.version ?? deps[name];
114
+
115
+ stack.add('languages', deps.typescript ? 'TypeScript' : 'JavaScript', deps.typescript ? version('typescript') : null);
116
+ stack.add('languages', 'Node.js', pkg.engines?.node ?? read('.nvmrc')?.trim() ?? null);
117
+ for (const [name, title] of Object.entries(JS_FRAMEWORKS)) if (deps[name]) stack.add('frameworks', title, version(name));
118
+ for (const [name, title] of Object.entries(JS_DATABASES)) if (deps[name]) stack.add('databases', title);
119
+ for (const [name, title] of Object.entries(JS_TESTS)) if (deps[name]) stack.add('tests', title);
120
+ if (/\bnode\s+--test\b/.test(Object.values(pkg.scripts ?? {}).join(' '))) stack.add('tests', 'node:test');
121
+ }
122
+
123
+ function readComposer(stack, read) {
124
+ const composer = json(read('composer.json'));
125
+ if (!composer) return;
126
+ const lock = json(read('composer.lock'));
127
+ const locked = new Map([...(lock?.packages ?? []), ...(lock?.['packages-dev'] ?? [])].map(p => [p.name, p.version]));
128
+ const required = { ...composer.require, ...composer['require-dev'] };
129
+
130
+ stack.add('languages', 'PHP', required.php ?? null);
131
+ for (const [name, [group, title]] of Object.entries(PHP_PACKAGES)) {
132
+ if (required[name] || locked.has(name)) stack.add(group, title, locked.get(name) ?? required[name]);
133
+ }
134
+ }
135
+
136
+ function readPython(stack, read) {
137
+ const pyproject = read('pyproject.toml');
138
+ const requirements = read('requirements.txt');
139
+ if (!pyproject && !requirements) return;
140
+ const pythonVersion = /requires-python\s*=\s*["']([^"']+)/.exec(pyproject ?? '')?.[1] ?? null;
141
+ stack.add('languages', 'Python', pythonVersion);
142
+ const text = `${pyproject ?? ''}\n${requirements ?? ''}`.toLowerCase();
143
+ for (const [name, [group, title]] of Object.entries(PY_PACKAGES)) {
144
+ const match = new RegExp(`(?:^|["'\\s])${name}\\s*(?:[=<>~!]=?\\s*([\\d.]+))?`, 'm').exec(text);
145
+ if (match) stack.add(group, title, match[1] ?? null);
146
+ }
147
+ }
148
+
149
+ function readGo(stack, read) {
150
+ const mod = read('go.mod');
151
+ if (!mod) return;
152
+ stack.add('languages', 'Go', /^go\s+([\d.]+)/m.exec(mod)?.[1] ?? null);
153
+ for (const [module, title] of [['gin-gonic/gin', 'Gin'], ['labstack/echo', 'Echo'], ['gofiber/fiber', 'Fiber']]) {
154
+ const match = new RegExp(`${module}(?:/v\\d+)?\\s+v([\\d.]+)`).exec(mod);
155
+ if (match) stack.add('frameworks', title, match[1]);
156
+ }
157
+ }
158
+
159
+ function readRust(stack, read) {
160
+ const cargo = read('Cargo.toml');
161
+ if (!cargo) return;
162
+ stack.add('languages', 'Rust', /rust-version\s*=\s*"([^"]+)"/.exec(cargo)?.[1] ?? null);
163
+ for (const [crate, title] of [['actix-web', 'Actix Web'], ['axum', 'Axum'], ['tokio', 'Tokio'], ['rocket', 'Rocket']]) {
164
+ const match = new RegExp(`^${crate}\\s*=\\s*(?:"([^"]+)"|\\{[^}]*version\\s*=\\s*"([^"]+)")`, 'm').exec(cargo);
165
+ if (match) stack.add('frameworks', title, match[1] ?? match[2]);
166
+ }
167
+ }
168
+
169
+ function readRuby(stack, read) {
170
+ const gemfile = read('Gemfile');
171
+ if (!gemfile) return;
172
+ stack.add('languages', 'Ruby', /^ruby\s+["']([^"']+)/m.exec(gemfile)?.[1] ?? null);
173
+ const rails = /^gem\s+["']rails["'](?:\s*,\s*["']([^"']+))?/m.exec(gemfile);
174
+ if (rails) stack.add('frameworks', 'Rails', rails[1] ?? null);
175
+ if (/^gem\s+["']rspec/m.test(gemfile)) stack.add('tests', 'RSpec');
176
+ }
177
+
178
+ /** docker-compose и Dockerfile: образы баз и инфраструктуры с версиями из тегов. */
179
+ function readContainers(stack, read) {
180
+ const compose = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'].map(read).filter(Boolean).join('\n');
181
+ const dockerfile = read('Dockerfile') ?? '';
182
+ if (!compose && !dockerfile) return;
183
+ stack.add('infra', 'Docker');
184
+ const images = [
185
+ ...compose.matchAll(/^\s*image:\s*["']?([\w./-]+?)(?::([\w.-]+))?["']?\s*$/gm),
186
+ ...dockerfile.matchAll(/^FROM\s+(?:--platform=\S+\s+)?([\w./-]+?)(?::([\w.-]+))?(?:\s|$)/gim),
187
+ ];
188
+ for (const [, image, tag] of images) {
189
+ const known = IMAGES.find(([pattern]) => pattern.test(image));
190
+ if (known) stack.add(known[1], known[2], tag && /^\d/.test(tag) ? tag : null);
191
+ }
192
+ }
193
+
194
+ // ------------------------------------------------------------- мелочи
195
+
196
+ /** «^11.9.3», «>=8.2», «v20.11.0», «8.0-fpm» → «11.9», «8.2», «20.11», «8.0». */
197
+ function shortVersion(version) {
198
+ const match = /(\d+)(?:\.(\d+))?/.exec(String(version ?? ''));
199
+ return match ? (match[2] !== undefined ? `${match[1]}.${match[2]}` : match[1]) : null;
200
+ }
201
+
202
+ function osName() {
203
+ if (process.platform === 'linux') return /microsoft/i.test(os.release()) ? 'Linux (WSL)' : 'Linux';
204
+ return { darwin: 'macOS', win32: 'Windows' }[process.platform] ?? process.platform;
205
+ }
206
+
207
+ function readText(file) {
208
+ try {
209
+ return fs.readFileSync(file, 'utf8');
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ function json(text) {
216
+ try {
217
+ return text ? JSON.parse(text) : null;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ function isDir(dir) {
224
+ try {
225
+ return fs.statSync(dir).isDirectory();
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
@@ -0,0 +1,82 @@
1
+ // Сжатая запись работы агента — то, что читает нейросеть, когда пишет урок. Шаги, ошибки
2
+ // и итог; длинные выводы урезаны: урок пишется по ходу работы, а не по содержимому файлов.
3
+ // Запись уходит модели того же поставщика, что уже видел эту работу целиком.
4
+
5
+ import path from 'node:path';
6
+ import { scrub } from './scrub.js';
7
+
8
+ const MAX_TOTAL = 24_000;
9
+ const MAX_COMMAND = 240;
10
+ const MAX_OUTPUT = 400;
11
+ const MAX_TEXT = 800;
12
+
13
+ /**
14
+ * @param {string} engine движок
15
+ * @param {string} output построчный вывод агента
16
+ * @param {string[]} questions тексты обращений — с чего началась работа
17
+ */
18
+ export function compactTranscript(engine, output, questions) {
19
+ const lines = [`Задача (сообщения в чате):\n${questions.map(q => `— ${clip(q, MAX_TEXT)}`).join('\n')}`, '', 'Ход работы:'];
20
+ const writer = WRITERS[engine];
21
+ if (writer) for (const event of events(output)) writer(event, line => lines.push(line));
22
+
23
+ let text = scrub(lines.join('\n'));
24
+ if (text.length > MAX_TOTAL) {
25
+ // Начало (задача, первые шаги) и конец (исправление, проверка, итог) важнее середины.
26
+ text = `${text.slice(0, MAX_TOTAL * 0.4)}\n…[пропущено]…\n${text.slice(-MAX_TOTAL * 0.6)}`;
27
+ }
28
+ return text;
29
+ }
30
+
31
+ const WRITERS = {
32
+ claude(event, write) {
33
+ for (const part of event.message?.content ?? []) {
34
+ if (part.type === 'text' && part.text?.trim()) write(`агент: ${clip(part.text, MAX_TEXT)}`);
35
+ if (part.type === 'tool_use' && part.name !== 'ToolSearch') write(`→ ${describeClaudeTool(part)}`);
36
+ if (part.type === 'tool_result') {
37
+ const out = typeof part.content === 'string' ? part.content
38
+ : (part.content ?? []).map(c => c?.text ?? '').join('\n');
39
+ write(` ${part.is_error ? 'ошибка' : 'результат'}: ${clip(out, MAX_OUTPUT)}`);
40
+ }
41
+ }
42
+ if (event.type === 'result' && event.result) write(`итог: ${clip(event.result, MAX_TEXT)}`);
43
+ },
44
+
45
+ codex(event, write) {
46
+ if (event.type !== 'item.completed') return;
47
+ const item = event.item ?? {};
48
+ if (item.type === 'agent_message') write(`агент: ${clip(item.text, MAX_TEXT)}`);
49
+ if (item.type === 'command_execution') {
50
+ write(`→ команда: ${clip(item.command, MAX_COMMAND)}`);
51
+ write(` код выхода ${item.exit_code}: ${clip(item.aggregated_output, MAX_OUTPUT)}`);
52
+ }
53
+ if (item.type === 'file_change') {
54
+ write(`→ правка файлов: ${(item.changes ?? []).map(c => `${path.basename(c.path ?? '')} (${c.kind})`).join(', ')}`);
55
+ }
56
+ if (item.type === 'mcp_tool_call') write(`→ инструмент чата: ${item.tool}`);
57
+ },
58
+ };
59
+
60
+ function describeClaudeTool(part) {
61
+ const input = part.input ?? {};
62
+ if (part.name === 'Bash') return `команда: ${clip(input.command, MAX_COMMAND)}`;
63
+ const file = input.file_path ?? input.path ?? input.notebook_path;
64
+ if (file) return `${part.name}: ${path.basename(file)}`;
65
+ if (input.pattern) return `${part.name}: ${clip(input.pattern, MAX_COMMAND)}`;
66
+ return part.name;
67
+ }
68
+
69
+ function clip(text, max) {
70
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
71
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
72
+ }
73
+
74
+ function* events(output) {
75
+ for (const line of String(output ?? '').split('\n')) {
76
+ try {
77
+ if (line.trim()) yield JSON.parse(line);
78
+ } catch {
79
+ // служебная строка движка
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,120 @@
1
+ // Шлюз к HTTP API чата. Мост и раннер ходят в чат только через него: адреса, заголовки
2
+ // и разбор ошибок — в одном месте.
3
+
4
+ /** Чат не ответил: сеть, таймаут. Не сбой прохода, а повод подождать и повторить. */
5
+ export class ChatUnavailableError extends Error {
6
+ constructor(reason, options) {
7
+ super(`чат недоступен (${reason})`, options);
8
+ this.reason = reason;
9
+ }
10
+ }
11
+
12
+ /** Чат ответил отказом. */
13
+ export class ChatHttpError extends Error {
14
+ constructor(status, method, path, body) {
15
+ super(`чат ответил ${status} на ${method} ${path}`);
16
+ this.status = status;
17
+ this.body = body;
18
+ }
19
+ }
20
+
21
+ export class ChatApi {
22
+ #url;
23
+ #token;
24
+ #timeoutMs;
25
+
26
+ /** @param {{url: string, token: string, timeoutMs?: number}} options url — с /api на конце */
27
+ constructor({ url, token, timeoutMs = 30_000 }) {
28
+ this.#url = url.replace(/\/+$/, '');
29
+ this.#token = token;
30
+ this.#timeoutMs = timeoutMs;
31
+ }
32
+
33
+ // --- обращения: по всем чатам сразу, мост у агента один ---
34
+
35
+ async mentions() {
36
+ return (await this.#request('GET', '/mentions')).mentions;
37
+ }
38
+
39
+ /** Подтвердить обращение: гасит его. Чтение обращение не гасит — только это. */
40
+ async acknowledge(mentionId) {
41
+ await this.#request('POST', `/mentions/${mentionId}/handled`);
42
+ }
43
+
44
+ // --- лента и настройки чатов ---
45
+
46
+ /**
47
+ * Страница ленты чата: после afterId по возрастанию id, иначе последние limit.
48
+ * @param {string|null} chat slug; null — чат по умолчанию (сервер старше нескольких чатов)
49
+ */
50
+ async messages(chat, { afterId, limit } = {}) {
51
+ const query = new URLSearchParams();
52
+ if (afterId !== undefined) query.set('after_id', String(afterId));
53
+ if (limit !== undefined) query.set('limit', String(limit));
54
+ return (await this.#request('GET', `${scope(chat)}/messages?${query}`)).messages;
55
+ }
56
+
57
+ async chats() {
58
+ return (await this.#request('GET', '/chats')).chats;
59
+ }
60
+
61
+ // --- опыт агентов ---
62
+
63
+ /** Уроки, похожие на запрос: по всем чатам, уроки этого чата выше. */
64
+ async knowledge(chat, query, { limit = 3 } = {}) {
65
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
66
+ return (await this.#request('GET', `${scope(chat)}/knowledge?${params}`)).lessons;
67
+ }
68
+
69
+ /** Карточка опыта запуска; та же запись запуска (key) обновляет ту же карточку. */
70
+ async saveCard(chat, key, card) {
71
+ await this.#request('POST', `${scope(chat)}/knowledge`, { key, card });
72
+ }
73
+
74
+ /** Ход обработки обращений: working | done | failed | deferred | paused. */
75
+ async report({ state, mentions, note, ttl }) {
76
+ const body = { state, mentions };
77
+ if (note) body.note = note.slice(0, 200);
78
+ if (ttl) body.ttl = ttl;
79
+ await this.#request('POST', '/activity', body);
80
+ }
81
+
82
+ // --- раннер: только для участника host ---
83
+
84
+ /** Агенты, которых раннер должен держать, с учётом движков этой машины. */
85
+ async runnerAgents(runtimes) {
86
+ return (await this.#request('GET', `/runner/agents?runtimes=${runtimes.map(encodeURIComponent).join(',')}`)).agents;
87
+ }
88
+
89
+ /** Токен агента для его моста. Постоянный токен агента при этом не меняется. */
90
+ async agentToken(handle) {
91
+ return (await this.#request('POST', `/runner/agents/${encodeURIComponent(handle)}/token`)).token;
92
+ }
93
+
94
+ async #request(method, path, body) {
95
+ let response;
96
+ try {
97
+ response = await fetch(this.#url + path, {
98
+ method,
99
+ headers: {
100
+ Authorization: `Bearer ${this.#token}`,
101
+ Accept: 'application/json',
102
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
103
+ },
104
+ body: body ? JSON.stringify(body) : undefined,
105
+ signal: AbortSignal.timeout(this.#timeoutMs),
106
+ });
107
+ } catch (e) {
108
+ throw new ChatUnavailableError(e.cause?.code ?? e.cause?.message ?? e.message, { cause: e });
109
+ }
110
+
111
+ const text = await response.text();
112
+ if (!response.ok) throw new ChatHttpError(response.status, method, path, text);
113
+ return text ? JSON.parse(text) : {};
114
+ }
115
+ }
116
+
117
+ /** Префикс API чата. Без чата пути ведут в чат по умолчанию — так было, пока чат был один. */
118
+ function scope(chat) {
119
+ return chat ? `/chats/${encodeURIComponent(chat)}` : '';
120
+ }
@@ -0,0 +1,6 @@
1
+ // Журнал процесса: время и сообщение в stderr. В файл его направляет тот, кто запустил
2
+ // процесс, — юнит systemd или раннер.
3
+
4
+ export function log(...parts) {
5
+ console.error(new Date().toTimeString().slice(0, 8), ...parts);
6
+ }
@@ -0,0 +1,65 @@
1
+ // Замок «один процесс на файл»: файл с pid держателя.
2
+ //
3
+ // Мосту он нужен, чтобы на одного агента был один мост: два моста видят одно и то же
4
+ // открытое обращение и оба запускают агента (так 27.09 на #95 Codex запустился дважды).
5
+ //
6
+ // flock в Node нет, поэтому замок сам не снимается со смертью процесса: занятый файл
7
+ // с pid мёртвого процесса считается брошенным и забирается. Окно гонки остаётся только
8
+ // между двумя процессами, которые в одну и ту же миллисекунду забирают один брошенный
9
+ // замок, — для мостов, которые запускает раннер или systemd, такого не бывает.
10
+
11
+ import fs from 'node:fs';
12
+
13
+ export class LockBusyError extends Error {
14
+ constructor(file, pid) {
15
+ super(`${file} занят процессом ${pid}`);
16
+ this.pid = pid;
17
+ }
18
+ }
19
+
20
+ export class PidLock {
21
+ #file;
22
+
23
+ constructor(file) {
24
+ this.#file = file;
25
+ }
26
+
27
+ /** @throws {LockBusyError} если держатель жив */
28
+ static acquire(file) {
29
+ for (let attempt = 0; attempt < 2; attempt++) {
30
+ try {
31
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
32
+ return new PidLock(file);
33
+ } catch (e) {
34
+ if (e.code !== 'EEXIST') throw e;
35
+ }
36
+ const holder = readPid(file);
37
+ if (holder !== null && isAlive(holder)) throw new LockBusyError(file, holder);
38
+ fs.rmSync(file, { force: true });
39
+ }
40
+ throw new LockBusyError(file, readPid(file));
41
+ }
42
+
43
+ /** Отпустить, если замок всё ещё наш. */
44
+ release() {
45
+ if (readPid(this.#file) === process.pid) fs.rmSync(this.#file, { force: true });
46
+ }
47
+ }
48
+
49
+ function readPid(file) {
50
+ try {
51
+ const pid = Number.parseInt(fs.readFileSync(file, 'utf8').trim(), 10);
52
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function isAlive(pid) {
59
+ try {
60
+ process.kill(pid, 0);
61
+ return true;
62
+ } catch (e) {
63
+ return e.code === 'EPERM'; // процесс есть, но чужой
64
+ }
65
+ }
@@ -0,0 +1,45 @@
1
+ // Командная строка агента из runner.json → слова для spawn. Оболочку не зовём: кавычки
2
+ // и обратная косая работают как в POSIX-оболочке, а подстановки ($, `, *) — нет.
3
+ // Те же правила, что у shlex.split в Python, которым это делал мост раньше.
4
+
5
+ export class ShellWordsError extends Error {}
6
+
7
+ /** @returns {string[]} */
8
+ export function splitWords(line) {
9
+ const words = [];
10
+ let word = null; // null — слова ещё нет; '' — есть, но пустое ("")
11
+ let quote = null;
12
+
13
+ for (let i = 0; i < line.length; i++) {
14
+ const c = line[i];
15
+
16
+ if (quote === "'") {
17
+ if (c === "'") quote = null;
18
+ else word += c;
19
+ } else if (quote === '"') {
20
+ if (c === '"') quote = null;
21
+ // Внутри двойных кавычек косая экранирует только кавычку и саму себя.
22
+ else if (c === '\\' && (line[i + 1] === '"' || line[i + 1] === '\\')) word += line[++i];
23
+ else word += c;
24
+ } else if (/\s/.test(c)) {
25
+ if (word !== null) words.push(word);
26
+ word = null;
27
+ } else {
28
+ word ??= '';
29
+ if (c === "'" || c === '"') quote = c;
30
+ else if (c === '\\') {
31
+ if (i + 1 === line.length) throw new ShellWordsError(`команда кончается обратной косой: ${line}`);
32
+ word += line[++i];
33
+ } else word += c;
34
+ }
35
+ }
36
+
37
+ if (quote) throw new ShellWordsError(`незакрытая кавычка ${quote} в команде: ${line}`);
38
+ if (word !== null) words.push(word);
39
+ return words;
40
+ }
41
+
42
+ /** Слово в одинарных кавычках — обратное к splitWords. */
43
+ export function quoteWord(word) {
44
+ return /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", `'\\''`)}'`;
45
+ }
@@ -0,0 +1,82 @@
1
+ // Мост одного агента — дочерний процесс раннера со своей группой процессов. Агент,
2
+ // которого запускает мост, живёт в той же группе, поэтому остановка моста гасит и его:
3
+ // иначе агент остался бы сиротой и дописал бы ответ от имени остановленного моста.
4
+
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+
8
+ /** Сколько ждать мост после SIGTERM, прежде чем убить. */
9
+ const GRACE_MS = 20_000;
10
+
11
+ export class BridgeProcess {
12
+ #child;
13
+ #exit;
14
+ #exitCode = null;
15
+
16
+ /**
17
+ * @param {object} p
18
+ * @param {string} p.handle
19
+ * @param {string} p.runtime движок, на котором запущен агент
20
+ * @param {string[]} p.command
21
+ * @param {NodeJS.ProcessEnv} p.env
22
+ * @param {string} p.logFile вывод моста дописывается сюда
23
+ * @param {number} p.startedAt
24
+ */
25
+ constructor({ handle, runtime, command, env, logFile, startedAt }) {
26
+ this.handle = handle;
27
+ this.runtime = runtime;
28
+ this.startedAt = startedAt;
29
+
30
+ const out = fs.openSync(logFile, 'a');
31
+ try {
32
+ const [cmd, ...args] = command;
33
+ this.#child = spawn(cmd, args, { env, stdio: ['ignore', out, out], detached: true });
34
+ } finally {
35
+ fs.closeSync(out);
36
+ }
37
+ this.#exit = new Promise(resolve => {
38
+ // Не запустился (error) или завершился (exit) — для раннера одно: моста нет.
39
+ const done = code => {
40
+ this.#exitCode ??= code;
41
+ resolve();
42
+ };
43
+ this.#child.once('error', () => done(127));
44
+ this.#child.once('exit', (code, signal) => done(code ?? signal));
45
+ });
46
+ }
47
+
48
+ get pid() {
49
+ return this.#child.pid;
50
+ }
51
+
52
+ /** Код выхода или сигнал, пока мост жив — null. */
53
+ get exitCode() {
54
+ return this.#exitCode;
55
+ }
56
+
57
+ alive() {
58
+ return this.#exitCode === null;
59
+ }
60
+
61
+ async stop() {
62
+ if (!this.alive()) return;
63
+ this.#signal('SIGTERM');
64
+ const timer = setTimeout(() => this.#signal('SIGKILL'), GRACE_MS);
65
+ await this.#exit;
66
+ clearTimeout(timer);
67
+ }
68
+
69
+ #signal(signal) {
70
+ // На Windows групп процессов нет: дерево моста гасит taskkill, сразу и насмерть.
71
+ if (process.platform === 'win32') {
72
+ spawnSync('taskkill', ['/pid', String(this.#child.pid), '/T', '/F'], { stdio: 'ignore' });
73
+ return;
74
+ }
75
+ try {
76
+ // Минус — вся группа: мост и его агент.
77
+ process.kill(-this.#child.pid, signal);
78
+ } catch {
79
+ // уже нет
80
+ }
81
+ }
82
+ }