local-agent-dashboard 0.0.0-stage → 0.3.2
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 +13 -2
- package/native/qr +0 -0
- package/package.json +30 -4
- package/public/app.js +110 -0
- package/public/index.html +8 -0
- package/public/style.css +7 -0
- package/src/adapters/agents/codex-input.js +55 -0
- package/src/adapters/agents/log-collector.js +282 -0
- package/src/adapters/agents/log-state.js +165 -0
- package/src/adapters/agents/native-unread.js +45 -0
- package/src/application/pairing.js +83 -0
- package/src/application/snapshots.js +40 -0
- package/src/cli.js +54 -0
- package/src/collector.js +2 -0
- package/src/composition/main.js +165 -0
- package/src/domain/sessions.js +31 -0
- package/src/infrastructure/certificates.js +52 -0
- package/src/infrastructure/pairing-link.js +3 -0
- package/src/infrastructure/qr.js +22 -0
- package/src/infrastructure/runtime-root.js +6 -0
- package/src/server.js +74 -0
- package/src/state.js +2 -0
- package/src/transport/lan/server.js +98 -0
package/README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Signal macOS 服务端
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
需要 macOS 13+、Node >=22.18 和 OpenSSL 3(brew install openssl@3)。
|
|
4
|
+
|
|
5
|
+
运行:npx local-agent-dashboard;本地归档:npx --package ./包名.tgz local-agent-dashboard。
|
|
6
|
+
启动打开电脑任务总览,“连接手机”中默认预选全部网卡,应用后启用 TLS。手机安装独立 Android APK,App 扫码后在电脑批准。
|
|
7
|
+
|
|
8
|
+
数据在 ~/Library/Application Support/Agent Signal;--state-dir 可选已有目录,目录不得多人共用。源码版的 .runtime 不会自动迁移。
|
|
9
|
+
每次重启仍需重新批准手机,当前设备授权未持久化。
|
|
10
|
+
|
|
11
|
+
--help 查看参数;--doctor 检查依赖;--demo 仅合成样本;--no-open 不自动打开浏览器。
|
|
12
|
+
初次运行读取本机 Codex/Claude 的任务状态;不更改原生配置,不安装 Hook。
|
|
13
|
+
|
|
14
|
+
个人研究软件,尚未授权再分发;UNLICENSED。
|
package/native/qr
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-agent-dashboard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Agent Signal macOS LAN host for the Android App",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/jiahim/local-agent-dashboard.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"bin": {
|
|
11
|
+
"local-agent-dashboard": "src/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.18"
|
|
15
|
+
},
|
|
16
|
+
"os": [
|
|
17
|
+
"darwin"
|
|
18
|
+
],
|
|
19
|
+
"cpu": [
|
|
20
|
+
"arm64",
|
|
21
|
+
"x64"
|
|
22
|
+
],
|
|
23
|
+
"license": "UNLICENSED",
|
|
24
|
+
"files": [
|
|
25
|
+
"src/**/*.js",
|
|
26
|
+
"public/*.html",
|
|
27
|
+
"public/*.css",
|
|
28
|
+
"public/*.js",
|
|
29
|
+
"native/qr",
|
|
30
|
+
"README.md"
|
|
31
|
+
]
|
|
32
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export function initialInterfaces(status) {
|
|
2
|
+
return new Set(status.interfacesConfigured || status.selectedAddresses.length
|
|
3
|
+
? status.selectedAddresses : status.available.map(i => i.address));
|
|
4
|
+
}
|
|
5
|
+
export function invertInterfaces(available, selected) {
|
|
6
|
+
return new Set(available.map(i => i.address).filter(a => !selected.has(a)));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Keep the page in one asset so an already-running host can serve this update.
|
|
10
|
+
if (typeof document !== 'undefined') {
|
|
11
|
+
const $ = id => document.getElementById(id);
|
|
12
|
+
const node = (tag, text) => { const n = document.createElement(tag); if (text !== undefined) n.textContent = text; return n; };
|
|
13
|
+
let snapshot, filter = 'all', statuses = new Set(), selected, csrf, busy = false, inviteGeneration = 0, addressDraft, addressSignature = '';
|
|
14
|
+
const queue = r => r.lifecycle === 'running' ? 'running' : r.lifecycle === 'failed' ? 'failed' : r.lifecycle === 'completed' && r.readState === 'unread' ? 'completed_unread' : null;
|
|
15
|
+
const label = r => ({ running: ({ awaiting_approval: '待审核', awaiting_feedback: '待反馈' }[r.runningPhase] || '运行中'), completed: r.readState === 'read' ? '本轮结束·已读' : r.readState === 'unread' ? '本轮结束未读' : '本轮结束·查看待确认', failed: '运行报错', cancelled: '已停止', unknown: '状态待确认' }[r.lifecycle]);
|
|
16
|
+
const at = t => t ? new Date(t).toLocaleString('zh-CN', { hour12: false }) : '时间未知';
|
|
17
|
+
function render() {
|
|
18
|
+
selected ??= new Set(snapshot.sources.map(s => s.applicationId));
|
|
19
|
+
$('banner').hidden = snapshot.mode !== 'demo'; $('banner').textContent = '模拟数据 · 不代表真实双源接入';
|
|
20
|
+
$('applications').replaceChildren();
|
|
21
|
+
for (const s of snapshot.sources) { const l = node('label'), c = node('input'); c.type = 'checkbox'; c.checked = selected.has(s.applicationId); c.onchange = () => { c.checked ? selected.add(s.applicationId) : selected.delete(s.applicationId); render(); }; l.append(c, node('span', s.displayName)); $('applications').append(l); }
|
|
22
|
+
const rows = snapshot.rows.filter(r => selected.has(r.applicationId));
|
|
23
|
+
$('overview').replaceChildren();
|
|
24
|
+
for (const [key, title] of Object.entries({running:'运行中',completed_unread:'结束未读',failed:'报错'})) {
|
|
25
|
+
const count=rows.filter(r=>queue(r)===key).length,b=node('button'),chosen=statuses.has(key);
|
|
26
|
+
b.className=`status-card ${key}`;b.setAttribute('role','checkbox');b.setAttribute('aria-label',`${title}筛选`);b.setAttribute('aria-checked',String(chosen));
|
|
27
|
+
const mark=node('span',chosen?'✓':'');mark.className='selection-mark';mark.setAttribute('aria-hidden','true');const caption=node('span',title);if(key==='completed_unread'){const pending=rows.filter(r=>r.lifecycle==='completed'&&r.readState==='unconfirmed').length;if(pending){const note=node('small',`另有 ${pending} 条查看待确认`);note.className='count-note';caption.append(note);}}if(key==='failed'&&snapshot.mode!=='demo'&&snapshot.sources.some(s=>selected.has(s.applicationId)&&s.capabilities.terminalFailure!=='supported')){const note=node('small','仅已识别错误 · 部分异常未接入');note.className='count-note';caption.append(note);}b.append(caption,node('strong',String(count)),mark);
|
|
28
|
+
b.onclick=()=>{chosen?statuses.delete(key):statuses.add(key);filter='all';render();};$('overview').append(b);
|
|
29
|
+
}
|
|
30
|
+
const runningOnly=statuses.size===1&&statuses.has('running');
|
|
31
|
+
$('running-subsets').hidden=!runningOnly;$('running-subsets').replaceChildren();
|
|
32
|
+
if(runningOnly)for(const [key,title] of Object.entries({all:'全部',awaiting_approval:'待审核',awaiting_feedback:'待反馈'})){
|
|
33
|
+
const count=rows.filter(r=>r.lifecycle==='running'&&(key==='all'||r.runningPhase===key)).length,b=node('button',`${title} ${count}`);
|
|
34
|
+
b.setAttribute('aria-pressed',String(filter===key));b.onclick=()=>{filter=key;render();};$('running-subsets').append(b);
|
|
35
|
+
}
|
|
36
|
+
const allStatuses=statuses.size===0||statuses.size===3;
|
|
37
|
+
const visible=rows.filter(r=>allStatuses||statuses.has(queue(r))&&(!runningOnly||filter==='all'||r.runningPhase===filter)).sort((a,b)=>(b.activityAt??-1)-(a.activityAt??-1)||a.key.localeCompare(b.key));
|
|
38
|
+
$('tasks').replaceChildren();
|
|
39
|
+
for(const r of visible){const row=node('article'),title=node('span',r.title),source=node('small',r.applicationId==='codex-desktop'?'Codex':'Claude'),main=node('div'),meta=node('div');
|
|
40
|
+
row.className=`session ${queue(r)||r.lifecycle}`;title.className='session-title';main.className='session-main';meta.className='session-meta';
|
|
41
|
+
main.append(title,source);if(queue(r)==='completed_unread')main.prepend(node('em','● 未读'));
|
|
42
|
+
meta.append(node('span',`${label(r)}${r.freshness==='stale'?' · 旧数据':''}`),node('time',at(r.activityAt)));row.append(main,meta);$('tasks').append(row);
|
|
43
|
+
}
|
|
44
|
+
$('empty').hidden=visible.length!==0;$('empty').textContent=!selected.size?'未选择应用':rows.length?'当前筛选没有匹配的会话,可清除状态筛选查看。':'当前采集范围内没有会话。';
|
|
45
|
+
$('sources').replaceChildren(...snapshot.sources.map(s => node('p', `${s.displayName} · ${s.health} · ${s.coverage} · 原生查看:${s.capabilities.desktopViewing}${s.excludedUnidentified ? ` · 排除 ${s.excludedUnidentified} 条无法确认桌面身份的记录` : ''}`)));
|
|
46
|
+
}
|
|
47
|
+
async function manage(action, input = {}) {
|
|
48
|
+
const r = await fetch(`/api/manage/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Agent-Signal-CSRF': csrf }, body: JSON.stringify(input) });
|
|
49
|
+
if (!r.ok) throw Error('操作未完成;请确认 LAN 已开启且申请尚未过期。'); return r.json();
|
|
50
|
+
}
|
|
51
|
+
async function management() {
|
|
52
|
+
const r = await fetch('/api/manage/status'); if (!r.ok) return;
|
|
53
|
+
const s = await r.json(); csrf = s.csrf;
|
|
54
|
+
$('lan').textContent = s.endpoints.length ? `TLS 入口:${s.endpoints.join(', ')} · 电脑指纹 ${s.computerId}${s.certificateWarning ? ' · '+s.certificateWarning : ''}` : 'LAN 未开启。勾选允许的地址并点击“应用所选地址”;只在你选择的网卡监听。';
|
|
55
|
+
$('invite').disabled = !s.endpoints.length;
|
|
56
|
+
addressDraft ??= initialInterfaces(s);
|
|
57
|
+
availableInterfaces=s.available;
|
|
58
|
+
const availableSet=new Set(s.available.map(i=>i.address));
|
|
59
|
+
addressDraft=new Set([...addressDraft].filter(a=>availableSet.has(a)));
|
|
60
|
+
const signature = JSON.stringify(s.available);
|
|
61
|
+
if (signature !== addressSignature) {
|
|
62
|
+
addressSignature = signature; $('interfaces').replaceChildren();
|
|
63
|
+
for (const i of s.available) {
|
|
64
|
+
const l = node('label'), c = node('input'); c.type = 'checkbox'; c.checked = addressDraft.has(i.address);
|
|
65
|
+
c.onchange = () => { c.checked ? addressDraft.add(i.address) : addressDraft.delete(i.address); updateInterfaceControls(); };
|
|
66
|
+
l.append(c, node('span', `${i.name} · ${i.address}`)); $('interfaces').append(l);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
updateInterfaceControls();
|
|
70
|
+
$('requests').replaceChildren();
|
|
71
|
+
for (const r of s.requests.filter(r => r.state === 'pending')) { const p = node('p', `${r.name} · 核对码 ${r.code} `), b = node('button', '批准此申请'); b.onclick = async () => { try { await manage('approve', { requestId: r.requestId, code: r.code }); await management(); } catch (e) { $('error').hidden = false; $('error').textContent = e.message; } }; p.append(b); $('requests').append(p); }
|
|
72
|
+
if(!$('requests').children.length)$('requests').append(node('p','暂时没有新的连接申请。'));
|
|
73
|
+
$('devices').replaceChildren();
|
|
74
|
+
for(const d of s.devices){const card=node('section'),heading=node('h2',d.name),status=node('p',d.revoked?'已撤销':d.online?'已连接 · 近期收到手机确认':'离线 · 等待手机确认');card.className='device-card';status.className=d.revoked?'muted':d.online?'online':'muted';card.append(heading,status,node('p',d.ack?`最近确认:${at(d.ack.receivedAt)} · 版本 ${d.ack.revision}`:'尚未收到同步确认'));
|
|
75
|
+
if(!d.revoked){const b=node('button','撤销访问');b.className='danger';b.setAttribute('aria-label',`撤销 ${d.name} 的访问`);b.onclick=async()=>{try{await manage('revoke',{deviceId:d.id});await management();}catch(e){$('error').hidden=false;$('error').textContent=e.message;}};card.append(b);}$('devices').append(card);
|
|
76
|
+
}
|
|
77
|
+
if(!s.devices.length)$('devices').append(node('p','还没有已配对设备。点击“连接手机”添加。'));
|
|
78
|
+
|
|
79
|
+
}
|
|
80
|
+
let availableInterfaces=[];
|
|
81
|
+
function updateInterfaceControls(){
|
|
82
|
+
document.querySelectorAll('#interfaces input').forEach((c,index)=>{c.checked=addressDraft.has(availableInterfaces[index].address);});
|
|
83
|
+
$('interface-count').textContent=`已选 ${addressDraft.size} / ${availableInterfaces.length} 个地址${addressDraft.size>8?'(最多启用 8 个)':''}`;
|
|
84
|
+
$('apply-interfaces').disabled=addressDraft.size>8;
|
|
85
|
+
}
|
|
86
|
+
$('select-all-interfaces').onclick=()=>{addressDraft=new Set(availableInterfaces.map(i=>i.address));updateInterfaceControls();};
|
|
87
|
+
$('invert-interfaces').onclick=()=>{addressDraft=invertInterfaces(availableInterfaces,addressDraft);updateInterfaceControls();};
|
|
88
|
+
$('apply-interfaces').onclick = async () => { try { await manage('interfaces', { addresses: [...addressDraft] }); inviteGeneration++; $('qr').replaceChildren(); $('invitation-actions').hidden=true; $('expiry').textContent = '入口已更新,请生成新邀请;同一电脑存量授权保留。'; await management(); } catch (e) { $('error').hidden = false; $('error').textContent = e.message; } };
|
|
89
|
+
$('invite').onclick = async () => { try { const generation = ++inviteGeneration; const r = await manage('invite'); const img = node('img'); img.src = r.image; img.alt = 'Android App 配对邀请'; $('qr').replaceChildren(img); $('large-qr').src=r.image; $('pair-link').href=r.link; $('pair-link-text').value=r.link; $('invitation-actions').hidden=!r.link; $('expiry').textContent = `邀请到期:${at(r.expiresAt)};扫码或手机点击链接均可`; setTimeout(() => { if (generation !== inviteGeneration) return; $('qr').replaceChildren(); $('invitation-actions').hidden=true; $('pair-link').removeAttribute('href'); $('pair-link-text').value=''; $('qr-dialog').close(); $('expiry').textContent = '邀请已到期,请重新生成'; }, Math.max(0, r.expiresAt - Date.now())); } catch (e) { $('error').hidden = false; $('error').textContent = e.message; } };
|
|
90
|
+
async function refresh() {
|
|
91
|
+
if(busy)return;busy=true;
|
|
92
|
+
try {const r=await fetch('/api/snapshot',{cache:'no-store',signal:AbortSignal.timeout(10000)});if(!r.ok)throw Error();snapshot=await r.json();$('connection').textContent=`本机同步 ${at(snapshot.generatedAt)} · v${snapshot.revision}`;$('error').hidden=true;render();}
|
|
93
|
+
catch {$('connection').textContent=`任务采集暂不可用 · ${at(snapshot?.generatedAt)}`;}
|
|
94
|
+
try {await management();}catch {$('error').hidden=false;$('error').textContent='配置服务暂不可用,请确认电脑服务仍在运行。';}finally{busy=false;}
|
|
95
|
+
}
|
|
96
|
+
function route(){
|
|
97
|
+
const key=location.hash.slice(1),page=({connect:'connect',setup:'connect',devices:'devices','device-management':'devices'})[key]||'tasks';
|
|
98
|
+
document.querySelectorAll('[data-route]').forEach(el=>el.hidden=el.dataset.route!==page);
|
|
99
|
+
document.querySelectorAll('[data-page]').forEach(el=>{if(el.dataset.page===page)el.setAttribute('aria-current','page');else el.removeAttribute('aria-current');});
|
|
100
|
+
const title={tasks:'任务总览',connect:'连接手机',devices:'设备管理'}[page];$('page-title').textContent=title;document.title=`Agent Signal · ${title}`;
|
|
101
|
+
}
|
|
102
|
+
window.addEventListener('hashchange',route);route();
|
|
103
|
+
$('reset-filters').onclick=()=>{statuses=new Set();filter='all';render();};
|
|
104
|
+
refresh(); setInterval(refresh, 3000);
|
|
105
|
+
|
|
106
|
+
$('enlarge-qr').onclick=()=>$('qr-dialog').showModal();
|
|
107
|
+
$('close-qr').onclick=()=>$('qr-dialog').close();
|
|
108
|
+
$('copy-pair-link').onclick=async()=>{try{await navigator.clipboard.writeText($('pair-link-text').value);$('link-feedback').textContent='已复制,请发送到自己的手机;两分钟内有效。';}catch{$('pair-link-text').hidden=false;$('pair-link-text').select();$('link-feedback').textContent='请手动复制下方链接。';}};
|
|
109
|
+
|
|
110
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Agent Signal · 任务总览</title><link rel="stylesheet" href="/style.css"><body>
|
|
2
|
+
<header><div><small>AGENT SIGNAL</small><h1 id="page-title">任务总览</h1></div><span id="connection">连接中</span></header>
|
|
3
|
+
<nav class="management-nav" aria-label="主要导航"><a href="#tasks" data-page="tasks" aria-current="page">任务总览</a><a href="#devices" data-page="devices">设备管理</a><a class="primary" href="#connect" data-page="connect">连接手机</a></nav>
|
|
4
|
+
<p id="error" role="alert" hidden></p><p id="banner" hidden></p>
|
|
5
|
+
<main id="tasks-page" data-route="tasks"><p class="intro">这里是手机同步的任务数据。查看最近会话,或筛选需要关注的状态。</p><div id="overview" aria-label="状态筛选"></div><nav id="running-subsets" aria-label="运行中子筛选" hidden></nav><div class="board-toolbar"><nav id="applications" aria-label="来源筛选"></nav><button id="reset-filters">清除状态筛选</button></div><div id="tasks" aria-label="会话列表"></div><p id="empty" class="empty" hidden></p><details class="coverage"><summary>数据来源与覆盖范围</summary><div id="sources"></div></details></main>
|
|
6
|
+
<main id="connect-page" data-route="connect" hidden><a class="back" href="#tasks">‹ 返回任务总览</a><div class="connect-grid"><section><h2>连接你的手机</h2><p>在 Agent Signal App 中打开「设置 → 电脑连接 → 扫码连接电脑」,或在手机点击配对链接。</p><button class="primary" id="invite">生成配对邀请</button><div id="qr"></div><div id="invitation-actions" hidden><button id="enlarge-qr">放大二维码</button><button id="copy-pair-link">复制配对链接</button><p id="link-feedback" role="status"></p><a id="pair-link">在手机上打开 App</a><textarea id="pair-link-text" readonly aria-label="配对链接" hidden></textarea></div><p id="expiry"></p></section><div><section><h2>电脑连接地址</h2><p>手机与电脑使用同一局域网。仅启用你选择的 Wi-Fi 或有线网络地址。</p><div class="interface-actions"><button id="select-all-interfaces">全选</button><button id="invert-interfaces">反选</button><span id="interface-count" aria-live="polite"></span></div><div id="interfaces"></div><button id="apply-interfaces">应用所选地址</button><p id="lan"></p></section><section><h2>待确认的手机</h2><p>核对手机显示的申请码,批准对应的申请。</p><div id="requests"></div></section></div></div></main>
|
|
7
|
+
<main id="devices-page" data-route="devices" hidden><a class="back" href="#tasks">‹ 返回任务总览</a><p class="intro">管理已配对手机,查看最近同步确认。撤销后,该设备将无法继续读取任务。</p><div id="devices"></div><a class="text-link" href="#connect">连接另一台手机 →</a></main>
|
|
8
|
+
<dialog id="qr-dialog"><button id="close-qr">关闭</button><p>使用 Agent Signal App 扫码</p><img id="large-qr" alt="放大的手机配对邀请"></dialog><script type="module" src="/app.js"></script></body></html>
|
package/public/style.css
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
:root{color-scheme:light;font:15px/1.5 system-ui;background:#f6f8fb;color:#202833}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0 auto;padding:28px;max-width:1280px}header{display:flex;align-items:center;justify-content:space-between;gap:20px}header small{font-size:11px;letter-spacing:.18em;color:#176b59}h1{font-size:30px;line-height:1.2;margin:6px 0 20px}h2{font-size:19px;margin:0 0 10px}p{color:#536173}#connection{font-size:13px;color:#176b59}.management-nav{display:flex;align-items:center;gap:8px;border-bottom:1px solid #dce2ea;padding-bottom:16px;margin-bottom:20px}.management-nav a{color:#536173;text-decoration:none;border:1px solid transparent;border-radius:9px;padding:10px 16px}.management-nav a[aria-current=page]{background:#e6f2ed;color:#176b59;border-color:#b9cec7}.management-nav .primary{margin-left:auto}button,.management-nav .primary{background:#fff;border:1px solid #bec9d4;color:#176b59;padding:9px 14px;border-radius:9px;cursor:pointer;font:inherit;min-height:42px}button.primary,.management-nav .primary{background:#176b59;color:white;border-color:#176b59}button:disabled{opacity:.45;cursor:not-allowed}button:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid #3e94ed;outline-offset:3px}button[aria-pressed=true]{border-color:#176b59;background:#e8f3ef}label{display:flex;align-items:center;gap:7px;cursor:pointer}.intro{margin:0 0 20px}#overview{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.status-card{display:flex;align-items:center;gap:14px;text-align:left;background:#fff;border-width:2px;border-color:#dce2ea}.status-card strong{font-size:30px;margin-left:auto;line-height:1.2}.status-card[aria-checked=true].running{background:#fff4d8;border-color:#855a0d;color:#855a0d}.status-card[aria-checked=true].completed_unread{background:#e8f4ec;border-color:#22633e;color:#22633e}.status-card[aria-checked=true].failed{background:#fcedec;border-color:#ad303a;color:#ad303a}.board-toolbar{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:18px 0}#applications,#running-subsets{display:flex;flex-wrap:wrap;gap:12px}#running-subsets{margin-top:12px}.session{display:flex;justify-content:space-between;gap:24px;border-left:3px solid #bec9d4;padding:14px 16px;border-bottom:1px solid #dce2ea;background:#fff;min-height:72px}.session-main{min-width:0;flex:1;display:flex;flex-direction:column;justify-content:center}.session-title{font-size:18px;font-weight:600;overflow-wrap:anywhere}.session-main small{color:#687587;font-size:12px}.session-meta{display:flex;flex-direction:column;text-align:right;justify-content:center;flex-shrink:0;gap:3px;font-size:14px}.session-meta time{color:#536173;font-size:12px;font-variant-numeric:tabular-nums}.session.running{border-left-color:#c99e42}.session.running .session-meta{color:#855a0d}.session.completed,.session.completed_unread{border-left-color:#a9d1b8}.session.completed .session-meta,.session.completed_unread .session-meta{color:#22633e}.session.failed{border-left-color:#ad303a}.session.failed .session-meta{color:#ad303a}.session.completed_unread{background:#e5f3e9}.session em{font-style:normal;color:#22633e;font-size:12px;font-weight:700}.coverage{margin-top:24px;font-size:13px;color:#536173}.coverage summary{cursor:pointer}.empty{padding:40px;text-align:center}section{background:#fff;border:1px solid #dce2ea;border-radius:14px;padding:22px;margin-bottom:18px}section p{line-height:1.7}.back,.text-link{display:inline-block;color:#176b59;text-decoration:none;margin-bottom:18px}.connect-grid{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:20px;align-items:start}#interfaces{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:16px}#interfaces label{padding:10px;background:#f6f8fb;border:1px solid #dce2ea;border-radius:8px}#lan{overflow-wrap:anywhere;font-size:12px}#qr img{display:block;width:min(680px,100%);height:auto;margin:20px auto;image-rendering:pixelated}#invitation-actions{display:flex;gap:10px;flex-wrap:wrap;align-items:center}#pair-link{color:#176b59}#pair-link-text{width:100%;height:90px;overflow-wrap:anywhere}#qr-dialog{border:0;border-radius:16px;padding:20px;text-align:center;max-width:94vw}#qr-dialog::backdrop{background:#0009}#large-qr{display:block;width:min(82vh,86vw,1000px);image-rendering:pixelated}#error,#banner{background:#fff3d9;color:#795414;padding:14px;border-radius:9px}#devices{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px}.device-card{margin:0 0 18px}.online{color:#22633e}.muted{color:#687587}.danger{color:#ad303a;border-color:#e4b0b4}@media(max-width:800px){body{padding:18px}.connect-grid{grid-template-columns:1fr}.session-title{font-size:16px}.session{gap:12px}}@media(max-width:500px){header{align-items:flex-start}#connection{max-width:150px}h1{font-size:25px}.management-nav{gap:3px}.management-nav a{padding:9px;font-size:14px}.status-card{gap:5px;padding:10px;font-size:13px}.status-card strong{font-size:24px}.board-toolbar{align-items:flex-start}.session-meta{max-width:46%;white-space:normal}.session-meta time{font-size:11px}}
|
|
2
|
+
|
|
3
|
+
.selection-mark{flex:0 0 20px;width:20px;height:20px;border:1px solid currentColor;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:13px}.status-card[aria-checked=true] .selection-mark{color:#fff}.status-card[aria-checked=true].running .selection-mark{background:#855a0d;border-color:#855a0d}.status-card[aria-checked=true].completed_unread .selection-mark{background:#22633e;border-color:#22633e}.status-card[aria-checked=true].failed .selection-mark{background:#ad303a;border-color:#ad303a}
|
|
4
|
+
|
|
5
|
+
.count-note{display:block;font-size:12px;color:#536173;margin-top:3px}
|
|
6
|
+
|
|
7
|
+
.interface-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 0}.interface-actions span{font-size:14px;color:#576979}#interfaces{display:flex;flex-direction:column;gap:10px;margin:12px 0}#interfaces label{display:flex;align-items:center;gap:10px;min-height:36px;overflow-wrap:anywhere}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Only retain exact request IDs; questions, answers and other tool bodies never enter the cache.
|
|
2
|
+
export function inputEvents(record, pending) {
|
|
3
|
+
const p = record.payload, events = [];
|
|
4
|
+
const emit = (type, id) => events.push({ timestamp: record.timestamp, type: 'event_msg', payload: { type, call_id: id } });
|
|
5
|
+
const valid = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
|
|
6
|
+
if (record.type !== 'response_item' || !p)
|
|
7
|
+
return events;
|
|
8
|
+
if (p.type === 'function_call' && ['request_user_input', 'request_user_input_async'].includes(p.name) && valid(p.call_id)) {
|
|
9
|
+
try {
|
|
10
|
+
const args = JSON.parse(p.arguments);
|
|
11
|
+
if (Array.isArray(args.questions) && args.questions.length > 0 && args.questions.length <= 8)
|
|
12
|
+
args.questions.forEach((_, index) => { const id = `${p.call_id}:${index}`; if (pending.size < 8) {
|
|
13
|
+
pending.add(id);
|
|
14
|
+
emit('input_requested', id);
|
|
15
|
+
} });
|
|
16
|
+
}
|
|
17
|
+
catch { }
|
|
18
|
+
}
|
|
19
|
+
if (p.type === 'function_call_output' && valid(p.call_id) && [...pending].some(id => id.startsWith(p.call_id + ':'))) {
|
|
20
|
+
let output;
|
|
21
|
+
try {
|
|
22
|
+
output = typeof p.output === 'string' ? JSON.parse(p.output) : p.output;
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
if (output?.accepted !== true)
|
|
26
|
+
for (const id of [...pending])
|
|
27
|
+
if (id.startsWith(p.call_id + ':')) {
|
|
28
|
+
pending.delete(id);
|
|
29
|
+
emit('input_resolved', id);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (p.type === 'message' && p.role === 'user' && Array.isArray(p.content))
|
|
33
|
+
for (const part of p.content) {
|
|
34
|
+
if (!['input_text', 'text'].includes(part.type) || typeof part.text !== 'string')
|
|
35
|
+
continue;
|
|
36
|
+
const match = part.text.trim().match(/^<send_user_message_question_reply>\s*([\s\S]*)\s*<\/send_user_message_question_reply>$/);
|
|
37
|
+
if (!match)
|
|
38
|
+
continue;
|
|
39
|
+
try {
|
|
40
|
+
const replies = JSON.parse(match[1]);
|
|
41
|
+
if (!Array.isArray(replies) || replies.length > 24)
|
|
42
|
+
continue;
|
|
43
|
+
for (const reply of replies) {
|
|
44
|
+
const key = JSON.parse(reply.questionItemId);
|
|
45
|
+
if (Array.isArray(key) && ['request_user_input', 'request_user_input_async'].includes(key[0]) && valid(key[1]) && Number.isSafeInteger(key[2]) && pending.has(`${key[1]}:${key[2]}`)) {
|
|
46
|
+
const id = `${key[1]}:${key[2]}`;
|
|
47
|
+
pending.delete(id);
|
|
48
|
+
emit('input_resolved', id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
}
|
|
54
|
+
return events;
|
|
55
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { readdir, open, stat } from 'node:fs/promises';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { join, basename, dirname } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { inputEvents } from "./codex-input.js";
|
|
6
|
+
import { readNativeUnread } from "./native-unread.js";
|
|
7
|
+
import { projectCodex, projectClaude, ageTask, codexTerminalFailure } from "./log-state.js";
|
|
8
|
+
const MAX_FILES = 64, HEAD = 512 * 1024, TAIL = 1024 * 1024;
|
|
9
|
+
// Stream Codex lifecycle records on first observation, then read only appended bytes.
|
|
10
|
+
// Keep metadata and lifecycle evidence, never prompt/tool output bodies, in the cache.
|
|
11
|
+
async function codexRecords(file, previous) {
|
|
12
|
+
const handle = await open(file.path, 'r');
|
|
13
|
+
let meta;
|
|
14
|
+
try {
|
|
15
|
+
const bytes = Buffer.alloc(Math.min(file.size, HEAD));
|
|
16
|
+
const read = await handle.read(bytes, 0, bytes.length, 0);
|
|
17
|
+
const first = JSON.parse(bytes.subarray(0, read.bytesRead).toString('utf8').split('\n')[0]);
|
|
18
|
+
const p = first.payload;
|
|
19
|
+
if (first.type !== 'session_meta' || !p)
|
|
20
|
+
throw new Error('Missing session header');
|
|
21
|
+
meta = { type: 'session_meta', payload: { id: p.id, session_id: p.session_id, source: p.source, originator: p.originator, thread_source: p.thread_source, parent_thread_id: p.parent_thread_id, cwd: typeof p.cwd === 'string' ? basename(p.cwd) : '' } };
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await handle.close();
|
|
25
|
+
}
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
if (!projectCodex([meta], basename(file.path), now))
|
|
28
|
+
return { records: [meta], cursor: file.size, incomplete: 0, seed: null };
|
|
29
|
+
const append = previous && previous.ino === file.ino && previous.size < file.size;
|
|
30
|
+
const start = append ? previous.cursor : 0;
|
|
31
|
+
let cursor = start, consumed = start, pending = Buffer.alloc(0), skipping = false, incomplete = 0;
|
|
32
|
+
let task = append ? previous.task : projectCodex([meta], basename(file.path), now);
|
|
33
|
+
const project = (event) => { task = projectCodex([meta, event], basename(file.path), now, task); };
|
|
34
|
+
const pendingInputs = new Set(append ? previous?.task?.pendingInputIds ?? [] : []);
|
|
35
|
+
let activity;
|
|
36
|
+
if (start < file.size) {
|
|
37
|
+
for await (const value of createReadStream(file.path, { start, end: file.size - 1, highWaterMark: 64 * 1024 })) {
|
|
38
|
+
const bytes = value;
|
|
39
|
+
const buffer = pending.length ? Buffer.concat([pending, bytes]) : bytes;
|
|
40
|
+
let offset = 0, newline;
|
|
41
|
+
while ((newline = buffer.indexOf(10, offset)) >= 0) {
|
|
42
|
+
const line = buffer.subarray(offset, newline);
|
|
43
|
+
consumed += line.length + 1;
|
|
44
|
+
cursor = consumed;
|
|
45
|
+
if (!skipping && line.length <= 4 * TAIL && (line.includes('"event_msg"') || line.includes('request_user_input') || pendingInputs.size > 0 && (line.includes('function_call_output') || line.includes('send_user_message_question_reply')))) {
|
|
46
|
+
try {
|
|
47
|
+
const r = JSON.parse(line.toString('utf8'));
|
|
48
|
+
for (const event of inputEvents(r, pendingInputs))
|
|
49
|
+
project(event);
|
|
50
|
+
if (r.type === 'event_msg' && r.payload && typeof r.payload.type === 'string') {
|
|
51
|
+
const compact = { timestamp: r.timestamp, type: r.type, payload: { type: r.payload.type, turn_id: r.payload.turn_id, ...(r.payload.type === 'task_complete' && codexTerminalFailure(r.payload) ? { terminalFailure: true } : {}) } };
|
|
52
|
+
if (['task_started', 'task_complete', 'turn_aborted'].includes(r.payload.type)) {
|
|
53
|
+
pendingInputs.clear();
|
|
54
|
+
project(compact);
|
|
55
|
+
}
|
|
56
|
+
else if (['agent_message', 'agent_reasoning', 'item_completed', 'token_count', 'exec_command_begin', 'exec_command_end', 'mcp_tool_call_begin', 'mcp_tool_call_end'].includes(r.payload.type))
|
|
57
|
+
activity = compact;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
incomplete++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
skipping = false;
|
|
65
|
+
offset = newline + 1;
|
|
66
|
+
}
|
|
67
|
+
pending = Buffer.from(buffer.subarray(offset));
|
|
68
|
+
if (pending.length > 4 * TAIL) {
|
|
69
|
+
consumed += pending.length;
|
|
70
|
+
pending = Buffer.alloc(0);
|
|
71
|
+
skipping = true;
|
|
72
|
+
incomplete++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (pending.length || skipping)
|
|
77
|
+
incomplete++;
|
|
78
|
+
return { records: [meta, ...(activity ? [activity] : [])], cursor, incomplete, seed: task };
|
|
79
|
+
}
|
|
80
|
+
// Same bounded streaming/incremental strategy for long Claude transcripts.
|
|
81
|
+
async function claudeRecords(file, previous) {
|
|
82
|
+
const append = previous && previous.ino === file.ino && previous.size < file.size;
|
|
83
|
+
const start = append ? previous.cursor : 0;
|
|
84
|
+
let task = append ? previous.task : null;
|
|
85
|
+
let cursor = start, consumed = start, pending = Buffer.alloc(0), skipping = false, incomplete = 0;
|
|
86
|
+
if (start < file.size)
|
|
87
|
+
for await (const value of createReadStream(file.path, { start, end: file.size - 1, highWaterMark: 65536 })) {
|
|
88
|
+
const buffer = Buffer.concat([pending, value]);
|
|
89
|
+
let offset = 0, newline;
|
|
90
|
+
while ((newline = buffer.indexOf(10, offset)) >= 0) {
|
|
91
|
+
const line = buffer.subarray(offset, newline);
|
|
92
|
+
consumed += line.length + 1;
|
|
93
|
+
cursor = consumed;
|
|
94
|
+
if (!skipping && line.length <= 4 * TAIL)
|
|
95
|
+
try {
|
|
96
|
+
const r = JSON.parse(line.toString('utf8'));
|
|
97
|
+
task = projectClaude([r], basename(file.path), Date.now(), task);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
incomplete++;
|
|
101
|
+
}
|
|
102
|
+
skipping = false;
|
|
103
|
+
offset = newline + 1;
|
|
104
|
+
}
|
|
105
|
+
pending = Buffer.from(buffer.subarray(offset));
|
|
106
|
+
if (pending.length > 4 * TAIL) {
|
|
107
|
+
consumed += pending.length;
|
|
108
|
+
pending = Buffer.alloc(0);
|
|
109
|
+
skipping = true;
|
|
110
|
+
incomplete++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (pending.length || skipping)
|
|
114
|
+
incomplete++;
|
|
115
|
+
return { task, cursor, incomplete };
|
|
116
|
+
}
|
|
117
|
+
export class Collector {
|
|
118
|
+
roots;
|
|
119
|
+
cache = new Map();
|
|
120
|
+
lastGood = new Map();
|
|
121
|
+
titleSignature = '';
|
|
122
|
+
titles = new Map();
|
|
123
|
+
constructor(options = {}) {
|
|
124
|
+
this.roots = [
|
|
125
|
+
{ provider: 'codex', path: options.codexRoot ?? join(homedir(), '.codex', 'sessions') },
|
|
126
|
+
{ provider: 'claude', path: options.claudeRoot ?? join(homedir(), '.claude', 'projects') },
|
|
127
|
+
];
|
|
128
|
+
this.hours = options.hours ?? 24;
|
|
129
|
+
}
|
|
130
|
+
hours;
|
|
131
|
+
async readTitles() {
|
|
132
|
+
const file = join(dirname(this.roots[0].path), 'session_index.jsonl');
|
|
133
|
+
try {
|
|
134
|
+
const info = await stat(file), signature = `${info.size}:${info.mtimeMs}`;
|
|
135
|
+
if (signature === this.titleSignature)
|
|
136
|
+
return;
|
|
137
|
+
const handle = await open(file, 'r');
|
|
138
|
+
try {
|
|
139
|
+
const start = Math.max(0, info.size - TAIL), buffer = Buffer.alloc(Math.min(info.size, TAIL));
|
|
140
|
+
const result = await handle.read(buffer, 0, buffer.length, start);
|
|
141
|
+
let body = buffer.subarray(0, result.bytesRead).toString('utf8');
|
|
142
|
+
if (start)
|
|
143
|
+
body = body.slice(body.indexOf('\n') + 1);
|
|
144
|
+
const titles = new Map();
|
|
145
|
+
for (const line of body.split('\n')) {
|
|
146
|
+
try {
|
|
147
|
+
const row = JSON.parse(line);
|
|
148
|
+
if (typeof row.id === 'string' && typeof row.thread_name === 'string')
|
|
149
|
+
titles.set(row.id, row.thread_name.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 120));
|
|
150
|
+
}
|
|
151
|
+
catch { /* active files may end with an incomplete line */ }
|
|
152
|
+
}
|
|
153
|
+
this.titles = titles;
|
|
154
|
+
this.titleSignature = signature;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await handle.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* titles are optional; status collection remains usable */ }
|
|
161
|
+
}
|
|
162
|
+
async snapshot() {
|
|
163
|
+
const now = Date.now(), tasks = [], sources = [];
|
|
164
|
+
await this.readTitles();
|
|
165
|
+
const nativeUnread = await readNativeUnread(join(dirname(this.roots[0].path), '.codex-global-state.json'));
|
|
166
|
+
for (const source of this.roots) {
|
|
167
|
+
const candidates = [];
|
|
168
|
+
let errors = 0, skipped = 0;
|
|
169
|
+
const walk = async (dir, depth = 0) => {
|
|
170
|
+
if (depth > 6)
|
|
171
|
+
return;
|
|
172
|
+
let entries;
|
|
173
|
+
try {
|
|
174
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
errors++;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
if (entry.isSymbolicLink())
|
|
182
|
+
continue;
|
|
183
|
+
const path = join(dir, entry.name);
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
if (entry.name === 'subagents' || entry.name.startsWith('.'))
|
|
186
|
+
continue;
|
|
187
|
+
await walk(path, depth + 1);
|
|
188
|
+
}
|
|
189
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
190
|
+
try {
|
|
191
|
+
const s = await stat(path);
|
|
192
|
+
if (s.mtimeMs >= now - this.hours * 3600_000)
|
|
193
|
+
candidates.push({ path, mtimeMs: s.mtimeMs, size: s.size, ino: s.ino });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
errors++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
await walk(source.path);
|
|
202
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
203
|
+
let accepted = 0;
|
|
204
|
+
const sourceTasks = [];
|
|
205
|
+
for (const file of candidates.slice(0, MAX_FILES)) {
|
|
206
|
+
const signature = `${file.size}:${file.mtimeMs}:${file.ino}`;
|
|
207
|
+
let cached = this.cache.get(file.path);
|
|
208
|
+
if (!cached || cached.signature !== signature) {
|
|
209
|
+
try {
|
|
210
|
+
if (source.provider === 'codex') {
|
|
211
|
+
const parsed = await codexRecords(file, cached);
|
|
212
|
+
cached = { signature, task: projectCodex(parsed.records, basename(file.path), now, parsed.seed), incomplete: parsed.incomplete, cursor: parsed.cursor, size: file.size, ino: file.ino };
|
|
213
|
+
this.cache.set(file.path, cached);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const parsed = await claudeRecords(file, cached);
|
|
217
|
+
cached = { signature, task: parsed.task, incomplete: parsed.incomplete, cursor: parsed.cursor, size: file.size, ino: file.ino };
|
|
218
|
+
this.cache.set(file.path, cached);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
errors++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
skipped += cached.incomplete;
|
|
227
|
+
if (cached.task) {
|
|
228
|
+
const task = { ...ageTask(cached.task, now) };
|
|
229
|
+
if (task.provider === 'codex' && this.titles.has(task.id))
|
|
230
|
+
task.title = this.titles.get(task.id);
|
|
231
|
+
sourceTasks.push(task);
|
|
232
|
+
accepted++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (errors) {
|
|
236
|
+
const existing = new Set(sourceTasks.map(t => t.id));
|
|
237
|
+
for (const t of this.lastGood.get(source.provider) ?? [])
|
|
238
|
+
if (!existing.has(t.id))
|
|
239
|
+
sourceTasks.push({ ...t, sourceStale: true });
|
|
240
|
+
}
|
|
241
|
+
else
|
|
242
|
+
this.lastGood.set(source.provider, sourceTasks);
|
|
243
|
+
tasks.push(...sourceTasks.map(t => errors ? { ...t, sourceStale: true } : t));
|
|
244
|
+
sources.push({ provider: source.provider, nativeUnreadAvailable: source.provider === 'codex' && nativeUnread !== null, files: candidates.length, tasks: accepted, errors, incompleteLines: skipped, capped: candidates.length > MAX_FILES,
|
|
245
|
+
coverage: source.provider === 'codex' ? '本地日志:开始 / 本轮结束 / 明确终止错误 / 中断 / 用户输入请求;创建结果未确认等创建阶段异常未接入,审批等待尚未接入' : '本地日志:活动 / 本轮结束 / 交互问题;终止错误与实时审批等待尚未接入' });
|
|
246
|
+
}
|
|
247
|
+
// Retain only cached files in the current time window. No transcript records are retained.
|
|
248
|
+
const cutoff = now - this.hours * 3600_000;
|
|
249
|
+
for (const [path, cached] of this.cache)
|
|
250
|
+
if (Number(cached.signature.split(':')[1]) < cutoff)
|
|
251
|
+
this.cache.delete(path);
|
|
252
|
+
const unique = new Map();
|
|
253
|
+
for (const task of tasks) {
|
|
254
|
+
const key = `${task.provider}:${task.id}`, previous = unique.get(key);
|
|
255
|
+
if (!previous || task.updatedAt > previous.updatedAt)
|
|
256
|
+
unique.set(key, task);
|
|
257
|
+
}
|
|
258
|
+
for (const task of unique.values()) {
|
|
259
|
+
delete task.nativeUnread;
|
|
260
|
+
if (task.applicationId === 'codex-desktop' && task.status === 'ended' && !task.sourceStale && nativeUnread?.has(task.id))
|
|
261
|
+
task.nativeUnread = true;
|
|
262
|
+
}
|
|
263
|
+
for (const source of sources)
|
|
264
|
+
source.tasks = [...unique.values()].filter(t => t.provider === source.provider).length;
|
|
265
|
+
return { mode: 'live', generatedAt: now, hours: this.hours, sources, tasks: [...unique.values()].sort((a, b) => b.updatedAt - a.updatedAt) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const demoStartedAt = Date.now();
|
|
269
|
+
export function demoSnapshot() {
|
|
270
|
+
const now = demoStartedAt;
|
|
271
|
+
const sample = (id, provider, title, project, status, evidence, seconds) => ({ id, provider, title, project, status, evidence, updatedAt: now - seconds * 1000, observedAt: now, turnId: 'demo-turn', reviewKey: `demo:${id}:turn-1` });
|
|
272
|
+
return { mode: 'demo', generatedAt: now, hours: 24, sources: [
|
|
273
|
+
{ provider: 'codex', files: 3, tasks: 3, errors: 0, coverage: '模拟数据;未读取真实任务' },
|
|
274
|
+
{ provider: 'claude', files: 2, tasks: 2, errors: 0, coverage: '模拟数据;未读取真实任务' },
|
|
275
|
+
], tasks: [
|
|
276
|
+
sample('demo-question', 'claude', '页面导航方案需要你选择', '示例网站', 'attention', '模拟 AskUserQuestion 待回答', 45),
|
|
277
|
+
sample('demo-ended', 'codex', '登录交互调整', '示例应用', 'ended', '模拟 task_complete;只表示本轮结束', 150),
|
|
278
|
+
sample('demo-active', 'codex', '检查状态来源', '示例工具', 'active', '模拟 task_started + 近期活动', 10),
|
|
279
|
+
sample('demo-old', 'claude', '没有新的活动证据', '示例服务', 'unknown', '事件过旧;不能据此判断卡住', 900),
|
|
280
|
+
sample('demo-interrupted', 'codex', '构建被中断', '示例组件', 'interrupted', '模拟 turn_aborted', 90),
|
|
281
|
+
] };
|
|
282
|
+
}
|