agim-cli 1.1.3 → 1.1.5

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.
@@ -25,8 +25,10 @@
25
25
  const T = {
26
26
  en: {
27
27
  title: 'Agim — Tasks',
28
- h1: 'Tasks & Schedules',
29
- backToChat: 'Chat',
28
+ h1: '🗂 Tasks & Schedules',
29
+ backToChat: 'Chat',
30
+ toReminders: 'Reminders',
31
+ toMemos: 'Memos',
30
32
  toSettings: 'Settings',
31
33
  tabsJobs: 'Jobs',
32
34
  tabsBackground: 'Background',
@@ -149,11 +151,92 @@
149
151
  jobsBatchRun: 'Run selected',
150
152
  jobsBatchEmpty: 'No jobs selected.',
151
153
  jobsBatchResult: 'Batch result: {ok} ok, {fail} failed',
154
+ // v1.1.2 / v1.1.3 — new tabs + columns
155
+ tabsOutbox: 'Outbox',
156
+ tabsA2A: 'A2A',
157
+ kindCol: 'Kind',
158
+ parentCol: 'Parent',
159
+ depthCol: 'Depth',
160
+ filterAllKinds: 'All kinds',
161
+ filterKindJob: 'job (explicit /job)',
162
+ filterKindInline: 'inline (auto-tracked)',
163
+ outboxEmpty: 'No outbox rows.',
164
+ outboxStatusPending: '⏳ Pending',
165
+ outboxStatusDelivered: '✅ Delivered',
166
+ outboxStatusGivingUp: '💀 Giving up',
167
+ outboxRetry: 'Retry',
168
+ outboxColPlatform: 'Platform',
169
+ outboxColKind: 'Kind',
170
+ outboxColPri: 'Pri',
171
+ outboxColAttempts: 'Attempts',
172
+ outboxColPayload: 'Payload',
173
+ outboxColError: 'Error',
174
+ outboxColCreated: 'Created',
175
+ a2aEmpty: 'No A2A calls yet.',
176
+ a2aRecent: 'Recent A2A calls',
177
+ a2aCallerCallee: 'Caller→Callee',
178
+ a2aTreeRoot: 'Call tree (root #{id})',
179
+ a2aTreeInputJob: 'job id for tree…',
180
+ a2aBtnTree: 'Tree',
181
+ a2aBtnViewParent: 'View parent tree',
182
+ a2aStatTotal: 'Total',
183
+ a2aStat24h: '24h',
184
+ a2aStatMaxDepth: 'Max depth',
185
+ a2aStatTop: 'Top:',
186
+ modalParent: 'Parent',
187
+ modalDepth: 'Depth',
188
+ modalThread: 'Thread',
189
+ modalReplaced: 'Replaced by',
190
+ modalLastOutbox: 'Last outbox',
191
+ modalArtifacts: 'Artifacts',
192
+ modalArtifactsInputs: 'Inputs:',
193
+ modalArtifactsOutputs: 'Outputs:',
194
+ modalDelivered: 'Delivered',
195
+ helpClose: 'Close',
196
+ // Help tooltips (concept glossary). Used by the (?) icons next to
197
+ // jargon. Keys are short ids; values are { title, body } in the
198
+ // 'help' namespace below.
199
+ help: {
200
+ outbox: {
201
+ title: 'Outbox · 投递队列',
202
+ body: 'Every outbound IM message is first written to a SQLite outbox, then a background worker drains it with exponential backoff. If the messenger glitches or the user is briefly offline, the row stays "pending" and gets retried (1s → 5s → 30s → 5min → 30min → 2h). After 6 failed attempts the row transitions to "giving_up" and stops retrying until you click Retry. Visible in: /tasks Outbox tab, IM command /outbox status.',
203
+ },
204
+ inline: {
205
+ title: 'Inline job · 内联任务',
206
+ body: 'An inline job is the row agim auto-creates for every inbound IM message that triggers an agent. It tracks the full lifecycle (pending → running → completed → delivered) so a crash or restart never loses work. Distinct from kind=job rows which come from explicit /job create. Retention: 24h (vs 30d for kind=job).',
207
+ },
208
+ a2a: {
209
+ title: 'A2A · Agent-to-Agent',
210
+ body: 'When the active agent calls another agent via mcp__imhub__call_agent (e.g. claude saying "用 codex 跑 git status"), agim spawns the callee as a fresh inline job whose parent_id points back at the caller. Guardrails enforced by agim: depth ceiling (default 3), self-call ban, workspace whitelist, shared per-user budget. The tree view shows the full caller→callee chain.',
211
+ },
212
+ artifacts: {
213
+ title: 'Artifacts · 共享文件',
214
+ body: 'A2A Layer 2 lets agents exchange files instead of cramming everything into prompts. Each A2A inline job gets a dedicated ~/.agim/artifacts/<jobId>/_agim-{input,output}/ directory. Caller drops files via `inputs[]`; callee writes results to _agim-output/; caller reads them through this UI or with the agent\'s native Read tool. Retention follows the inline job (24h).',
215
+ },
216
+ callDepth: {
217
+ title: 'Call depth · 调用深度',
218
+ body: 'How deep this row sits in an A2A chain. 0 = user-originated message. +1 per nested mcp__imhub__call_agent invocation. Configurable max via IMHUB_A2A_MAX_DEPTH (default 3) — exceeding it rejects the call before any tokens are spent.',
219
+ },
220
+ parent: {
221
+ title: 'Parent · 父任务',
222
+ body: 'For A2A callees, the inline-job id of the row that fired mcp__imhub__call_agent. NULL on user-originated rows. Click to open the parent\'s detail view.',
223
+ },
224
+ replacedBy: {
225
+ title: 'Replaced by · 已被替换为',
226
+ body: 'For interrupted inline jobs the user retried via "1 重发" after a restart, this points at the new replacement row that ran instead. The old row stays for audit purposes.',
227
+ },
228
+ givingUp: {
229
+ title: 'Giving up · 已放弃',
230
+ body: 'An outbox row enters this state after 6 failed delivery attempts (cumulative ~3h of backoff). It will no longer be retried automatically. Click "Retry" to put it back into the pending queue from scratch.',
231
+ },
232
+ },
152
233
  },
153
234
  zh: {
154
235
  title: 'Agim — 任务',
155
- h1: '任务与定时',
156
- backToChat: '对话',
236
+ h1: '🗂 任务与定时',
237
+ backToChat: '对话',
238
+ toReminders: '提醒',
239
+ toMemos: '备忘',
157
240
  toSettings: '设置',
158
241
  tabsAudit: '审计',
159
242
  tabsJobs: '任务',
@@ -276,6 +359,82 @@
276
359
  jobsBatchRun: '批量运行',
277
360
  jobsBatchEmpty: '未选择任何任务。',
278
361
  jobsBatchResult: '批量结果:成功 {ok},失败 {fail}',
362
+ // v1.1.2 / v1.1.3 — 新 Tab 与列
363
+ tabsOutbox: '投递队列',
364
+ tabsA2A: 'A2A 调用',
365
+ kindCol: '类型',
366
+ parentCol: '父任务',
367
+ depthCol: '深度',
368
+ filterAllKinds: '全部类型',
369
+ filterKindJob: 'job(显式 /job 创建)',
370
+ filterKindInline: 'inline(自动跟踪)',
371
+ outboxEmpty: '投递队列为空。',
372
+ outboxStatusPending: '⏳ 待发',
373
+ outboxStatusDelivered: '✅ 已送达',
374
+ outboxStatusGivingUp: '💀 已放弃',
375
+ outboxRetry: '重试',
376
+ outboxColPlatform: '平台',
377
+ outboxColKind: '类型',
378
+ outboxColPri: '优先级',
379
+ outboxColAttempts: '尝试次数',
380
+ outboxColPayload: '消息内容',
381
+ outboxColError: '错误',
382
+ outboxColCreated: '创建时间',
383
+ a2aEmpty: '暂无 A2A 调用记录。',
384
+ a2aRecent: '最近 A2A 调用',
385
+ a2aCallerCallee: '调用方→被调方',
386
+ a2aTreeRoot: '调用链树(根 #{id})',
387
+ a2aTreeInputJob: '输入 job id 看调用链…',
388
+ a2aBtnTree: '查看树',
389
+ a2aBtnViewParent: '查看父任务调用链',
390
+ a2aStatTotal: '总数',
391
+ a2aStat24h: '24小时',
392
+ a2aStatMaxDepth: '最大深度',
393
+ a2aStatTop: '热门:',
394
+ modalParent: '父任务',
395
+ modalDepth: '深度',
396
+ modalThread: '会话',
397
+ modalReplaced: '替换为',
398
+ modalLastOutbox: '最近投递',
399
+ modalArtifacts: '文件附件',
400
+ modalArtifactsInputs: '输入:',
401
+ modalArtifactsOutputs: '输出:',
402
+ modalDelivered: '送达时间',
403
+ helpClose: '关闭',
404
+ help: {
405
+ outbox: {
406
+ title: 'Outbox · 投递队列',
407
+ body: '每条要发到 IM 的消息都先写入 SQLite 投递队列,后台 worker 按指数退避节奏拉走真发:失败时 1s→5s→30s→5min→30min→2h 重试,连续 6 次失败转为「已放弃」直到你手动点重试。这样 IM 抖动 / 短暂断网都不会丢消息。可在 IM 内用 /outbox status 查询。',
408
+ },
409
+ inline: {
410
+ title: 'Inline 任务 · 自动跟踪',
411
+ body: '每条进入 agim 触发 Agent 的 IM 消息,会自动建一条 inline job 跟踪完整生命周期(待执行 → 运行中 → 已完成 → 已送达)。agim 崩溃 / 重启时绝不丢任务。区别于 kind=job 行(来自显式 /job create)。inline 保留 24 小时,job 保留 30 天。',
412
+ },
413
+ a2a: {
414
+ title: 'A2A · Agent 互调',
415
+ body: '当前 Agent 可以通过 mcp__imhub__call_agent 工具调用另一个 Agent(比如 claude 说"用 codex 跑 git status")。agim 会为被调 Agent 建一条新 inline job,parent_id 指向调用方。护栏由 agim 强制:调用深度上限(默认 3)、禁止自调、工作区白名单、按人共享预算。调用树视图展示完整调用链。',
416
+ },
417
+ artifacts: {
418
+ title: 'Artifacts · 共享文件',
419
+ body: 'A2A Layer 2 让 Agent 之间传文件而非把内容塞进 prompt。每条 A2A 任务有独立工作目录 ~/.agim/artifacts/<jobId>/_agim-{input,output}/。调用方通过 inputs[] 放文件,被调方写到 _agim-output/,调用方在这里点击下载或用 Read 工具读。保留期跟 inline 任务一致(24 小时)。',
420
+ },
421
+ callDepth: {
422
+ title: '调用深度',
423
+ body: '该任务在 A2A 链中的层数。0 = 用户发起的消息,每多一层 mcp__imhub__call_agent 嵌套 +1。可由 IMHUB_A2A_MAX_DEPTH 配置最大值(默认 3),超过则在花费 token 前就拒绝调用。',
424
+ },
425
+ parent: {
426
+ title: '父任务',
427
+ body: 'A2A 被调方任务的 parent_id 指向触发此次调用的任务行。用户原发的任务为空。点击可打开父任务详情。',
428
+ },
429
+ replacedBy: {
430
+ title: '已被替换为',
431
+ body: '服务重启后被中断、用户回复"1 重发"重新发起的任务,其 replaced_by 字段指向新行 id。旧行保留用于审计。',
432
+ },
433
+ givingUp: {
434
+ title: '已放弃',
435
+ body: '投递队列中连续 6 次失败(累计退避约 3 小时)的行进入此状态,不再自动重试。点击"重试"会将其重新入队、退避计数清零。',
436
+ },
437
+ },
279
438
  },
280
439
  };
281
440
  window.__t = T[window.__lang];
@@ -419,8 +578,45 @@
419
578
  .pill.pending { background: rgba(255, 193, 7, 0.18); color: #b89000; }
420
579
  .pill.running { background: rgba(23, 162, 184, 0.18); color: #0d8898; }
421
580
  .pill.completed { background: rgba(40, 167, 69, 0.18); color: #1e7c34; }
581
+ .pill.delivered { background: rgba(40, 167, 69, 0.28); color: #155724; font-weight: 500; }
422
582
  .pill.failed { background: rgba(220, 53, 69, 0.18); color: #b32433; }
423
583
  .pill.cancelled { background: rgba(108, 117, 125, 0.18); color: #5a6268; }
584
+ .pill.interrupted { background: rgba(255, 152, 0, 0.22); color: #b35900; }
585
+ .pill.replaced { background: rgba(94, 53, 177, 0.18); color: #4527a0; }
586
+ .pill.abandoned { background: rgba(96, 125, 139, 0.18); color: #455a64; }
587
+ .pill.kind-inline { background: rgba(63, 81, 181, 0.12); color: #3949ab; font-size: 11px; }
588
+ .pill.kind-job { background: rgba(141, 110, 99, 0.18); color: #5d4037; font-size: 11px; }
589
+ /* Mobile-friendly: wide tables (Jobs / Outbox / A2A / Audit) get a
590
+ horizontal scrollbar inside their pane instead of overflowing the
591
+ viewport. WeChat/Telegram in-app WebView is the common entry. */
592
+ #jobs-list, #outbox-list, #a2a-list, #audit-list, #subtasks-list, #bg-list, #schedules-list {
593
+ overflow-x: auto;
594
+ -webkit-overflow-scrolling: touch;
595
+ }
596
+ #jobs-list table, #outbox-list table, #a2a-list table, #audit-list table {
597
+ min-width: 720px; /* allow horizontal scroll instead of squishing cells */
598
+ }
599
+ /* Tab bar should also scroll horizontally on narrow screens rather
600
+ than wrapping (10 tabs don't fit on a phone otherwise). */
601
+ .tabs {
602
+ overflow-x: auto;
603
+ -webkit-overflow-scrolling: touch;
604
+ white-space: nowrap;
605
+ flex-wrap: nowrap;
606
+ }
607
+ .tabs .tab { flex-shrink: 0; }
608
+ /* Help-tooltip button — small (?) next to jargon, opens centered modal */
609
+ .help-btn {
610
+ display: inline-flex; align-items: center; justify-content: center;
611
+ width: 18px; height: 18px; padding: 0; border-radius: 50%;
612
+ font-size: 11px; font-weight: 700; line-height: 1;
613
+ background: var(--surface-2, rgba(0,0,0,0.06)); color: var(--text-muted, #666);
614
+ border: 1px solid var(--border, rgba(0,0,0,0.12));
615
+ cursor: pointer; transition: all .15s;
616
+ }
617
+ .help-btn:hover { background: var(--primary, #3b82f6); color: #fff; border-color: var(--primary, #3b82f6); }
618
+ .help-modal h2 { margin: 0 0 .5em; font-size: 18px; }
619
+ .help-modal p { line-height: 1.7; color: var(--text, #333); white-space: pre-wrap; }
424
620
  .row-actions button {
425
621
  font-size: 12px;
426
622
  padding: 3px 8px;
@@ -555,10 +751,14 @@
555
751
  <header>
556
752
  <h1 id="page-title"></h1>
557
753
  <button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
558
- <a href="/">↩ <span id="lbl-chat"></span></a>
559
- <a href="/reminders">Reminders</a>
560
- <a href="/memos">Memos</a>
561
- <a href="/settings" aria-label="Settings"><span id="lbl-settings"></span></a>
754
+ <select id="langSelect" title="Language / 语言">
755
+ <option value="en">EN</option>
756
+ <option value="zh">中文</option>
757
+ </select>
758
+ <a href="/" id="lnk-chat"></a>
759
+ <a href="/reminders" id="lnk-reminders"></a>
760
+ <a href="/memos" id="lnk-memos"></a>
761
+ <a href="/settings" id="lnk-settings"></a>
562
762
  </header>
563
763
  <main>
564
764
  <div class="tabs">
@@ -570,6 +770,8 @@
570
770
  <button type="button" class="tab" data-tab="health" id="tab-health"></button>
571
771
  <button type="button" class="tab" data-tab="files" id="tab-files"></button>
572
772
  <button type="button" class="tab" data-tab="audit" id="tab-audit"></button>
773
+ <button type="button" class="tab" data-tab="outbox" id="tab-outbox">Outbox</button>
774
+ <button type="button" class="tab" data-tab="a2a" id="tab-a2a">A2A</button>
573
775
  </div>
574
776
 
575
777
  <section id="jobs-pane">
@@ -582,6 +784,15 @@
582
784
  <option value="completed"></option>
583
785
  <option value="failed"></option>
584
786
  <option value="cancelled"></option>
787
+ <option value="delivered">delivered</option>
788
+ <option value="interrupted">interrupted</option>
789
+ <option value="replaced">replaced</option>
790
+ <option value="abandoned">abandoned</option>
791
+ </select>
792
+ <select id="filter-kind" title="kind filter" data-i18n-attr="title:kindCol">
793
+ <option value="" data-i18n="filterAllKinds">All kinds</option>
794
+ <option value="job" data-i18n="filterKindJob">job (explicit /job)</option>
795
+ <option value="inline" data-i18n="filterKindInline">inline (auto-tracked)</option>
585
796
  </select>
586
797
  <select id="jobs-agent-filter" data-agent-filter>
587
798
  <option value="">All agents</option>
@@ -689,6 +900,47 @@
689
900
  <div class="stats" id="audit-stats"></div>
690
901
  <div id="audit-list"></div>
691
902
  </section>
903
+
904
+ <!-- Outbox tab (v1.1.2) — persistent IM delivery queue -->
905
+ <section id="outbox-pane" hidden>
906
+ <h2 style="margin-top:0;display:flex;align-items:center;gap:.5em">
907
+ <span id="hdr-outbox">Outbox</span>
908
+ <button type="button" class="help-btn" data-help="outbox" title="?">?</button>
909
+ </h2>
910
+ <div class="toolbar">
911
+ <select id="outbox-status-filter">
912
+ <option value="" data-i18n="filterAll">All</option>
913
+ <option value="pending" data-i18n="outboxStatusPending">⏳ Pending</option>
914
+ <option value="delivered" data-i18n="outboxStatusDelivered">✅ Delivered</option>
915
+ <option value="giving_up" data-i18n="outboxStatusGivingUp">💀 Giving up</option>
916
+ </select>
917
+ <select id="outbox-limit">
918
+ <option value="20">20</option>
919
+ <option value="50" selected>50</option>
920
+ <option value="200">200</option>
921
+ </select>
922
+ <button type="button" id="btn-outbox-refresh" data-i18n="refresh">Refresh</button>
923
+ </div>
924
+ <div class="stats" id="outbox-stats"></div>
925
+ <div id="outbox-list"></div>
926
+ </section>
927
+
928
+ <!-- A2A tab (v1.1.3) — agent-to-agent call observability + artifacts viewer -->
929
+ <section id="a2a-pane" hidden>
930
+ <h2 style="margin-top:0;display:flex;align-items:center;gap:.5em">
931
+ <span id="hdr-a2a">A2A</span>
932
+ <button type="button" class="help-btn" data-help="a2a" title="?">?</button>
933
+ <button type="button" class="help-btn" data-help="artifacts" title="?" style="margin-left:-4px" id="hdr-a2a-art-help">📎?</button>
934
+ </h2>
935
+ <div class="toolbar">
936
+ <input type="number" id="a2a-tree-id" min="1" style="width:160px" data-i18n-attr="placeholder:a2aTreeInputJob">
937
+ <button type="button" id="btn-a2a-tree" data-i18n="a2aBtnTree">Tree</button>
938
+ <button type="button" id="btn-a2a-refresh" data-i18n="refresh">Refresh</button>
939
+ </div>
940
+ <div class="stats" id="a2a-stats"></div>
941
+ <div id="a2a-list"></div>
942
+ <div id="a2a-tree"></div>
943
+ </section>
692
944
  </main>
693
945
 
694
946
  <div class="modal-bg" id="modal-bg">
@@ -706,8 +958,10 @@
706
958
  // i18n string fills
707
959
  document.title = T.title;
708
960
  document.getElementById('page-title').textContent = T.h1;
709
- document.getElementById('lbl-chat').textContent = T.backToChat;
710
- document.getElementById('lbl-settings').textContent = T.toSettings;
961
+ document.getElementById('lnk-chat').textContent = T.backToChat;
962
+ document.getElementById('lnk-reminders').textContent = T.toReminders;
963
+ document.getElementById('lnk-memos').textContent = T.toMemos;
964
+ document.getElementById('lnk-settings').textContent = T.toSettings;
711
965
  document.getElementById('tab-jobs').textContent = T.tabsJobs;
712
966
  document.getElementById('tab-background').textContent = T.tabsBackground;
713
967
  document.getElementById('tab-subtasks').textContent = T.tabsSubtasks;
@@ -716,6 +970,67 @@
716
970
  document.getElementById('tab-approvals').textContent = T.tabsApprovals;
717
971
  document.getElementById('tab-health').textContent = T.tabsHealth;
718
972
  document.getElementById('tab-files').textContent = T.tabsFiles;
973
+ document.getElementById('tab-outbox').textContent = T.tabsOutbox;
974
+ document.getElementById('tab-a2a').textContent = T.tabsA2A;
975
+ document.getElementById('hdr-outbox').textContent = T.tabsOutbox;
976
+ document.getElementById('hdr-a2a').textContent = T.tabsA2A;
977
+
978
+ // data-i18n / data-i18n-attr — sweep static markers so placeholders +
979
+ // option labels pick up the active language. Same shape as _app.js's
980
+ // applyI18n but inline here (tasks.html runs before _app.js applies
981
+ // its sweep to the rest of the doc).
982
+ (function applyStaticI18n() {
983
+ document.querySelectorAll('[data-i18n]').forEach((el) => {
984
+ const k = el.getAttribute('data-i18n');
985
+ if (k && T[k] != null) el.textContent = T[k];
986
+ });
987
+ document.querySelectorAll('[data-i18n-attr]').forEach((el) => {
988
+ const spec = el.getAttribute('data-i18n-attr') || '';
989
+ for (const pair of spec.split(';')) {
990
+ const [attr, key] = pair.split(':').map(s => s && s.trim());
991
+ if (attr && key && T[key] != null) el.setAttribute(attr, T[key]);
992
+ }
993
+ });
994
+ })();
995
+
996
+ // Help-tooltip system — every (?) button opens a centered modal whose
997
+ // body comes from T.help[<key>]. Reuses the existing modal-bg overlay
998
+ // so a single Escape press dismisses everything.
999
+ document.addEventListener('click', (ev) => {
1000
+ const btn = ev.target.closest && ev.target.closest('.help-btn');
1001
+ if (!btn) return;
1002
+ ev.preventDefault();
1003
+ const key = btn.getAttribute('data-help');
1004
+ const def = (T.help && T.help[key]) || null;
1005
+ if (!def) { console.warn('no help def for', key); return; }
1006
+ const m = document.getElementById('modal');
1007
+ m.classList.add('help-modal');
1008
+ m.innerHTML = `
1009
+ <h2>${def.title}</h2>
1010
+ <p>${def.body}</p>
1011
+ <div class="modal-actions">
1012
+ <button type="button" id="help-modal-close">${T.helpClose}</button>
1013
+ </div>
1014
+ `;
1015
+ document.getElementById('modal-bg').classList.add('show');
1016
+ document.getElementById('help-modal-close').onclick = () => {
1017
+ document.getElementById('modal-bg').classList.remove('show');
1018
+ m.classList.remove('help-modal');
1019
+ };
1020
+ });
1021
+ // Language switcher: persist + reload so the IIFE in <head> reads the
1022
+ // new preference and rebuilds T.
1023
+ (function setupLangSwitcher() {
1024
+ const sel = document.getElementById('langSelect');
1025
+ if (!sel) return;
1026
+ sel.value = window.__lang;
1027
+ sel.addEventListener('change', () => {
1028
+ const newLang = sel.value;
1029
+ if (newLang === window.__lang) return;
1030
+ localStorage.setItem('im-hub-lang', newLang);
1031
+ window.location.reload();
1032
+ });
1033
+ })();
719
1034
  document.getElementById('btn-bg-refresh').textContent = T.refresh;
720
1035
  document.getElementById('btn-sub-refresh').textContent = T.refresh;
721
1036
  document.getElementById('btn-approvals-refresh').textContent = T.refresh;
@@ -754,6 +1069,8 @@
754
1069
  document.getElementById('approvals-pane').hidden = tab !== 'approvals';
755
1070
  document.getElementById('health-pane').hidden = tab !== 'health';
756
1071
  document.getElementById('files-pane').hidden = tab !== 'files';
1072
+ document.getElementById('outbox-pane').hidden = tab !== 'outbox';
1073
+ document.getElementById('a2a-pane').hidden = tab !== 'a2a';
757
1074
  // Lazy-load on first activation; auto-refresh hooks below kick in too.
758
1075
  if (tab === 'schedules') loadSchedules();
759
1076
  if (tab === 'background') { ensureBgRootsLoaded().then(loadBgjobs); }
@@ -762,6 +1079,8 @@
762
1079
  if (tab === 'approvals') loadApprovals();
763
1080
  if (tab === 'health') loadHealth();
764
1081
  if (tab === 'files') ensureFilesAgentLoaded().then(() => loadFiles(filesPath));
1082
+ if (tab === 'outbox') loadOutbox();
1083
+ if (tab === 'a2a') loadA2A();
765
1084
  // Pause/resume auto-refresh so hidden tabs don't poll.
766
1085
  setupBgAutoRefresh();
767
1086
  setupApprovalsAutoRefresh();
@@ -779,7 +1098,15 @@
779
1098
 
780
1099
  function fmtTime(s) {
781
1100
  if (!s) return '-';
782
- const d = new Date(s.endsWith('Z') ? s : `${s}Z`);
1101
+ // SQLite datetime('now') produces "2026-05-15 14:02:50" — space-separated
1102
+ // and no zone. Safari + iOS WebView refuse that string (Invalid Date);
1103
+ // Chrome happens to parse it. Normalize to RFC3339 by swapping space for
1104
+ // T and forcing UTC. Also accept already-ISO strings unchanged.
1105
+ let iso = String(s).trim();
1106
+ if (iso && !iso.includes('T')) iso = iso.replace(' ', 'T');
1107
+ if (iso && !/[Z+]/.test(iso.slice(-6))) iso = iso + 'Z';
1108
+ const d = new Date(iso);
1109
+ if (Number.isNaN(d.getTime())) return s; // fall back to raw if still bad
783
1110
  return d.toLocaleString();
784
1111
  }
785
1112
 
@@ -826,7 +1153,10 @@
826
1153
  <tr>
827
1154
  <th><input type="checkbox" id="job-select-all" ${allSelected ? 'checked' : ''} title="${T.jobsSelectAll}"></th>
828
1155
  <th>#</th>
1156
+ <th>${T.kindCol} <button type="button" class="help-btn" data-help="inline" title="?">?</button></th>
829
1157
  <th>${T.agent}</th>
1158
+ <th>${T.parentCol} <button type="button" class="help-btn" data-help="parent" title="?">?</button></th>
1159
+ <th>${T.depthCol} <button type="button" class="help-btn" data-help="callDepth" title="?">?</button></th>
830
1160
  <th>${T.prompt}</th>
831
1161
  <th>${T.status}</th>
832
1162
  <th>${T.created}</th>
@@ -834,11 +1164,21 @@
834
1164
  </tr>
835
1165
  </thead>
836
1166
  <tbody>
837
- ${jobs.map(j => `
1167
+ ${jobs.map(j => {
1168
+ const kind = j.kind || 'job';
1169
+ const kindPill = `<span class="pill kind-${kind}">${esc(kind)}</span>`;
1170
+ const parentLink = (j.parent_id != null && j.parent_id > 0)
1171
+ ? `<a href="#" data-act="parent" data-pid="${j.parent_id}" title="View parent">#${j.parent_id}</a>` : '';
1172
+ const depth = (typeof j.call_depth === 'number' && j.call_depth > 0)
1173
+ ? `<span style="font-size:11px;opacity:0.8">↳${j.call_depth}</span>` : '';
1174
+ return `
838
1175
  <tr data-id="${j.id}">
839
1176
  <td><input type="checkbox" data-sel="${j.id}" ${jobSelection.has(j.id) ? 'checked' : ''}></td>
840
1177
  <td>#${j.id}</td>
1178
+ <td>${kindPill}</td>
841
1179
  <td><code>${esc(j.agent)}</code></td>
1180
+ <td>${parentLink}</td>
1181
+ <td>${depth}</td>
842
1182
  <td class="truncate">${esc(j.prompt)}</td>
843
1183
  <td>${statusPill(j.status)}</td>
844
1184
  <td>${fmtTime(j.created_at)}</td>
@@ -849,8 +1189,8 @@
849
1189
  ${j.status === 'pending' || j.status === 'running'
850
1190
  ? `<button type="button" data-act="cancel" class="danger">${T.cancel}</button>` : ''}
851
1191
  </td>
852
- </tr>
853
- `).join('')}
1192
+ </tr>`;
1193
+ }).join('')}
854
1194
  </tbody>
855
1195
  </table>
856
1196
  `;
@@ -859,6 +1199,11 @@
859
1199
  tr.querySelector('[data-act="view"]')?.addEventListener('click', () => showJob(id));
860
1200
  tr.querySelector('[data-act="run"]')?.addEventListener('click', () => runJob(id));
861
1201
  tr.querySelector('[data-act="cancel"]')?.addEventListener('click', () => cancelJob(id));
1202
+ tr.querySelector('[data-act="parent"]')?.addEventListener('click', (e) => {
1203
+ e.preventDefault();
1204
+ const pid = parseInt(e.currentTarget.getAttribute('data-pid'), 10);
1205
+ if (pid > 0) showJob(pid);
1206
+ });
862
1207
  tr.querySelector('[data-sel]')?.addEventListener('change', (e) => {
863
1208
  if (e.target.checked) jobSelection.add(id);
864
1209
  else jobSelection.delete(id);
@@ -914,9 +1259,11 @@
914
1259
  async function loadJobs() {
915
1260
  const status = document.getElementById('filter-status').value;
916
1261
  const agent = document.getElementById('jobs-agent-filter').value;
1262
+ const kind = document.getElementById('filter-kind')?.value || '';
917
1263
  const qs = new URLSearchParams({ limit: '100' });
918
1264
  if (status) qs.set('status', status);
919
1265
  if (agent) qs.set('agent', agent);
1266
+ if (kind) qs.set('kind', kind);
920
1267
  try {
921
1268
  const { jobs, stats } = await api(`/api/jobs?${qs.toString()}`);
922
1269
  renderStats(stats);
@@ -926,18 +1273,46 @@
926
1273
  `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
927
1274
  }
928
1275
  }
1276
+ document.getElementById('filter-kind')?.addEventListener('change', loadJobs);
929
1277
 
930
1278
  async function showJob(id) {
931
1279
  const { job } = await api(`/api/jobs/${id}`);
1280
+ // Fetch artifacts in parallel — non-fatal if 404 / empty.
1281
+ let arts = { inputs: [], outputs: [], totalBytes: 0 };
1282
+ try {
1283
+ const r = await fetch(`/api/artifacts/${id}`);
1284
+ if (r.ok) arts = await r.json();
1285
+ } catch { /* ignore — modal still renders */ }
932
1286
  const m = document.getElementById('modal');
1287
+ const kind = job.kind || 'job';
1288
+ const kindPill = `<span class="pill kind-${kind}">${esc(kind)}</span>`;
1289
+ const parentLine = (job.parent_id != null && job.parent_id > 0)
1290
+ ? `<p><strong>${T.modalParent}:</strong> <a href="#" data-act="modal-parent" data-pid="${job.parent_id}">#${job.parent_id}</a> · <strong>${T.modalDepth}:</strong> ${job.call_depth || 0}</p>` : '';
1291
+ const threadLine = job.thread_key
1292
+ ? `<p><strong>${T.modalThread}:</strong> <code>${esc(job.thread_key)}</code></p>` : '';
1293
+ const replacedLine = (job.replaced_by != null && job.replaced_by > 0)
1294
+ ? `<p><strong>${T.modalReplaced}:</strong> <a href="#" data-act="modal-parent" data-pid="${job.replaced_by}">#${job.replaced_by}</a></p>` : '';
1295
+ const outboxLine = (job.last_outbox_id != null && job.last_outbox_id > 0)
1296
+ ? `<p><strong>${T.modalLastOutbox}:</strong> #${job.last_outbox_id}</p>` : '';
1297
+ const fmt = (n) => n < 1024 ? `${n} B` : n < 1024*1024 ? `${(n/1024).toFixed(1)} KB` : `${(n/1024/1024).toFixed(1)} MB`;
1298
+ const artsBlock = (arts.outputs.length > 0 || arts.inputs.length > 0) ? `
1299
+ <h3>📎 ${T.modalArtifacts} (${fmt(arts.totalBytes || 0)}) <button type="button" class="help-btn" data-help="artifacts" title="?">?</button></h3>
1300
+ ${arts.inputs.length > 0 ? `<p style="font-size:13px;color:var(--muted)">${T.modalArtifactsInputs}</p><ul>${arts.inputs.map(f => `<li><a href="/api/artifacts/${id}/file/${encodeURIComponent(f.name)}" target="_blank">${esc(f.name)}</a> (${fmt(f.bytes)})</li>`).join('')}</ul>` : ''}
1301
+ ${arts.outputs.length > 0 ? `<p style="font-size:13px;color:var(--muted)">${T.modalArtifactsOutputs}</p><ul>${arts.outputs.map(f => `<li><a href="/api/artifacts/${id}/file/${encodeURIComponent(f.name)}" target="_blank">${esc(f.name)}</a> (${fmt(f.bytes)})</li>`).join('')}</ul>` : ''}
1302
+ ` : '';
933
1303
  m.innerHTML = `
934
- <h2>${T.details} #${job.id}</h2>
1304
+ <h2>${T.details} #${job.id} ${kindPill}</h2>
935
1305
  <p><strong>${T.agent}:</strong> <code>${esc(job.agent)}</code> · <strong>${T.status}:</strong> ${statusPill(job.status)}</p>
936
- <p><strong>${T.created}:</strong> ${fmtTime(job.created_at)}${job.completed_at ? ` · <strong>${T.completed}:</strong> ${fmtTime(job.completed_at)}` : ''}</p>
1306
+ ${parentLine}
1307
+ ${threadLine}
1308
+ ${replacedLine}
1309
+ ${outboxLine}
1310
+ <p><strong>${T.created}:</strong> ${fmtTime(job.created_at)}${job.completed_at ? ` · <strong>${T.completed}:</strong> ${fmtTime(job.completed_at)}` : ''}${job.delivered_at ? ` · <strong>${T.modalDelivered}:</strong> ${fmtTime(job.delivered_at)}` : ''}</p>
937
1311
  <h3>${T.prompt}</h3>
938
1312
  <pre>${esc(job.prompt)}</pre>
939
1313
  ${job.result ? `<h3>${T.result}</h3><pre>${esc(job.result)}</pre>` : ''}
940
1314
  ${job.error ? `<h3>${T.error}</h3><pre>${esc(job.error)}</pre>` : ''}
1315
+ ${artsBlock}
941
1316
  <div class="modal-actions">
942
1317
  <button type="button" id="modal-close">${T.close}</button>
943
1318
  </div>
@@ -945,6 +1320,13 @@
945
1320
  document.getElementById('modal-bg').classList.add('show');
946
1321
  document.getElementById('modal-close').onclick = () =>
947
1322
  document.getElementById('modal-bg').classList.remove('show');
1323
+ m.querySelectorAll('[data-act="modal-parent"]').forEach((a) => {
1324
+ a.addEventListener('click', (e) => {
1325
+ e.preventDefault();
1326
+ const pid = parseInt(e.currentTarget.getAttribute('data-pid'), 10);
1327
+ if (pid > 0) showJob(pid);
1328
+ });
1329
+ });
948
1330
  }
949
1331
 
950
1332
  async function runJob(id) {
@@ -1817,6 +2199,177 @@
1817
2199
 
1818
2200
  setupSSE();
1819
2201
 
2202
+ // ============================================
2203
+ // Outbox tab (v1.1.2)
2204
+ // ============================================
2205
+
2206
+ function escapeHtml(s) {
2207
+ return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
2208
+ }
2209
+
2210
+ async function loadOutbox() {
2211
+ const statusEl = document.getElementById('outbox-status-filter');
2212
+ const limitEl = document.getElementById('outbox-limit');
2213
+ const statusFilter = statusEl ? statusEl.value : '';
2214
+ const limit = limitEl ? limitEl.value : '50';
2215
+ try {
2216
+ const statsResp = await fetch('/api/outbox/stats');
2217
+ const stats = await statsResp.json();
2218
+ const statsEl = document.getElementById('outbox-stats');
2219
+ if (statsEl) {
2220
+ statsEl.innerHTML =
2221
+ `<span class="stat">${T.outboxStatusPending} <b>${stats.pending ?? 0}</b></span>` +
2222
+ `<span class="stat">${T.outboxStatusDelivered} <b>${stats.delivered ?? 0}</b></span>` +
2223
+ `<span class="stat">${T.outboxStatusGivingUp} <b>${stats.giving_up ?? 0}</b></span> ` +
2224
+ `<button type="button" class="help-btn" data-help="givingUp" title="?">?</button>`;
2225
+ }
2226
+ const qs = new URLSearchParams({ limit });
2227
+ if (statusFilter) qs.set('status', statusFilter);
2228
+ const listResp = await fetch('/api/outbox?' + qs);
2229
+ const { rows = [] } = await listResp.json();
2230
+ const listEl = document.getElementById('outbox-list');
2231
+ if (!rows.length) { listEl.innerHTML = `<p style="color:var(--text-muted)">${T.outboxEmpty}</p>`; return; }
2232
+ const html = ['<table class="data-table"><thead><tr>',
2233
+ `<th>#</th><th>${T.status}</th><th>${T.outboxColPlatform}</th><th>${T.outboxColKind}</th><th>${T.outboxColPri}</th>`,
2234
+ `<th>${T.outboxColAttempts}</th><th>${T.outboxColPayload}</th><th>${T.outboxColError}</th><th>${T.outboxColCreated}</th><th></th>`,
2235
+ '</tr></thead><tbody>'];
2236
+ for (const r of rows) {
2237
+ const icon = r.status === 'delivered' ? '✅' : r.status === 'giving_up' ? '💀' : '⏳';
2238
+ const preview = (r.payload || '').slice(0, 60).replace(/\n/g, ' ');
2239
+ const errCell = r.last_error ? `<span title="${escapeHtml(r.last_error)}">${escapeHtml(r.last_error.slice(0, 40))}…</span>` : '';
2240
+ const ts = fmtTime(r.created_at);
2241
+ const retryBtn = r.status === 'giving_up'
2242
+ ? `<button class="btn-link" data-retry="${r.id}">${T.outboxRetry}</button>` : '';
2243
+ html.push(`<tr>
2244
+ <td>#${r.id}</td>
2245
+ <td>${icon} ${escapeHtml(r.status)}</td>
2246
+ <td>${escapeHtml(r.platform)}</td>
2247
+ <td>${escapeHtml(r.kind)}</td>
2248
+ <td>${escapeHtml(r.priority)}</td>
2249
+ <td>${r.attempts}</td>
2250
+ <td title="${escapeHtml(r.payload || '')}">${escapeHtml(preview)}…</td>
2251
+ <td>${errCell}</td>
2252
+ <td>${ts}</td>
2253
+ <td>${retryBtn}</td>
2254
+ </tr>`);
2255
+ }
2256
+ html.push('</tbody></table>');
2257
+ listEl.innerHTML = html.join('');
2258
+ listEl.querySelectorAll('[data-retry]').forEach((btn) => {
2259
+ btn.addEventListener('click', async () => {
2260
+ const id = btn.getAttribute('data-retry');
2261
+ const r = await fetch(`/api/outbox/${id}/retry`, { method: 'POST' });
2262
+ const j = await r.json();
2263
+ if (j.ok) { loadOutbox(); }
2264
+ else alert('Retry failed: ' + (j.error || 'unknown'));
2265
+ });
2266
+ });
2267
+ } catch (err) {
2268
+ document.getElementById('outbox-list').innerHTML = `<p style="color:#d00">Failed: ${escapeHtml(String(err))}</p>`;
2269
+ }
2270
+ }
2271
+ document.getElementById('btn-outbox-refresh')?.addEventListener('click', loadOutbox);
2272
+ document.getElementById('outbox-status-filter')?.addEventListener('change', loadOutbox);
2273
+ document.getElementById('outbox-limit')?.addEventListener('change', loadOutbox);
2274
+
2275
+ // ============================================
2276
+ // A2A tab (v1.1.3)
2277
+ // ============================================
2278
+
2279
+ async function loadA2A() {
2280
+ try {
2281
+ const [statsResp, recentResp] = await Promise.all([
2282
+ fetch('/api/a2a/stats'),
2283
+ fetch('/api/a2a/recent?limit=20'),
2284
+ ]);
2285
+ const stats = await statsResp.json();
2286
+ const recent = (await recentResp.json()).rows || [];
2287
+ const statsEl = document.getElementById('a2a-stats');
2288
+ if (statsEl) {
2289
+ const byStatus = (stats.byStatus || []).map(s => `${s.status}:${s.n}`).join(' · ');
2290
+ const byAgent = (stats.byAgent || []).map(s => `${s.agent} <b>${s.n}</b>`).join(' / ');
2291
+ statsEl.innerHTML =
2292
+ `<span class="stat">${T.a2aStatTotal} <b>${stats.total ?? 0}</b></span>` +
2293
+ `<span class="stat">${T.a2aStat24h} <b>${stats.recent24h ?? 0}</b></span>` +
2294
+ `<span class="stat">${T.a2aStatMaxDepth} <b>${stats.maxDepth ?? 0}</b></span>` +
2295
+ (byStatus ? `<span class="stat">${byStatus}</span>` : '') +
2296
+ (byAgent ? `<span class="stat">${T.a2aStatTop} ${byAgent}</span>` : '');
2297
+ }
2298
+ const listEl = document.getElementById('a2a-list');
2299
+ if (!recent.length) { listEl.innerHTML = `<p style="color:var(--text-muted)">${T.a2aEmpty}</p>`; return; }
2300
+ const html = [`<h3 style="margin-top:1em">${T.a2aRecent}</h3>`,
2301
+ '<table class="data-table"><thead><tr>',
2302
+ `<th>#</th><th>${T.a2aCallerCallee}</th><th>${T.depthCol}</th><th>${T.status}</th>`,
2303
+ `<th>${T.prompt}</th><th>${T.created}</th><th>${T.modalDelivered}</th><th></th>`,
2304
+ '</tr></thead><tbody>'];
2305
+ for (const r of recent) {
2306
+ const icon = r.status === 'delivered' || r.status === 'completed' ? '✅'
2307
+ : r.status === 'failed' ? '❌'
2308
+ : r.status === 'running' ? '🔄' : '⏳';
2309
+ const preview = (r.prompt || '').slice(0, 50).replace(/\n/g, ' ');
2310
+ const started = fmtTime(r.started_at);
2311
+ const done = fmtTime(r.completed_at || r.delivered_at);
2312
+ html.push(`<tr>
2313
+ <td>#${r.id}</td>
2314
+ <td>←#${r.parent_id ?? '?'} → <b>${escapeHtml(r.agent)}</b></td>
2315
+ <td>${r.call_depth}</td>
2316
+ <td>${icon} ${escapeHtml(r.status)}</td>
2317
+ <td title="${escapeHtml(r.prompt || '')}">${escapeHtml(preview)}…</td>
2318
+ <td>${started}</td>
2319
+ <td>${done}</td>
2320
+ <td><button class="btn-link" data-tree="${r.parent_id}">${T.a2aBtnViewParent}</button></td>
2321
+ </tr>`);
2322
+ }
2323
+ html.push('</tbody></table>');
2324
+ listEl.innerHTML = html.join('');
2325
+ listEl.querySelectorAll('[data-tree]').forEach((btn) => {
2326
+ btn.addEventListener('click', () => {
2327
+ const id = btn.getAttribute('data-tree');
2328
+ if (!id || id === 'null') return;
2329
+ document.getElementById('a2a-tree-id').value = id;
2330
+ loadA2ATree(id);
2331
+ });
2332
+ });
2333
+ } catch (err) {
2334
+ document.getElementById('a2a-list').innerHTML = `<p style="color:#d00">Failed: ${escapeHtml(String(err))}</p>`;
2335
+ }
2336
+ }
2337
+
2338
+ async function loadA2ATree(rootId) {
2339
+ const treeEl = document.getElementById('a2a-tree');
2340
+ if (!rootId) return;
2341
+ treeEl.innerHTML = `<p style="color:var(--text-muted)">${T.loading}</p>`;
2342
+ try {
2343
+ const resp = await fetch(`/api/a2a/tree/${rootId}`);
2344
+ const j = await resp.json();
2345
+ if (!resp.ok) { treeEl.innerHTML = `<p style="color:#d00">${escapeHtml(j.error || 'failed')}</p>`; return; }
2346
+ const lines = [`<h3 style="margin-top:1em">🌳 ${T.a2aTreeRoot.replace('{id}', String(rootId))}</h3>`];
2347
+ function render(node, depth) {
2348
+ const indent = '&nbsp;'.repeat(depth * 4);
2349
+ const icon = node.status === 'delivered' || node.status === 'completed' ? '✅'
2350
+ : node.status === 'failed' ? '❌'
2351
+ : node.status === 'running' ? '🔄' : '⏳';
2352
+ const preview = (node.prompt || '').slice(0, 70).replace(/\n/g, ' ');
2353
+ lines.push(`<div>${indent}${icon} <b>#${node.id}</b> ${escapeHtml(node.agent)} [${escapeHtml(node.status)}] ${escapeHtml(preview)}…</div>`);
2354
+ for (const f of (node.outputs || [])) {
2355
+ const fmt = (n) => n < 1024 ? `${n} B` : n < 1024*1024 ? `${(n/1024).toFixed(1)} KB` : `${(n/1024/1024).toFixed(1)} MB`;
2356
+ lines.push(`<div>${indent}&nbsp;&nbsp;&nbsp;&nbsp;📎 <a href="/api/artifacts/${node.id}/file/${encodeURIComponent(f.name)}" target="_blank">${escapeHtml(f.name)}</a> (${fmt(f.bytes)})</div>`);
2357
+ }
2358
+ for (const c of (node.children || [])) render(c, depth + 1);
2359
+ }
2360
+ render(j.tree, 0);
2361
+ treeEl.innerHTML = `<div style="font-family:monospace;font-size:13px;line-height:1.6">${lines.join('')}</div>`;
2362
+ } catch (err) {
2363
+ treeEl.innerHTML = `<p style="color:#d00">Tree failed: ${escapeHtml(String(err))}</p>`;
2364
+ }
2365
+ }
2366
+
2367
+ document.getElementById('btn-a2a-refresh')?.addEventListener('click', loadA2A);
2368
+ document.getElementById('btn-a2a-tree')?.addEventListener('click', () => {
2369
+ const id = document.getElementById('a2a-tree-id').value;
2370
+ if (id) loadA2ATree(id);
2371
+ });
2372
+
1820
2373
  // Initial load
1821
2374
  loadJobs();
1822
2375
  </script>