local-agent-dashboard 0.0.0-stage → 0.3.3
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 +77 -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 +76 -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 +10 -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,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Local Agent Dashboard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把闲置的 Android 手机变成 AI 任务看板:不用一直盯着电脑,也能看到 Codex Desktop 和 Claude Code 是否还在运行、是否需要你处理,以及有没有报错。
|
|
4
|
+
|
|
5
|
+
电脑上运行这个 npm 包,手机上安装配套 App。手机和电脑需要连接同一个局域网,也可以在 App 中添加多台电脑。
|
|
6
|
+
|
|
7
|
+
## 电脑需要什么?
|
|
8
|
+
|
|
9
|
+
- macOS 13 或更新版本,支持 Apple 芯片和 Intel 芯片。
|
|
10
|
+
- [Node.js](https://nodejs.org/) 22.18 或更新版本。
|
|
11
|
+
- OpenSSL 3。如果已经安装 [Homebrew](https://brew.sh/),运行:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
brew install openssl@3
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
目前电脑端仅支持 macOS,暂不支持 Windows 和 Linux。
|
|
18
|
+
|
|
19
|
+
## 启动电脑端
|
|
20
|
+
|
|
21
|
+
在终端运行:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx local-agent-dashboard@0.3.3
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
第一次运行时,npx 会询问是否下载,输入 `y` 确认即可。启动后会自动打开浏览器,显示这台电脑最近的任务。
|
|
28
|
+
|
|
29
|
+
使用期间请保持这个终端窗口打开。要停止服务,在终端按 `Control + C`。
|
|
30
|
+
|
|
31
|
+
## 连接 Android 手机
|
|
32
|
+
|
|
33
|
+
1. 从 [GitHub 下载 0.3.3 的 Android 安装包](https://github.com/jiahim/local-agent-dashboard/releases/tag/v0.3.3),在手机上安装。请选择 `.apk` 文件;无需安装 Expo Go。
|
|
34
|
+
2. 让手机和电脑连接同一个 Wi-Fi,或处于可以互相访问的局域网。
|
|
35
|
+
3. 在电脑页面点击 **连接手机**,确认网络地址,再点击 **应用所选地址**。默认会选中所有可用地址,也可以用全选、反选来调整。
|
|
36
|
+
4. 点击 **生成配对邀请**。打开手机 App,在 **设置 → 电脑连接** 中扫码;也可以把配对链接发送到手机,点击链接打开 App。
|
|
37
|
+
5. 核对手机显示的电脑身份,并在电脑页面确认对应的连接申请。
|
|
38
|
+
|
|
39
|
+
连接成功后,手机就会显示这台电脑的任务。需要添加其他电脑时,在那些电脑上运行同一个命令,再从手机设置中添加。
|
|
40
|
+
|
|
41
|
+
手机端仅支持 Android arm64,系统要求 Android 8.0 或更新版本。目前 APK 使用测试签名,供试用。
|
|
42
|
+
|
|
43
|
+
## 常用命令
|
|
44
|
+
|
|
45
|
+
| 用途 | 命令 |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| 检查运行环境,排查启动问题 | `npx local-agent-dashboard@0.3.3 --doctor` |
|
|
48
|
+
| 用示例任务体验界面 | `npx local-agent-dashboard@0.3.3 --demo` |
|
|
49
|
+
| 启动时不自动打开浏览器 | `npx local-agent-dashboard@0.3.3 --no-open` |
|
|
50
|
+
| 查看全部参数 | `npx local-agent-dashboard@0.3.3 --help` |
|
|
51
|
+
|
|
52
|
+
如果下载的是 Release 中的 `.tgz` 文件,在文件所在目录运行:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
npx --package ./local-agent-dashboard-0.3.3.tgz local-agent-dashboard
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 数据保存在哪里?
|
|
59
|
+
|
|
60
|
+
新安装的数据保存在当前用户目录:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
~/Library/Application Support/Local Agent Dashboard
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
如果你已经使用过旧版,且没有创建过新目录,程序会继续使用原来的 `~/Library/Application Support/Agent Signal`,保留已有电脑身份,不会自动移动或删除文件。启动前运行 `--doctor`,可以查看实际使用的目录。
|
|
67
|
+
|
|
68
|
+
也可以用 `--state-dir <目录>` 指定已有数据目录。同一个目录不要同时运行两个服务。通过源码启动时使用的 `.runtime` 目录不会自动迁移。
|
|
69
|
+
|
|
70
|
+
**目前电脑服务重启后,需要在电脑上重新确认手机连接。** 电脑身份会保留,但已批准的手机连接尚未持久保存。
|
|
71
|
+
|
|
72
|
+
## 隐私与使用范围
|
|
73
|
+
|
|
74
|
+
程序只读取本机 Codex 和 Claude Code 的任务状态,不安装 Hook,也不修改它们的配置。电脑管理页面只允许本机访问,手机连接使用加密通信,不经过云端中转。
|
|
75
|
+
|
|
76
|
+
任务标题可能包含私人信息,请只连接你信任的设备,不要公开分享配对链接或数据目录。任务状态识别仍在完善,部分会话可能显示为状态待确认。
|
|
77
|
+
|
|
78
|
+
项目目前未提供开源许可证,按 `UNLICENSED` 管理。源码、问题反馈和版本下载见 [GitHub 仓库](https://github.com/jiahim/local-agent-dashboard)。
|
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.3",
|
|
4
|
+
"description": "在 Android 手机上查看 Mac 的 Codex 和 Claude Code 任务状态",
|
|
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=`Local Agent Dashboard · ${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>Local Agent Dashboard · 任务总览</title><link rel="stylesheet" href="/style.css"><body>
|
|
2
|
+
<header><div><small>LOCAL AGENT DASHBOARD</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>在 Local Agent Dashboard 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>使用 Local Agent Dashboard 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
|
+
}
|