@alfacapital-tech/c4builder 0.9.0-rc.3 → 0.9.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/README.MD CHANGED
@@ -31,6 +31,9 @@ than a hundred diagrams, so we continue it as a maintained fork, adapted to work
31
31
  and lays it out with horizontal pools and lanes.
32
32
  - **Mermaid** `.mmd` files and ```` ```mermaid ```` blocks are rendered at build time by real mermaid,
33
33
  without a browser: images in every output instead of client-side rendering.
34
+ - **Review from the page.** In `c4builder --site -w` a selection becomes a question in the source
35
+ `.md` or feedback for the agent; `c4builder feedback --wait --as <name>` hands the batch to the
36
+ agent, and the page reloads with its edits.
34
37
  - **Maintained.** TypeScript, golden rendering snapshots, CI on every pull request, releases to
35
38
  npm and GHCR. The PDF output of the original was removed.
36
39
 
@@ -1,11 +1,20 @@
1
1
  import chalk from 'chalk';
2
2
  import fs from 'node:fs';
3
3
  import { parseConfig } from "../../config/options.js";
4
+ import { LISTENER_NAME } from "../../core/feedback.js";
4
5
  import { FEEDBACK_HEADER, FEEDBACK_PATH } from "./site.comments.js";
5
6
  // `c4builder feedback` (change site-agent-feedback, design D9): забирает пачку замечаний у `site -w`.
6
7
  // Конфиг читается как JSON без Configstore — тот создал бы `.c4builder` в любом каталоге. Ошибки —
7
8
  // через exitCode, а не process.exit: напечатанная в pipe пачка должна успеть уйти целиком.
8
9
  export default async (configPath, opts) => {
10
+ // Имя обязательно и без умолчания (change feedback-mailboxes, D2): с ним два агента со старой
11
+ // инструкцией молча делили бы один ящик. Код 2, а не 1: код 1 инструкции агентов читают как
12
+ // «нет сервера, перезапусти сайт».
13
+ if (!opts.as || !LISTENER_NAME.test(opts.as)) {
14
+ console.error(chalk.red('--as <name> is required: name this listener after your change or task (1-64 letters, digits, ".", "_" or "-"), e.g. "c4builder feedback --wait --as add-dark-mode"; use the same name after each batch'));
15
+ process.exitCode = 2;
16
+ return;
17
+ }
9
18
  let raw;
10
19
  try {
11
20
  raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
@@ -20,7 +29,7 @@ export default async (configPath, opts) => {
20
29
  };
21
30
  // 127.0.0.1, а не localhost: сервер слушает только IPv4, а `localhost` → `::1` на части машин
22
31
  // подвешивает соединение.
23
- const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next${opts.wait ? '' : '?wait=0'}`;
32
+ const url = `http://127.0.0.1:${port}${FEEDBACK_PATH}/next?as=${opts.as}${opts.wait ? '' : '&wait=0'}`;
24
33
  for (;;) {
25
34
  let res;
26
35
  try {
@@ -40,6 +49,7 @@ export default async (configPath, opts) => {
40
49
  }
41
50
  if (res.status === 404)
42
51
  return fail(`agent feedback is disabled on ${shown} — the server runs without -w or with --no-agent`);
43
- return fail(`unexpected response ${res.status} from ${shown}`);
52
+ const error = await res.json().then((body) => body?.error, () => undefined);
53
+ return fail(`unexpected response ${res.status} from ${shown}${typeof error === 'string' ? `: ${error}` : ''}`);
44
54
  }
45
55
  };
@@ -22,6 +22,10 @@ const CSS = [
22
22
  '.c4b-review{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:1000;width:520px;max-width:calc(100vw - 32px);box-sizing:border-box;padding:8px 12px;border:1px solid #3b82f6;border-radius:6px;background:#fff;color:#2c3e50;font:13px/1.4 sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.2)}',
23
23
  '.c4b-review .c4b-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px}',
24
24
  '.c4b-review .c4b-row small{flex:1;color:#888;white-space:nowrap}',
25
+ // Выбор адресата сжимается вместе с select: длинные имена не выталкивают кнопки из строки.
26
+ '.c4b-review .c4b-to{flex:1;min-width:0;display:flex;align-items:center;gap:4px;color:#888;white-space:nowrap}',
27
+ '.c4b-review .c4b-to[hidden]{display:none}',
28
+ '.c4b-review select{min-width:0;max-width:100%;padding:2px;border:1px solid #ccc;border-radius:4px;background:#fff;color:#2c3e50;font:13px sans-serif}',
25
29
  // Кнопки панели переносятся одной группой: иначе при нехватке места часть уезжает на
26
30
  // вторую строку, а Discard остаётся один в первой.
27
31
  '.c4b-review .c4b-acts{display:flex;gap:6px;margin-left:auto}',
@@ -49,7 +53,7 @@ const jsValue = (v) => JSON.stringify(v)
49
53
  // Начальное состояние агентского режима и ключ проекта вставляет сервер, дальше его обновляют
50
54
  // события `c4builder:agent` от сниппета livereload (change site-agent-feedback, design D4, D8).
51
55
  // Язык выбирает сам скрипт: при `auto` — по navigator.languages (change site-ui-language, D1).
52
- export const commentsSnippet = (heading, { agent = false, listening = false, project = '', language = 'auto' } = {}) => `
56
+ export const commentsSnippet = (heading, { agent = false, mailboxes = [], project = '', language = 'auto' } = {}) => `
53
57
  <script>
54
58
  (function () {
55
59
  if (window.__c4builderComments) return;
@@ -59,7 +63,8 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
59
63
  var HEADING = ${jsValue(heading)};
60
64
  var TOAST_KEY = '__c4builderCommentToast';
61
65
  var DRAFT_KEY = '__c4builderReview:' + ${jsValue(project)};
62
- var AGENT = { on: ${agent}, listening: ${listening} };
66
+ var TARGET_KEY = '__c4builderTarget:' + ${jsValue(project)};
67
+ var AGENT = { on: ${agent}, mailboxes: ${jsValue(mailboxes)} };
63
68
  var BLOCKS = 'p,li,td,th,h1,h2,h3,h4,h5,h6,pre,blockquote,dd,dt,div,section,details,summary';
64
69
  var style = document.createElement('style');
65
70
  style.textContent = ${jsValue(CSS)};
@@ -378,40 +383,65 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
378
383
  return function (err) { toast(t(key, { error: err && err.message ? err.message : err }), true); };
379
384
  };
380
385
 
386
+ // The tab's recipient lives in sessionStorage: it survives livereload, is per tab and is
387
+ // copied with a duplicated tab. Recipients are the server's mailboxes plus the tab's own.
388
+ var target = '';
389
+ try { target = sessionStorage.getItem(TARGET_KEY) || ''; } catch (e) {}
390
+ var recipients = function () {
391
+ var all = AGENT.mailboxes.slice();
392
+ var known = all.some(function (m) { return m.name === target; });
393
+ if (target && !known) all.push({ name: target, state: 'reconnecting', queued: 0 });
394
+ return all;
395
+ };
396
+ // Without mailboxes a batch goes to whoever connects first; with several, only to a chosen one.
397
+ var canSend = function () { return !!target || !AGENT.mailboxes.length; };
398
+ var named = function (key, name) { return name ? t(key + 'To', { name: name }) : t(key); };
399
+
381
400
  // Feedback to the agent: "Delivered" only after the server handed the batch to
382
401
  // "c4builder feedback"; the event may outrun the POST response, hence both maps.
383
402
  var delivered = {}, pending = {};
384
403
  var sendBatch = function (decision, comments, general) {
385
- return send({ decision: decision, comments: comments, general: general }, 'POST', FEEDBACK).then(function (r) {
386
- if (delivered[r.id]) return toast(t('delivered'));
404
+ var body = { decision: decision, comments: comments, general: general, to: target || undefined };
405
+ return send(body, 'POST', FEEDBACK).then(function (r) {
406
+ if (r.id in delivered) return toast(named('delivered', delivered[r.id]));
387
407
  pending[r.id] = true;
388
- toast(t(r.queued ? 'queued' : 'sent'));
408
+ toast(named(r.queued ? 'queued' : 'sent', r.to));
389
409
  });
390
410
  };
391
411
  window.addEventListener('c4builder:delivered', function (e) {
392
- var id = e.detail && e.detail.id;
393
- delivered[id] = true;
394
- if (pending[id]) { delete pending[id]; toast(t('delivered')); }
412
+ var d = e.detail || {};
413
+ delivered[d.id] = d.to || '';
414
+ if (pending[d.id]) { delete pending[d.id]; toast(named('delivered', d.to)); }
395
415
  });
396
416
 
397
- // Review draft lives in localStorage per project: it survives livereload and a closed tab;
398
- // without storage it lives until the page reloads.
399
- var memDraft = [];
417
+ // Review draft lives in localStorage per project and recipient: it survives livereload and a
418
+ // closed tab, and a tab never sends comments typed for another agent. Without storage it lives
419
+ // until the page reloads. Without a recipient the rc key is used, so earlier drafts survive.
420
+ var memDraft = {};
421
+ var draftKey = function () { return DRAFT_KEY + (target ? ':' + target : ''); };
400
422
  var draft = function () {
401
423
  try {
402
- var v = JSON.parse(localStorage.getItem(DRAFT_KEY) || '[]');
424
+ var v = JSON.parse(localStorage.getItem(draftKey()) || '[]');
403
425
  return Array.isArray(v) ? v : [];
404
- } catch (e) { return memDraft; }
426
+ } catch (e) { return memDraft[draftKey()] || []; }
405
427
  };
406
428
  var saveDraft = function (items) {
407
- memDraft = items;
429
+ memDraft[draftKey()] = items;
408
430
  try {
409
- if (items.length) localStorage.setItem(DRAFT_KEY, JSON.stringify(items));
410
- else localStorage.removeItem(DRAFT_KEY);
431
+ if (items.length) localStorage.setItem(draftKey(), JSON.stringify(items));
432
+ else localStorage.removeItem(draftKey());
411
433
  } catch (e) {}
412
434
  renderPanel();
413
435
  highlightDraft();
414
436
  };
437
+ // A draft started before the tab had a recipient goes to its first recipient.
438
+ var setTarget = function (name) {
439
+ var orphan = target ? [] : draft();
440
+ if (orphan.length) saveDraft([]);
441
+ target = name;
442
+ try { sessionStorage.setItem(TARGET_KEY, name); } catch (e) {}
443
+ saveDraft(draft().concat(orphan));
444
+ };
415
445
  var highlightDraft = function () {
416
446
  var root = content();
417
447
  if (!root) return;
@@ -436,6 +466,12 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
436
466
  var count = button('', function () { expanded = !expanded; renderPanel(); }, 'c4b-link');
437
467
  var listen = document.createElement('small');
438
468
  listen.className = 'c4b-listen';
469
+ var sendTo = document.createElement('label');
470
+ sendTo.className = 'c4b-to';
471
+ sendTo.textContent = t('sendTo');
472
+ var select = document.createElement('select');
473
+ select.addEventListener('change', function () { setTarget(select.value); renderAgent(); });
474
+ sendTo.appendChild(select);
439
475
  var list = document.createElement('ul');
440
476
  var general = document.createElement('textarea');
441
477
  general.placeholder = t('generalPlaceholder');
@@ -474,7 +510,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
474
510
  var acts = document.createElement('span');
475
511
  acts.className = 'c4b-acts';
476
512
  [discard, approve, submit].forEach(function (el) { acts.appendChild(el); });
477
- [count, listen, acts].forEach(function (el) { head.appendChild(el); });
513
+ [count, listen, sendTo, acts].forEach(function (el) { head.appendChild(el); });
478
514
  panel.appendChild(head);
479
515
  panel.appendChild(list);
480
516
  panel.appendChild(general);
@@ -506,28 +542,51 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
506
542
  approve.className = empty ? 'c4b-primary' : '';
507
543
  discard.hidden = submit.hidden = empty;
508
544
  discard.disabled = busy;
509
- approve.disabled = busy;
510
- submit.disabled = busy || empty;
545
+ approve.disabled = busy || !canSend();
546
+ submit.disabled = busy || empty || !canSend();
547
+ };
548
+ var label = function (m) {
549
+ return t(m.state === 'listening' ? 'stateListening' : 'stateReconnecting', { name: m.name }) +
550
+ (m.queued ? ', ' + t('inQueue', { n: m.queued }) : '');
511
551
  };
552
+ // The popover's send button follows the recipient too; set while the popover is open.
553
+ var syncPop = null;
512
554
  var renderAgent = function () {
555
+ // A lone mailbox is chosen and remembered: the tab stays with it when a second agent comes.
556
+ if (!target && AGENT.mailboxes.length === 1) setTarget(AGENT.mailboxes[0].name);
513
557
  fbBtn.hidden = !AGENT.on;
514
- renderPanel();
515
- var text = t(AGENT.listening ? 'listening' : 'notListening');
558
+ var all = recipients(), current = null;
559
+ all.forEach(function (m) { if (m.name === target) current = m; });
560
+ var text = current ? label(current) : t(all.length ? 'chooseAgent' : 'notListening');
516
561
  var els = document.querySelectorAll('.c4b-listen');
517
562
  for (var i = 0; i < els.length; i++) { els[i].textContent = text; els[i].title = text; }
563
+ listen.hidden = all.length > 1;
564
+ sendTo.hidden = !listen.hidden;
565
+ select.textContent = '';
566
+ if (!target) all.unshift({ name: '', label: t('chooseAgent') });
567
+ all.forEach(function (m) {
568
+ var o = document.createElement('option');
569
+ o.value = m.name;
570
+ o.textContent = m.label || label(m);
571
+ select.appendChild(o);
572
+ });
573
+ select.value = target;
574
+ renderPanel();
575
+ if (syncPop) syncPop();
518
576
  };
519
577
  window.addEventListener('c4builder:agent', function (e) {
520
578
  var d = e.detail || {};
521
579
  AGENT.on = !!d.on;
522
- AGENT.listening = !!d.listening;
580
+ AGENT.mailboxes = Array.isArray(d.mailboxes) ? d.mailboxes : [];
523
581
  renderAgent();
524
582
  });
525
583
  window.addEventListener('storage', function (e) {
526
- if (e.key === DRAFT_KEY) { renderPanel(); highlightDraft(); }
584
+ if (e.key === draftKey()) { renderPanel(); highlightDraft(); }
527
585
  });
528
586
 
529
587
  var close = function () {
530
588
  if (pop) { pop.remove(); pop = null; }
589
+ syncPop = null;
531
590
  btns.hidden = true;
532
591
  };
533
592
  var open = function (data, rect, feedback) {
@@ -545,13 +604,19 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
545
604
  else hint.textContent = hint.title = t('questionTo', { target: HEADING + (data.section ? ' / ' + data.section : '') });
546
605
  var actions = document.createElement('span');
547
606
  var buttons = [button(t('cancel'), close)];
607
+ var sending = false, now = null;
608
+ var sync = function () {
609
+ buttons.forEach(function (b) { b.disabled = sending || (b === now && !canSend()); });
610
+ };
548
611
  var run = function (action) {
549
612
  return function () {
550
613
  var comment = ta.value.trim();
551
614
  if (!comment) { ta.focus(); return; }
552
- buttons.forEach(function (b) { b.disabled = true; });
615
+ sending = true;
616
+ sync();
553
617
  action(comment).then(close, function (err) {
554
- buttons.forEach(function (b) { b.disabled = false; });
618
+ sending = false;
619
+ sync();
555
620
  failure(feedback ? 'feedbackFailed' : 'questionFailed')(err);
556
621
  });
557
622
  };
@@ -567,7 +632,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
567
632
  saveDraft(draft().concat([it]));
568
633
  return Promise.resolve();
569
634
  }));
570
- var now = button(t('sendNow'), run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
635
+ now = button(t('sendNow'), run(function (c) { return sendBatch('submitted', [item(c)], ''); }));
571
636
  buttons.push(add, now);
572
637
  // As in a GitLab merge request: once a review is started, comments go into it.
573
638
  primary = draft().length ? add : now;
@@ -592,6 +657,7 @@ export const commentsSnippet = (heading, { agent = false, listening = false, pro
592
657
  pop.appendChild(ta);
593
658
  pop.appendChild(row);
594
659
  document.body.appendChild(pop);
660
+ syncPop = sync;
595
661
  renderAgent();
596
662
  place(pop, rect);
597
663
  ta.focus();
@@ -15,6 +15,9 @@ export const UI_STRINGS = {
15
15
  sent: 'Sent to the agent',
16
16
  delivered: 'Delivered to the agent',
17
17
  queued: 'Queued — agent is not listening',
18
+ sentTo: 'Sent to {name}',
19
+ deliveredTo: 'Delivered to {name}',
20
+ queuedTo: 'Queued for {name} — not listening',
18
21
  reviewFailed: 'Review failed: {error}',
19
22
  feedbackFailed: 'Feedback failed: {error}',
20
23
  questionFailed: 'Question failed: {error}',
@@ -32,8 +35,12 @@ export const UI_STRINGS = {
32
35
  comments: { one: '{n} pending', other: '{n} pending' },
33
36
  pages: { one: '{n} page', other: '{n} pages' },
34
37
  removeFromReview: 'Remove from the review',
35
- listening: 'agent: listening ●',
36
38
  notListening: 'agent: not listening ○',
39
+ stateListening: '{name}: listening ●',
40
+ stateReconnecting: '{name}: reconnecting ○',
41
+ inQueue: '{n} queued',
42
+ sendTo: 'Send to',
43
+ chooseAgent: 'choose an agent',
37
44
  feedbackPlaceholder: 'What should the agent change? (Ctrl+Enter to send, Esc to cancel)',
38
45
  questionPlaceholder: 'Your question (Ctrl+Enter to send, Esc to cancel)',
39
46
  questionTo: 'To: {target}',
@@ -54,6 +61,9 @@ export const UI_STRINGS = {
54
61
  sent: 'Отправлено агенту',
55
62
  delivered: 'Доставлено агенту',
56
63
  queued: 'В очереди: агент не слушает',
64
+ sentTo: 'Отправлено агенту {name}',
65
+ deliveredTo: 'Доставлено агенту {name}',
66
+ queuedTo: 'В очереди у {name}: агент не слушает',
57
67
  reviewFailed: 'Не удалось отправить ревью: {error}',
58
68
  feedbackFailed: 'Не удалось отправить замечание: {error}',
59
69
  questionFailed: 'Не удалось добавить вопрос: {error}',
@@ -75,8 +85,12 @@ export const UI_STRINGS = {
75
85
  comments: { one: '{n} замечание', few: '{n} замечания', many: '{n} замечаний' },
76
86
  pages: { one: 'на {n} странице', few: 'на {n} страницах', many: 'на {n} страницах' },
77
87
  removeFromReview: 'Убрать из ревью',
78
- listening: 'агент: слушает ●',
79
88
  notListening: 'агент: не слушает ○',
89
+ stateListening: '{name}: слушает ●',
90
+ stateReconnecting: '{name}: переподключается ○',
91
+ inQueue: 'в очереди: {n}',
92
+ sendTo: 'Кому',
93
+ chooseAgent: 'выберите агента',
80
94
  feedbackPlaceholder: 'Что должен изменить агент? (Ctrl+Enter — отправить, Esc — отмена)',
81
95
  questionPlaceholder: 'Ваш вопрос (Ctrl+Enter — отправить, Esc — отмена)',
82
96
  questionTo: 'Куда: {target}',
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import fs from 'node:fs';
5
5
  import express from 'express';
6
6
  import open from 'open';
7
- import { batchSchema, formatBatch } from "../../core/feedback.js";
7
+ import { batchSchema, formatBatch, LISTENER_NAME } from "../../core/feedback.js";
8
8
  import { deleteQuestion, formatQuestion, pickSource, writeQuestion } from "../../core/questions.js";
9
9
  import { packageJson as pkg } from "../../util/paths.js";
10
10
  import { pendingUpdate } from "../update.js";
@@ -103,20 +103,51 @@ pages = () => new Map()) => {
103
103
  // Кеш index.html — отдаём его из памяти, если на диске его временно нет
104
104
  // (в окне build.js → emptyDir(DIST_FOLDER) перед записью).
105
105
  let lastIndexHtml = null;
106
+ let sweeper;
106
107
  // reloadEmitter в условии — сужает тип до EventEmitter внутри блока (liveReloadEnabled
107
108
  // истинно ⟺ reloadEmitter задан), поэтому дальше без non-null assertions.
108
109
  if (liveReloadEnabled && reloadEmitter) {
109
- // Состояние слушателя `c4builder feedback` (design D4): подключён, пока открыт long-poll
110
- // или с конца последнего прошло меньше idleMs — зазор между повторами команды.
111
- let listening = false;
112
- const agentState = () => ({ on: agent.on, listening });
113
- const setAgent = (on, listen) => {
114
- if (on === agent.on && listen === listening)
110
+ // Ящики слушателей `c4builder feedback --as <name>` (change feedback-mailboxes, D3–D4).
111
+ // Слушает, пока открыт long-poll или с конца последнего прошло меньше idleMs — зазор между
112
+ // повторами команды. Пачки без адресата (ящиков ещё не было) достаются любому имени.
113
+ const { pollMs = 25_000, idleMs = 30_000, mailboxTtlMs = 30 * 60_000 } = program;
114
+ const mailboxes = new Map();
115
+ const unaddressed = [];
116
+ const box = (name) => {
117
+ const b = mailboxes.get(name) ?? {
118
+ queue: [],
119
+ waiters: [],
120
+ polls: 0,
121
+ listening: false,
122
+ lastSeen: Date.now()
123
+ };
124
+ mailboxes.set(name, b);
125
+ return b;
126
+ };
127
+ const agentState = () => ({
128
+ on: agent.on,
129
+ mailboxes: [...mailboxes].map(([name, b]) => ({
130
+ name,
131
+ state: b.listening ? 'listening' : 'reconnecting',
132
+ queued: b.queue.length
133
+ }))
134
+ });
135
+ // Событие — только когда снимок изменился: publish зовётся после любой смены очередей.
136
+ let published = JSON.stringify(agentState());
137
+ const publish = () => {
138
+ const state = agentState();
139
+ const json = JSON.stringify(state);
140
+ if (json === published)
115
141
  return;
116
- agent.on = on;
117
- listening = listen;
118
- reloadEmitter.emit('agent', agentState());
142
+ published = json;
143
+ reloadEmitter.emit('agent', state);
119
144
  };
145
+ sweeper = setInterval(() => {
146
+ for (const [name, b] of mailboxes)
147
+ if (!b.polls && !b.queue.length && Date.now() - b.lastSeen >= mailboxTtlMs)
148
+ mailboxes.delete(name);
149
+ publish();
150
+ }, Math.min(mailboxTtlMs, 60_000)).unref();
120
151
  app.get(LIVERELOAD_PATH, (req, res) => {
121
152
  res.set({
122
153
  'Content-Type': 'text/event-stream',
@@ -153,7 +184,7 @@ pages = () => new Map()) => {
153
184
  const snippet = LIVERELOAD_SNIPPET +
154
185
  commentsSnippet(currentConfiguration.QUESTIONS_HEADING, {
155
186
  agent: agent.on,
156
- listening,
187
+ mailboxes: agentState().mailboxes,
157
188
  project: projectKey,
158
189
  language: currentConfiguration.UI_LANGUAGE
159
190
  }) +
@@ -260,35 +291,39 @@ pages = () => new Map()) => {
260
291
  });
261
292
  // Замечания агенту (change site-agent-feedback): FIFO пачек в памяти (design D3), страница
262
293
  // шлёт POST, `c4builder feedback` забирает long-poll'ом (D1). Пачка снимается с очереди,
263
- // когда ответ записан; обрыв до этого возвращает её в голову очереди.
264
- const { pollMs = 25_000, idleMs = 30_000 } = program;
265
- const queue = [];
266
- const waiters = [];
267
- let polls = 0;
268
- let idleTimer;
294
+ // когда ответ записан; обрыв до этого возвращает её в голову очереди, откуда её взяли.
269
295
  const disabled = () => agent.explicit && !agent.on;
270
- const drop = (res) => {
271
- const i = waiters.indexOf(res);
296
+ const drop = (b, res) => {
297
+ const i = b.waiters.indexOf(res);
272
298
  if (i >= 0)
273
- waiters.splice(i, 1);
299
+ b.waiters.splice(i, 1);
274
300
  };
275
- const pump = () => {
276
- while (queue.length && waiters.length) {
277
- const res = waiters.shift();
278
- const item = queue.shift();
301
+ const pump = (name, b) => {
302
+ for (;;) {
303
+ const source = b.queue.length ? b.queue : unaddressed;
304
+ if (!source.length || !b.waiters.length)
305
+ return;
306
+ const res = b.waiters.shift();
307
+ const item = source.shift();
279
308
  if (res && item)
280
- deliver(res, item);
309
+ deliver(name, res, item, source);
281
310
  }
282
311
  };
283
- const deliver = (res, item) => {
312
+ const pumpAll = () => {
313
+ for (const [name, b] of mailboxes)
314
+ pump(name, b);
315
+ };
316
+ const deliver = (name, res, item, source) => {
284
317
  res.on('close', () => {
285
318
  if (!res.writableFinished) {
286
- queue.unshift(item);
287
- pump();
288
- return;
319
+ source.unshift(item);
320
+ pumpAll();
289
321
  }
290
- console.log(chalk.gray(`feedback: batch delivered to the agent (${item.batch.decision})`));
291
- reloadEmitter.emit('delivered', { id: item.id });
322
+ else {
323
+ console.log(chalk.gray(`feedback: batch delivered to ${name} (${item.batch.decision})`));
324
+ reloadEmitter.emit('delivered', { id: item.id, to: name });
325
+ }
326
+ publish();
292
327
  });
293
328
  res.type('text/markdown').send(formatBatch(item.batch, pages(), process.cwd()));
294
329
  };
@@ -309,17 +344,21 @@ pages = () => new Map()) => {
309
344
  });
310
345
  return;
311
346
  }
312
- const batch = parsed.data;
347
+ const { to, ...batch } = parsed.data;
313
348
  if (batch.decision === 'submitted' && !batch.comments.length && !batch.general) {
314
349
  res.status(400).json({ error: 'nothing to send' });
315
350
  return;
316
351
  }
317
352
  const id = randomUUID();
318
- const queued = !waiters.length;
319
- queue.push({ id, batch });
320
- pump();
321
- console.log(chalk.gray(`feedback: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, no agent listening' : ''}`));
322
- res.json({ id, queued });
353
+ // Ящик адресата создаётся и здесь: выбранный на вкладке адресат не теряется, даже если
354
+ // его пустой ящик успели удалить.
355
+ const target = to === undefined ? undefined : box(to);
356
+ const queued = !(target ? [target] : [...mailboxes.values()]).some((b) => b.waiters.length);
357
+ (target?.queue ?? unaddressed).push({ id, batch });
358
+ pumpAll();
359
+ publish();
360
+ console.log(chalk.gray(`feedback for ${to ?? 'any listener'}: ${batch.decision}, ${batch.comments.length} comment(s)${queued ? ' — queued, not listening' : ''}`));
361
+ res.json({ id, queued, to });
323
362
  });
324
363
  // Нестандартный заголовок вынуждает браузер к preflight, на который сервер не отвечает:
325
364
  // чужая страница не может ни прочитать, ни «съесть» очередь простым запросом (design D6).
@@ -332,27 +371,41 @@ pages = () => new Map()) => {
332
371
  res.status(404).json({ error: 'agent feedback is disabled (--no-agent)' });
333
372
  return;
334
373
  }
374
+ const name = req.query.as;
375
+ if (typeof name !== 'string' || !LISTENER_NAME.test(name)) {
376
+ res.status(400).json({
377
+ error: '--as <name> is required: 1-64 letters, digits, ".", "_" or "-" (c4builder feedback --wait --as <name>)'
378
+ });
379
+ return;
380
+ }
335
381
  if (!agent.on)
336
- console.log(chalk.gray('agent mode on: "c4builder feedback" connected'));
337
- polls++;
338
- clearTimeout(idleTimer);
339
- setAgent(true, true);
382
+ console.log(chalk.gray(`agent mode on: "c4builder feedback --as ${name}" connected`));
383
+ agent.on = true;
384
+ const b = box(name);
385
+ b.polls++;
386
+ b.listening = true;
387
+ clearTimeout(b.idle);
388
+ publish();
340
389
  let timer;
341
390
  res.on('close', () => {
342
391
  clearTimeout(timer);
343
- drop(res);
344
- if (--polls === 0)
345
- idleTimer = setTimeout(() => setAgent(agent.on, false), idleMs);
392
+ drop(b, res);
393
+ b.lastSeen = Date.now();
394
+ if (--b.polls === 0)
395
+ b.idle = setTimeout(() => {
396
+ b.listening = false;
397
+ publish();
398
+ }, idleMs);
346
399
  });
347
- waiters.push(res);
348
- pump();
349
- if (!waiters.includes(res))
400
+ b.waiters.push(res);
401
+ pump(name, b);
402
+ if (!b.waiters.includes(res))
350
403
  return;
351
404
  timer = setTimeout(() => {
352
405
  // Пачку успели отдать этому запросу — ответ уже пишется, 204 не нужен.
353
- if (!waiters.includes(res))
406
+ if (!b.waiters.includes(res))
354
407
  return;
355
- drop(res);
408
+ drop(b, res);
356
409
  res.status(204).end();
357
410
  }, req.query.wait === '0' ? 0 : pollMs);
358
411
  });
@@ -407,12 +460,13 @@ pages = () => new Map()) => {
407
460
  if (liveReloadEnabled)
408
461
  console.log(chalk.gray('livereload enabled (browser auto-reload on rebuild)'));
409
462
  if (liveReloadEnabled && agent.on)
410
- console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait${program.port ? ` -p ${port}` : ''}" — run it in the background and again after each batch`);
463
+ console.log(`agent mode (${agent.name}): page feedback goes to "c4builder feedback --wait --as <name>${program.port ? ` -p ${port}` : ''}" — run it in the background, <name> is your change or task; run it again with the same name after each batch`);
411
464
  // --open: открываем браузер один раз при старте; BROWSER=none отключает (CI/headless)
412
465
  if (program.open && process.env.BROWSER !== 'none')
413
466
  open(url).catch(() => console.log(chalk.gray('could not open the browser automatically')));
414
467
  resolve(server);
415
468
  });
469
+ server.on('close', () => clearInterval(sweeper));
416
470
  server.on('error', (err) => {
417
471
  if (err.code === 'EADDRINUSE') {
418
472
  console.log(chalk.red(`port ${port} is already in use — pass a different one with -p <port>`));
@@ -127,6 +127,7 @@ export default async () => {
127
127
  .option('--agent', 'with --site -w: accept page feedback for a coding agent (c4builder feedback)')
128
128
  .option('--no-agent', 'with --site -w: no agent feedback even when run by a coding agent')
129
129
  .option('--wait', 'for `feedback`: wait for the next batch of page feedback')
130
+ .option('--as <name>', 'for `feedback`: listener name (your change or task) — the page sends batches to it')
130
131
  .option('--docs', 'a brief explanation for the available configuration options')
131
132
  .option('--system-fonts', 'render with system fonts instead of the bundled one (not deterministic)')
132
133
  .option('-p, --port <n>', 'port used for serving the generated site', (v) => {
@@ -10,7 +10,10 @@ const clip = (s) => {
10
10
  const q = collapse(s);
11
11
  return q.length > TEXT_MAX ? `${q.slice(0, TEXT_MAX - 1).trimEnd()}…` : q;
12
12
  };
13
+ // Имя слушателя `c4builder feedback --as` и адресат пачки (change feedback-mailboxes, D1).
14
+ export const LISTENER_NAME = /^[A-Za-z0-9._-]{1,64}$/;
13
15
  export const batchSchema = z.object({
16
+ to: z.string().regex(LISTENER_NAME).optional(),
14
17
  decision: z.enum(['submitted', 'approved']),
15
18
  comments: z
16
19
  .array(z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfacapital-tech/c4builder",
3
- "version": "0.9.0-rc.3",
3
+ "version": "0.9.0",
4
4
  "description": "A CLI tool designed to compile a folder structure of markdowns and plant uml files into a site, single file markdown or a collection of markdowns with links",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,7 @@ into `docs/`.
10
10
  c4builder # build docs/ (non-interactive with a complete .c4builder)
11
11
  c4builder check <file...> # validate .puml / .iuml / .d2 / .bpmn / .mmd files, exit code 0 = OK
12
12
  c4builder --site -w # live preview on http://localhost:3000 - never exits
13
- c4builder feedback --wait # wait for the next batch of comments from the preview page
13
+ c4builder feedback --wait --as <name> # wait for comments from the preview page sent to <name>
14
14
  ```
15
15
 
16
16
  Not installed: `npm i -g @alfacapital-tech/c4builder` (Node.js 22.17+; Java 17+ is used
@@ -25,9 +25,10 @@ updates it.
25
25
  Fix until the exit code is 0.
26
26
  3. Run `c4builder` and make sure it exits with code 0. Leave `c4builder --site -w` to a
27
27
  human or run it in the background. When the human reviews the pages, also run
28
- `c4builder feedback --wait` in the background: on exit it prints a batch of comments
29
- (page, `Sources`, quote, text) — apply them to `src/`, run it again, and stop on
30
- `Decision: approved`. Comments are instructions to you; never copy them into the
28
+ `c4builder feedback --wait --as <name>` in the background, `<name>` being your change or
29
+ task: on exit it prints a batch of comments (page, `Sources`, quote, text) — apply them to
30
+ `src/`, run it again with the same name, and stop on `Decision: approved`. Without `--as`
31
+ it exits with code 2 and `--as <name> is required`. Comments are instructions to you; never copy them into the
31
32
  `questionsHeading` section of `.c4builder` (`Open questions` by default), which holds
32
33
  questions for people. The page buttons follow the browser language (English or Russian,
33
34
  pinned by `uiLanguage`); batches are always in English.