agy-mcp-bridge 0.1.21 → 0.1.22

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/ui/app.js CHANGED
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
2
  // All dynamic text is inserted with textContent. Model output is untrusted and must never reach innerHTML.
3
3
  const STATUS = {queued: 'Đang chờ', running: 'Đang chạy', completed: 'Hoàn tất', failed: 'Thất bại', timeout: 'Quá hạn', canceled: 'Đã hủy', unsupported: 'Không hỗ trợ'};
4
+ // ready/pending/lost come from the bridge itself; every other step state is the state of the task that step was dispatched as.
5
+ const STEP_STATE = {ready: 'Sẵn sàng', pending: 'Chờ bước trước', lost: 'Mất bản ghi'};
4
6
  const NS = 'http://www.w3.org/2000/svg';
5
7
  const ICONS = {
6
8
  tasks: 'M4 6h16M4 12h16M4 18h10',
9
+ missions: 'M4 5h4v4H4zM4 15h4v4H4zM16 10h4v4h-4zM8 7h3a2 2 0 0 1 2 2v3M8 17h3a2 2 0 0 0 2-2v-3',
7
10
  usage: 'M4 20V10M10 20V4M16 20v-8M22 20H2',
8
11
  settings: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
9
12
  out: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9'
@@ -16,6 +19,7 @@ const MATCH = {
16
19
  };
17
20
  const state = {
18
21
  overview: null, selected: null, detail: null, view: 'tasks', logTab: 'stdout',
22
+ missions: null, selectedMission: null, plan: null,
19
23
  open: new Set(), changes: null, connected: true, toast: '', q: '', filter: 'all'
20
24
  };
21
25
  let timer = null;
@@ -41,6 +45,8 @@ function icon(name) {
41
45
  return svg;
42
46
  }
43
47
  function pill(status) { return h('span', {class: `pill ${status}`, text: STATUS[status] || status}); }
48
+ const stepLabel = value => STEP_STATE[value] || STATUS[value] || value;
49
+ function stepPill(value) { return h('span', {class: `pill ${value}`, text: stepLabel(value)}); }
44
50
 
45
51
  function ago(iso) {
46
52
  const seconds = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
@@ -170,6 +176,41 @@ function followChanges() {
170
176
  if (state.changes?.task_id !== task.task_id || state.changes.loading) return;
171
177
  void loadChanges(task.task_id, true);
172
178
  }
179
+ /* ---------- Mission page: read the recorded plan, stop it, never start a step ---------- */
180
+ let missionsAt = 0;
181
+ /** A step changes state only when its task does, so the list is read when the page opens and then at most every 8 seconds. */
182
+ async function loadMissions(force) {
183
+ if (!force && Date.now() - missionsAt < 8000) return;
184
+ missionsAt = Date.now();
185
+ try { state.missions = await api('missions'); } catch { /* keep the list already on screen */ }
186
+ patch();
187
+ }
188
+ /** The recorded task text is the one thing the progress list leaves out, so it is fetched only for the opened plan. */
189
+ async function loadPlan(id) {
190
+ state.plan = {mission_id: id, loading: true}; patch();
191
+ try { state.plan = {mission_id: id, data: await api(`missions/${id}`)}; }
192
+ catch (error) { state.plan = {mission_id: id, error: error.message}; }
193
+ patch();
194
+ }
195
+ function selectMission(id) {
196
+ state.selectedMission = state.selectedMission === id ? null : id;
197
+ if (!state.selectedMission) state.plan = null;
198
+ patch();
199
+ if (state.selectedMission) void loadPlan(state.selectedMission);
200
+ }
201
+ // Stopping a plan is the only change this page can make: it forbids further steps and leaves every running agent alone.
202
+ async function cancelMission(id, title) {
203
+ if (!confirm(`Dừng kế hoạch "${title}"?\n\nKhông bước nào được bắt đầu thêm. Task đang chạy vẫn chạy tới khi xong và vẫn hủy được riêng ở trang Task.`)) return;
204
+ try {
205
+ const result = await api(`missions/${id}/cancel`, {method: 'POST', body: {confirm: true}});
206
+ state.plan = {mission_id: id, data: result};
207
+ notify(result.running_tasks?.length ? `Đã dừng kế hoạch; ${result.running_tasks.length} task vẫn đang chạy` : 'Đã dừng kế hoạch');
208
+ await loadMissions(true);
209
+ } catch (error) { notify(error.message); }
210
+ }
211
+ /** A step row links to its task; the exchange itself stays on the Task page, which already has logs and changes. */
212
+ function openTask(id) { state.selected = id; state.view = 'tasks'; render(); void select(id); }
213
+
173
214
  async function cancel(id) {
174
215
  if (!confirm('Hủy task này? Các thay đổi file dở dang sẽ được giữ lại, không tự hoàn tác.')) return;
175
216
  try { await api(`tasks/${id}/cancel`, {method: 'POST'}); notify('Đã hủy task'); await refresh(); } catch (error) { notify(error.message); }
@@ -617,6 +658,59 @@ function usageView(u) {
617
658
  return [head, cards, usageCard, dayCard, groupTable('Theo model', u.by_model, 'Model'), groupTable('Theo mức suy luận', u.by_effort, 'Effort'), recent,
618
659
  more('more:usage', callout)];
619
660
  }
661
+ function stepRow(step) {
662
+ const blocking = step.blocked_by.map(item => `${item.step_id} (${stepLabel(item.state)})`).join(', ');
663
+ return h('tr', {},
664
+ h('td', {class: 'mono', text: step.step_id}),
665
+ h('td', {text: step.title}),
666
+ h('td', {}, stepPill(step.state)),
667
+ h('td', {class: 'muted', text: !step.depends_on.length ? '—' : blocking ? `chờ: ${blocking}` : 'đủ phụ thuộc'}),
668
+ h('td', {class: 'muted', text: `${step.agent || 'mặc định'}${step.isolation === 'worktree' ? ' · worktree' : ''}${step.scope?.out_of_scope ? ` · ngoài phạm vi ${step.scope.out_of_scope}` : ''}`}),
669
+ h('td', {}, step.task_id ? h('button', {class: 'btn small mono', onclick: () => openTask(step.task_id), text: step.task_id.slice(0, 8), title: step.task_id}) : h('span', {class: 'muted', text: 'chưa chạy'})));
670
+ }
671
+ function planBody(plan, live) {
672
+ // The recorded request is a snapshot the bridge will not change, but its state must not lag the table above it: the plan was read when the panel opened, the rows keep refreshing.
673
+ const state = id => live?.find(step => step.step_id === id)?.state;
674
+ return [h('dl', {class: 'rows'}, row('Mục tiêu', plan.goal), row('Client yêu cầu', plan.client || '—'), row('Workspace', plan.workspace)),
675
+ h('p', {class: 'muted', text: 'Đây là nội dung bridge đã ghi; mission_dispatch bắt đầu một bước đúng bằng các tham số này, không thể đổi giữa chừng.'}),
676
+ ...plan.steps.map(step => h('div', {class: 'step-args'},
677
+ h('div', {class: 'msg-head'}, h('span', {class: 'mono', text: step.step_id}), step.title, stepPill(state(step.step_id) ?? step.state)),
678
+ h('p', {class: 'text', text: step.delegate.task}),
679
+ h('div', {}, h('div', {class: 'msg-head', text: 'Tiêu chí nghiệm thu'}), h('ul', {class: 'crit'}, step.delegate.acceptance_criteria.map(item => h('li', {text: item})))),
680
+ step.delegate.context ? details(`ctx:${plan.mission_id}:${step.step_id}`, 'Ngữ cảnh kèm theo', h('p', {class: 'text', text: step.delegate.context})) : null))];
681
+ }
682
+ function planPanel(mission) {
683
+ const loaded = state.plan && state.plan.mission_id === mission.mission_id ? state.plan : null;
684
+ const body = !loaded || loaded.loading ? [h('p', {class: 'muted', text: 'Đang đọc kế hoạch đã ghi…'})]
685
+ : loaded.error ? [h('div', {class: 'callout bad', text: loaded.error})]
686
+ : planBody(loaded.data, mission.steps);
687
+ return details(`plan:${mission.mission_id}`, 'Yêu cầu đã ghi cho từng bước', ...body);
688
+ }
689
+ function missionCard(mission) {
690
+ const p = mission.progress;
691
+ const bits = [`${p.completed}/${p.total} hoàn tất`, p.active ? `${p.active} đang chạy` : null, p.ready ? `${p.ready} sẵn sàng` : null,
692
+ p.pending ? `${p.pending} chờ bước trước` : null, p.unmet ? `${p.unmet} không qua được` : null].filter(Boolean);
693
+ return h('section', {class: 'card panel mission'},
694
+ h('div', {class: 'mission-head'},
695
+ h('div', {}, h('h2', {}, mission.title, h('span', {class: mission.status === 'open' ? 'pill warn' : 'pill', text: mission.status === 'open' ? 'Đang mở' : 'Đã dừng'})),
696
+ h('div', {class: 'meta-line muted mono', title: mission.workspace}, `${folder(mission.workspace)} · ${bits.join(' · ')}`),
697
+ h('div', {class: 'meta-line muted'}, agoEl(mission.updated_at, 'meta'), ` · tạo lúc ${clock(mission.created_at)}`,
698
+ h('button', {class: 'link-btn', onclick: () => selectMission(mission.mission_id), text: state.selectedMission === mission.mission_id ? 'Ẩn yêu cầu đã ghi' : 'Xem yêu cầu đã ghi'}))),
699
+ mission.status === 'open' ? h('button', {class: 'btn danger small', onclick: () => cancelMission(mission.mission_id, mission.title), text: 'Dừng kế hoạch'}) : null),
700
+ h('table', {class: 'usage-table'}, h('thead', {}, h('tr', {}, ...['Bước', 'Việc', 'Trạng thái', 'Chưa qua vì', 'Agent', 'Task'].map(label => h('th', {text: label})))),
701
+ h('tbody', {}, mission.steps.map(stepRow))),
702
+ state.selectedMission === mission.mission_id ? planPanel(mission) : null);
703
+ }
704
+ function missionsView(o) {
705
+ const head = h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Nhiệm vụ'}),
706
+ h('p', {text: 'Kế hoạch nhiều bước mà bridge ghi lại và chặn: một bước chỉ bắt đầu được khi mọi bước nó phụ thuộc đã hoàn tất.'})),
707
+ o.missions ? h('div', {class: 'muted', text: `${o.missions.open} kế hoạch đang mở · ${o.missions.ready} bước sẵn sàng · ${o.missions.steps} bước đã ghi`}) : null);
708
+ const note = h('p', {class: 'callout warn', text: 'Bridge không tự bắt đầu bước nào: mỗi bước chạy theo lệnh mission_dispatch của client MCP. Trang này chỉ đọc kế hoạch và dừng nó.'});
709
+ const list = state.missions?.missions;
710
+ if (!list) return [head, note, h('div', {class: 'card empty'}, state.connected ? 'Đang tải…' : 'Không đọc được danh sách nhiệm vụ.')];
711
+ if (!list.length) return [head, note, h('div', {class: 'card empty'}, h('strong', {text: 'Chưa có nhiệm vụ nào'}), 'Khi một client gọi mission_create, kế hoạch và trạng thái từng bước hiện ở đây.')];
712
+ return [head, note, ...list.map(missionCard)];
713
+ }
620
714
  function settingsView(o) {
621
715
  const delegation = o.delegation;
622
716
  return [h('div', {class: 'page-head'}, h('div', {}, h('h1', {text: 'Cài đặt'}), h('p', {text: 'Mọi thứ đổi được ở đây áp dụng cho task mới, không cần khởi động lại. Giới hạn cứng vẫn nằm trong config/workspaces.json.'}))),
@@ -661,6 +755,7 @@ function settingsView(o) {
661
755
  row('Task lưu giữ', o.limits.max_retained_tasks ?? '—'), row('Task thường chạy cùng lúc', o.limits.concurrent_normal_tasks ?? 1),
662
756
  row('Worktree chạy song song', o.limits.max_parallel_worktrees ?? o.concurrency?.max_parallel_worktrees ?? '—'),
663
757
  row('Quyền tự duyệt mặc định', o.default_autonomy ?? 'auto'),
758
+ row('Bước mỗi kế hoạch', o.limits.max_mission_steps ?? '—'), row('Kế hoạch lưu giữ', o.limits.max_retained_missions ?? '—'),
664
759
  row('Hàng đợi tối đa', o.limits.max_queued_tasks ?? '—')))),
665
760
  h('section', {class: 'card panel wide'}, collapsible('more:limitations', 'Lưu ý khi dùng', h('ul', {class: 'list'}, o.limitations.map(item => h('li', {text: item}))))),
666
761
  h('section', {class: 'card panel wide'}, h('h2', {text: 'Dừng bridge'}),
@@ -670,10 +765,10 @@ function settingsView(o) {
670
765
 
671
766
  /* ---------- Shell: built once per view change, then patched region by region ---------- */
672
767
  function mount() {
673
- const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view === 'tasks' ? 'tasks' : view === 'usage' ? 'usage' : 'settings'), label);
768
+ const nav = (view, label) => h('button', {'aria-current': state.view === view ? 'page' : null, onclick: () => { state.view = view; render(); }}, icon(view), label);
674
769
  const live = h('div', {class: 'live', role: 'status'});
675
770
  const side = h('aside', {class: 'side'}, h('div', {class: 'brand'}, h('img', {src: '/ui/favicon.svg', alt: ''}), h('span', {text: 'Antigravity Bridge'})),
676
- h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('usage', 'Sử dụng'), nav('settings', 'Cài đặt')),
771
+ h('nav', {class: 'nav', 'aria-label': 'Điều hướng'}, nav('tasks', 'Task'), nav('missions', 'Nhiệm vụ'), nav('usage', 'Sử dụng'), nav('settings', 'Cài đặt')),
677
772
  h('div', {class: 'side-foot'}, live));
678
773
  const banner = h('div', {'aria-live': 'polite'});
679
774
  const main = h('main', {class: 'main'}, banner);
@@ -692,6 +787,9 @@ function mount() {
692
787
  } else if (state.view === 'usage') {
693
788
  regions.usage = h('div', {});
694
789
  main.append(regions.usage);
790
+ } else if (state.view === 'missions') {
791
+ regions.missions = h('div', {class: 'mission-page'});
792
+ main.append(regions.missions);
695
793
  } else {
696
794
  regions.settings = h('div', {});
697
795
  main.append(regions.settings);
@@ -732,6 +830,9 @@ function patch() {
732
830
  } else if (regions.view === 'usage') {
733
831
  loadUsage();
734
832
  setRegion('usage', JSON.stringify(state.usage), () => usageView(state.usage));
833
+ } else if (regions.view === 'missions') {
834
+ loadMissions();
835
+ setRegion('missions', JSON.stringify([o.missions, state.missions, state.selectedMission, state.plan]), () => missionsView(o));
735
836
  } else {
736
837
  if (!state.models && !state.modelsLoading) loadModels();
737
838
  setRegion('settings', JSON.stringify([state.models, o.model, o.permission, o.effort, o.wait, o.budget && [o.budget.task_tokens, o.budget.day_tokens], o.soft_timeout, o.progress, o.cli, o.agents, o.default_agent, o.default_autonomy, o.concurrency, o.delegation, o.limits, o.allowed_workspaces, o.session_workspaces, o.server.mcp_endpoint]), () => settingsView(o));