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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
3
|
+
const text = (value) => typeof value === 'string' ? value : '';
|
|
4
|
+
const cleanLabel = (value, fallback) => text(value).replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || fallback;
|
|
5
|
+
const time = (value, now) => {
|
|
6
|
+
const at = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
7
|
+
return Number.isFinite(at) && at <= now + 60_000 ? at : 0;
|
|
8
|
+
};
|
|
9
|
+
function finish(task) {
|
|
10
|
+
task.reviewKey = `${task.provider}:${task.id}:${task.turnId}:${task.status}:${task.updatedAt}`;
|
|
11
|
+
return task;
|
|
12
|
+
}
|
|
13
|
+
function base(id, provider, now) {
|
|
14
|
+
return { id, provider, title: `${provider === 'codex' ? 'Codex' : 'Claude'} · ${id.slice(-8)}`, project: '未知项目', status: 'unknown', evidence: '缺少明确生命周期事件', updatedAt: 0, observedAt: now, turnId: '', reviewKey: '' };
|
|
15
|
+
}
|
|
16
|
+
// A terminal record is authoritative; command exits/retry diagnostics are not.
|
|
17
|
+
// The collector copies only this boolean, never the raw error message/details.
|
|
18
|
+
export function codexTerminalFailure(payload) {
|
|
19
|
+
const error = object(payload.error);
|
|
20
|
+
return payload.terminalFailure === true || Object.keys(error).length > 0 || typeof payload.error === 'string' && payload.error.trim().length > 0;
|
|
21
|
+
}
|
|
22
|
+
export function projectCodex(records, filename, now = Date.now(), previous) {
|
|
23
|
+
const metadata = object(records.find(r => object(r).type === 'session_meta'));
|
|
24
|
+
const meta = object(metadata.payload);
|
|
25
|
+
// Fail closed: without the header we cannot distinguish child agents from user threads.
|
|
26
|
+
if (!Object.keys(meta).length || meta.parent_thread_id || typeof meta.source === 'object' ||
|
|
27
|
+
(meta.thread_source && !['user', 'agent_created_thread', 'automation'].includes(meta.thread_source)))
|
|
28
|
+
return null;
|
|
29
|
+
const id = text(meta.id) || text(meta.session_id) || basename(filename, '.jsonl');
|
|
30
|
+
const task = previous?.id === id ? { ...previous, observedAt: now } : base(id, 'codex', now);
|
|
31
|
+
task.applicationId = meta.originator === 'Codex Desktop' ? 'codex-desktop' : 'codex-unidentified';
|
|
32
|
+
task.project = cleanLabel(basename(text(meta.cwd)), '未知项目');
|
|
33
|
+
const pendingInputs = new Set(previous?.pendingInputIds ?? []);
|
|
34
|
+
let sawLifecycle = !!previous && previous.status !== 'unknown';
|
|
35
|
+
for (const value of records) {
|
|
36
|
+
const r = object(value), p = object(r.payload), at = time(r.timestamp, now);
|
|
37
|
+
if (!at)
|
|
38
|
+
continue;
|
|
39
|
+
const type = text(p.type);
|
|
40
|
+
if (r.type === 'event_msg' && type === 'task_started') {
|
|
41
|
+
if (at < task.updatedAt && text(p.turn_id) !== task.turnId)
|
|
42
|
+
continue;
|
|
43
|
+
pendingInputs.clear();
|
|
44
|
+
task.status = 'active';
|
|
45
|
+
task.turnId = text(p.turn_id) || String(at);
|
|
46
|
+
task.evidence = 'task_started';
|
|
47
|
+
sawLifecycle = true;
|
|
48
|
+
}
|
|
49
|
+
else if (r.type === 'event_msg' && (type === 'task_complete' || type === 'turn_aborted')) {
|
|
50
|
+
if (p.turn_id && task.turnId && p.turn_id !== task.turnId)
|
|
51
|
+
continue;
|
|
52
|
+
task.turnId ||= text(p.turn_id) || String(at);
|
|
53
|
+
pendingInputs.clear();
|
|
54
|
+
task.status = type === 'task_complete' ? codexTerminalFailure(p) ? 'failed' : 'ended' : 'interrupted';
|
|
55
|
+
task.evidence = task.status === 'failed' ? 'task_complete with explicit error' : type;
|
|
56
|
+
sawLifecycle = true;
|
|
57
|
+
}
|
|
58
|
+
if (r.type === 'event_msg' && ['input_requested', 'input_resolved'].includes(type) && at >= task.updatedAt && ['active', 'attention'].includes(task.status)) {
|
|
59
|
+
if (type === 'input_requested' && pendingInputs.size < 8)
|
|
60
|
+
pendingInputs.add(text(p.call_id));
|
|
61
|
+
else if (type === 'input_resolved')
|
|
62
|
+
pendingInputs.delete(text(p.call_id));
|
|
63
|
+
task.status = pendingInputs.size ? 'attention' : 'active';
|
|
64
|
+
task.evidence = pendingInputs.size ? 'explicit request_user_input pending' : 'input reply observed';
|
|
65
|
+
task.updatedAt = at;
|
|
66
|
+
}
|
|
67
|
+
if (sawLifecycle && task.status === 'active')
|
|
68
|
+
task.updatedAt = Math.max(task.updatedAt, at);
|
|
69
|
+
else if (r.type === 'event_msg' && ['task_complete', 'turn_aborted'].includes(type))
|
|
70
|
+
task.updatedAt = Math.max(task.updatedAt, at);
|
|
71
|
+
}
|
|
72
|
+
task.pendingInputIds = [...pendingInputs];
|
|
73
|
+
return finish(task);
|
|
74
|
+
}
|
|
75
|
+
export function projectClaude(records, filename, now = Date.now(), previous) {
|
|
76
|
+
const first = records.map(object).find(r => r.sessionId || r.session_id);
|
|
77
|
+
if (first?.isSidechain)
|
|
78
|
+
return previous ?? null;
|
|
79
|
+
if (!first && !previous)
|
|
80
|
+
return null;
|
|
81
|
+
const task = previous ? { ...previous, observedAt: now } : base(text(first.sessionId) || text(first.session_id) || basename(filename, '.jsonl'), 'claude', now);
|
|
82
|
+
task.applicationId = 'claude-code';
|
|
83
|
+
const questions = new Set(previous?.pendingQuestions ?? []);
|
|
84
|
+
let lifecycle = !!previous;
|
|
85
|
+
for (const value of records) {
|
|
86
|
+
const r = object(value);
|
|
87
|
+
if (r.isSidechain)
|
|
88
|
+
continue;
|
|
89
|
+
if (r.cwd)
|
|
90
|
+
task.project = cleanLabel(basename(text(r.cwd)), task.project);
|
|
91
|
+
if (r.type === 'ai-title' && text(r.aiTitle).trim()) {
|
|
92
|
+
task.title = cleanLabel(r.aiTitle, task.title);
|
|
93
|
+
task.titleOrigin = 'native';
|
|
94
|
+
}
|
|
95
|
+
const at = time(r.timestamp, now);
|
|
96
|
+
if (!at)
|
|
97
|
+
continue;
|
|
98
|
+
const m = object(r.message);
|
|
99
|
+
const content = Array.isArray(m.content) ? m.content.map(object) : [];
|
|
100
|
+
if (r.type === 'user') {
|
|
101
|
+
const results = content.filter(c => c.type === 'tool_result');
|
|
102
|
+
if (results.length) {
|
|
103
|
+
for (const c of results)
|
|
104
|
+
questions.delete(text(c.tool_use_id));
|
|
105
|
+
if (task.status === 'attention' && !questions.size) {
|
|
106
|
+
task.status = 'active';
|
|
107
|
+
task.evidence = '问题已收到对应 tool_result';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
else if (!r.isMeta && (typeof m.content === 'string' || content.some(c => c.type === 'text'))) {
|
|
111
|
+
// Claude may never append ai-title (not merely append it late). Retain only
|
|
112
|
+
// a bounded first ordinary user line, never commands/attachments or bodies.
|
|
113
|
+
if (!task.titleOrigin) {
|
|
114
|
+
const prompt = typeof m.content === 'string' ? m.content : text(content.find(c => c.type === 'text')?.text);
|
|
115
|
+
const line = prompt.trim().split(/\r?\n/, 1)[0] ?? '';
|
|
116
|
+
if (line && !/^[</]/.test(line)) {
|
|
117
|
+
task.title = cleanLabel(line, task.title);
|
|
118
|
+
task.titleOrigin = 'prompt';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
task.turnId = text(r.uuid) || text(r.promptId) || String(at);
|
|
122
|
+
task.status = 'active';
|
|
123
|
+
task.evidence = 'user message';
|
|
124
|
+
questions.clear();
|
|
125
|
+
lifecycle = true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (r.type === 'assistant') {
|
|
129
|
+
lifecycle = true;
|
|
130
|
+
task.turnId ||= text(r.parentUuid) || String(at);
|
|
131
|
+
for (const c of content) {
|
|
132
|
+
if (c.type === 'tool_use' && ['AskUserQuestion', 'ExitPlanMode'].includes(text(c.name)))
|
|
133
|
+
questions.add(text(c.id));
|
|
134
|
+
}
|
|
135
|
+
if (questions.size) {
|
|
136
|
+
task.status = 'attention';
|
|
137
|
+
task.evidence = '等待交互工具的对应结果';
|
|
138
|
+
}
|
|
139
|
+
else if (m.stop_reason === 'end_turn') {
|
|
140
|
+
task.status = 'ended';
|
|
141
|
+
task.evidence = 'assistant stop_reason=end_turn';
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
task.status = 'active';
|
|
145
|
+
task.evidence = 'assistant / tool_use';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (r.type === 'system' && r.subtype === 'turn_duration' && !questions.size) {
|
|
149
|
+
task.status = 'ended';
|
|
150
|
+
task.evidence = 'system turn_duration';
|
|
151
|
+
lifecycle = true;
|
|
152
|
+
}
|
|
153
|
+
if (lifecycle && (['user', 'assistant'].includes(r.type) || (r.type === 'system' && r.subtype === 'turn_duration')))
|
|
154
|
+
task.updatedAt = Math.max(task.updatedAt, at);
|
|
155
|
+
}
|
|
156
|
+
task.pendingQuestions = [...questions];
|
|
157
|
+
return finish(task);
|
|
158
|
+
}
|
|
159
|
+
export function ageTask(task, now = Date.now()) {
|
|
160
|
+
const age = now - task.updatedAt;
|
|
161
|
+
if ((task.status === 'active' && age > 5 * 60_000) || (task.status === 'attention' && age > 30 * 60_000)) {
|
|
162
|
+
return { ...task, status: 'unknown', evidence: `上次事件:${task.evidence};当前状态待确认` };
|
|
163
|
+
}
|
|
164
|
+
return task;
|
|
165
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
4
|
+
const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
export function parseNativeUnread(value) {
|
|
6
|
+
const state = record(record(value)?.['electron-thread-read-state-v1']);
|
|
7
|
+
if (state?.version !== 1)
|
|
8
|
+
return null;
|
|
9
|
+
const identities = record(state.unreadByIdentity);
|
|
10
|
+
if (!identities || Object.keys(identities).length !== 1)
|
|
11
|
+
return null;
|
|
12
|
+
const [identity] = Object.keys(identities);
|
|
13
|
+
if (!/^[a-f\d]{64}$/.test(identity))
|
|
14
|
+
return null;
|
|
15
|
+
const hosts = record(identities[identity]);
|
|
16
|
+
if (!hosts || Object.keys(hosts).length !== 1)
|
|
17
|
+
return null;
|
|
18
|
+
const [host] = Object.keys(hosts);
|
|
19
|
+
if (!/^local:[a-f\d]{64}$/.test(host))
|
|
20
|
+
return null;
|
|
21
|
+
const ids = hosts[host];
|
|
22
|
+
if (!Array.isArray(ids) || ids.length > 10000 || ids.some(id => typeof id !== 'string' || !/^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(id)))
|
|
23
|
+
return null;
|
|
24
|
+
return new Set(ids);
|
|
25
|
+
}
|
|
26
|
+
export async function readNativeUnread(file) {
|
|
27
|
+
try {
|
|
28
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
29
|
+
try {
|
|
30
|
+
const info = await handle.stat();
|
|
31
|
+
if (!info.isFile() || info.size > MAX_BYTES)
|
|
32
|
+
return null;
|
|
33
|
+
const buffer = Buffer.alloc(info.size + 1), result = await handle.read(buffer, 0, buffer.length, 0);
|
|
34
|
+
if (result.bytesRead !== info.size)
|
|
35
|
+
return null;
|
|
36
|
+
return parseNativeUnread(JSON.parse(buffer.subarray(0, result.bytesRead).toString('utf8')));
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await handle.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomBytes, randomUUID, createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const secret = () => randomBytes(32).toString('base64url');
|
|
3
|
+
export const hash = (s) => createHash('sha256').update(s).digest('hex');
|
|
4
|
+
const equal = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
5
|
+
export class ProtocolError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
constructor(status, code) { super(code); this.status = status; this.code = code; }
|
|
9
|
+
}
|
|
10
|
+
export class Pairing {
|
|
11
|
+
invite;
|
|
12
|
+
requests = new Map();
|
|
13
|
+
devices = new Map();
|
|
14
|
+
revokeListeners = new Set();
|
|
15
|
+
now;
|
|
16
|
+
onRevoke;
|
|
17
|
+
constructor(now = Date.now, onRevoke = () => { }) { this.now = now; this.onRevoke = onRevoke; }
|
|
18
|
+
createInvite() {
|
|
19
|
+
this.requests.clear();
|
|
20
|
+
return this.invite = { secret: secret(), expiresAt: this.now() + 120_000, consumed: false };
|
|
21
|
+
}
|
|
22
|
+
request(invitation, challenge, name) {
|
|
23
|
+
const i = this.invite;
|
|
24
|
+
if (!i || i.consumed || i.expiresAt <= this.now() || !equal(hash(invitation), hash(i.secret)))
|
|
25
|
+
throw new ProtocolError(410, 'invite_expired');
|
|
26
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(challenge) || !name.trim() || name.length > 60 || /[\x00-\x1f]/.test(name))
|
|
27
|
+
throw new ProtocolError(400, 'invalid_request');
|
|
28
|
+
const challengeHash = hash(challenge);
|
|
29
|
+
const previous = [...this.requests.values()].find(r => r.challengeHash === challengeHash);
|
|
30
|
+
if (previous)
|
|
31
|
+
return this.publicRequest(previous);
|
|
32
|
+
if ([...this.requests.values()].filter(r => r.state === 'pending').length >= 8)
|
|
33
|
+
throw new ProtocolError(429, 'pairing_busy');
|
|
34
|
+
const id = randomUUID(), code = hash(`${id}:${challenge}`).slice(0, 8).toUpperCase();
|
|
35
|
+
const r = { id, name, challengeHash, code, expiresAt: i.expiresAt, state: 'pending' };
|
|
36
|
+
this.requests.set(id, r);
|
|
37
|
+
return this.publicRequest(r);
|
|
38
|
+
}
|
|
39
|
+
publicRequest(r) { return { requestId: r.id, name: r.name, code: r.code, expiresAt: r.expiresAt, state: r.state }; }
|
|
40
|
+
approve(id, code) {
|
|
41
|
+
const r = this.requests.get(id);
|
|
42
|
+
if (!r || r.code !== code || r.expiresAt <= this.now() || r.state !== 'pending' || this.invite?.consumed)
|
|
43
|
+
throw new ProtocolError(409, 'request_changed');
|
|
44
|
+
r.state = 'approved';
|
|
45
|
+
for (const other of this.requests.values())
|
|
46
|
+
if (other.id !== id)
|
|
47
|
+
other.state = 'rejected';
|
|
48
|
+
}
|
|
49
|
+
claim(id, challenge) {
|
|
50
|
+
const r = this.requests.get(id);
|
|
51
|
+
if (!r || !equal(hash(challenge), r.challengeHash))
|
|
52
|
+
throw new ProtocolError(403, 'challenge_rejected');
|
|
53
|
+
if (r.expiresAt <= this.now() || this.invite?.consumed || ['claimed', 'rejected'].includes(r.state))
|
|
54
|
+
throw new ProtocolError(410, 'pairing_expired');
|
|
55
|
+
if (r.state === 'pending')
|
|
56
|
+
return { state: 'pending' };
|
|
57
|
+
if (this.devices.size >= 64)
|
|
58
|
+
throw new ProtocolError(429, 'device_limit');
|
|
59
|
+
const token = secret(), deviceId = randomUUID();
|
|
60
|
+
this.devices.set(deviceId, { id: deviceId, name: r.name, tokenHash: hash(token), createdAt: this.now(), revoked: false });
|
|
61
|
+
r.state = 'claimed';
|
|
62
|
+
this.invite.consumed = true;
|
|
63
|
+
return { state: 'paired', deviceId, token };
|
|
64
|
+
}
|
|
65
|
+
authenticate(token) {
|
|
66
|
+
const d = [...this.devices.values()].find(d => !d.revoked && equal(d.tokenHash, hash(token)));
|
|
67
|
+
if (!d)
|
|
68
|
+
throw new ProtocolError(401, 'repair_required');
|
|
69
|
+
return d;
|
|
70
|
+
}
|
|
71
|
+
subscribeRevoke(listener) { this.revokeListeners.add(listener); return () => this.revokeListeners.delete(listener); }
|
|
72
|
+
revoke(id) { const d = this.devices.get(id); if (!d)
|
|
73
|
+
throw new ProtocolError(404, 'unknown_device'); d.revoked = true; this.onRevoke(id); for (const listener of this.revokeListeners)
|
|
74
|
+
listener(id); }
|
|
75
|
+
ack(d, instanceId, revision) {
|
|
76
|
+
if (d.revoked)
|
|
77
|
+
throw new ProtocolError(401, 'repair_required');
|
|
78
|
+
if (!d.issued || instanceId !== d.issued.instanceId || revision !== d.issued.revision || (d.ack?.instanceId === instanceId && d.ack.revision > revision))
|
|
79
|
+
throw new ProtocolError(409, 'snapshot_mismatch');
|
|
80
|
+
d.ack = { instanceId, revision, receivedAt: this.now() };
|
|
81
|
+
}
|
|
82
|
+
status() { return { requests: [...this.requests.values()].filter(r => r.expiresAt > this.now()).map(r => this.publicRequest(r)), devices: [...this.devices.values()].map(d => ({ id: d.id, name: d.name, revoked: d.revoked, createdAt: d.createdAt, ack: d.ack, online: !!d.ack && this.now() - d.ack.receivedAt < 15000 })) }; }
|
|
83
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
export class Snapshots {
|
|
3
|
+
instanceId = randomUUID();
|
|
4
|
+
revision = 0;
|
|
5
|
+
signature = '';
|
|
6
|
+
inFlight = null;
|
|
7
|
+
last;
|
|
8
|
+
collect;
|
|
9
|
+
constructor(collect) { this.collect = collect; }
|
|
10
|
+
async get() {
|
|
11
|
+
if (this.last && Date.now() - this.last.generatedAt < 1500)
|
|
12
|
+
return this.last;
|
|
13
|
+
return this.inFlight ??= this.build().finally(() => { this.inFlight = null; });
|
|
14
|
+
}
|
|
15
|
+
async build() {
|
|
16
|
+
const raw = await this.collect();
|
|
17
|
+
const rows = raw.tasks.filter(t => t.provider !== 'codex' || t.applicationId === 'codex-desktop' || raw.mode === 'demo').map(t => ({
|
|
18
|
+
key: createHash('sha256').update(`${t.provider}:${t.id}`).digest('hex').slice(0, 32),
|
|
19
|
+
applicationId: t.applicationId ?? (t.provider === 'codex' ? 'codex-desktop' : 'claude-code'), title: t.title,
|
|
20
|
+
lifecycle: { active: 'running', attention: 'running', ended: 'completed', interrupted: 'cancelled', failed: 'failed', unknown: 'unknown' }[t.status],
|
|
21
|
+
...(t.status === 'attention' ? { runningPhase: 'awaiting_feedback' } : t.status === 'active' ? { runningPhase: 'executing' } : {}),
|
|
22
|
+
...(t.status === 'ended' ? { readState: t.provider === 'codex' && t.applicationId === 'codex-desktop' && t.nativeUnread && !t.sourceStale ? 'unread' : 'unconfirmed' } : {}),
|
|
23
|
+
activityAt: t.updatedAt || null, freshness: t.sourceStale ? 'stale' : 'fresh',
|
|
24
|
+
}));
|
|
25
|
+
const sources = raw.sources.map(s => ({ applicationId: s.provider === 'codex' ? 'codex-desktop' : 'claude-code',
|
|
26
|
+
displayName: s.provider === 'codex' ? 'Codex Desktop' : 'Claude Code',
|
|
27
|
+
health: s.errors ? 'unavailable' : 'partial',
|
|
28
|
+
capabilities: { lifecycle: 'partial', approvalWaiting: 'unsupported', feedbackWaiting: s.provider === 'claude' ? 'unverified' : 'partial', terminalFailure: s.provider === 'codex' ? 'partial' : 'unsupported', desktopViewing: s.provider === 'codex' && s.nativeUnreadAvailable ? 'partial' : 'unsupported' },
|
|
29
|
+
evidence: raw.mode === 'demo' ? 'synthetic' : s.provider === 'codex' ? 'local-log' : 'local-log-unverified',
|
|
30
|
+
coverage: `最近 ${raw.hours} 小时 / 最多 64 份日志;${s.coverage};${s.provider === 'codex' && s.nativeUnreadAvailable ? '原生线程未读标记部分支持,缺失标记不证明已读' : '查看状态未接入'};${s.files ?? 0} 份文件${s.errors ? ';采集不可用,保留旧数据' : ''}${s.capped ? ';已截断' : ''}${s.incompleteLines ? ';存在不完整行' : ''}`,
|
|
31
|
+
excludedUnidentified: s.provider === 'codex' ? raw.tasks.filter(t => t.applicationId === 'codex-unidentified').length : 0,
|
|
32
|
+
}));
|
|
33
|
+
const signature = createHash('sha256').update(JSON.stringify({ rows, sources, mode: raw.mode })).digest('hex');
|
|
34
|
+
if (signature !== this.signature) {
|
|
35
|
+
this.signature = signature;
|
|
36
|
+
this.revision++;
|
|
37
|
+
}
|
|
38
|
+
return this.last = { schemaVersion: 1, instanceId: this.instanceId, revision: this.revision, generatedAt: Date.now(), mode: raw.mode, sources, rows };
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { userRuntimeRoot } from "./infrastructure/runtime-root.js";
|
|
6
|
+
import { qrExecutable } from "./infrastructure/qr.js";
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const metadata = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
|
+
const command = Object.keys(metadata.bin ?? {})[0] ?? 'local-agent-dashboard';
|
|
10
|
+
const value = (key) => { const i = args.indexOf(key); if (i < 0)
|
|
11
|
+
return undefined; if (!args[i + 1] || args[i + 1].startsWith('--'))
|
|
12
|
+
throw Error(`${key} 需要参数`); return args[i + 1]; };
|
|
13
|
+
async function run() {
|
|
14
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
15
|
+
console.log(`Local Agent Dashboard ${metadata.version}
|
|
16
|
+
在 Android 手机上查看这台 Mac 的 Codex 和 Claude Code 任务状态。
|
|
17
|
+
|
|
18
|
+
启动:${command}
|
|
19
|
+
启动后会自动打开电脑上的任务总览。要连接手机,请点击页面中的「连接手机」。
|
|
20
|
+
|
|
21
|
+
常用命令:
|
|
22
|
+
${command} --doctor 检查运行环境
|
|
23
|
+
${command} --demo 使用示例任务体验界面
|
|
24
|
+
${command} --no-open 启动时不自动打开浏览器
|
|
25
|
+
${command} --version 查看版本
|
|
26
|
+
|
|
27
|
+
可选参数:
|
|
28
|
+
--state-dir <目录> 指定数据目录
|
|
29
|
+
--port <端口> 电脑管理页面端口(默认自动分配)
|
|
30
|
+
--lan-port <端口> 手机连接端口(默认 63982)
|
|
31
|
+
--hours <1..168> 查看最近多少小时的任务(默认 24)
|
|
32
|
+
--lan <IPv4,IPv4> 启动时启用指定的本机网络地址
|
|
33
|
+
|
|
34
|
+
数据目录:${userRuntimeRoot()}
|
|
35
|
+
需要 macOS 13+、Node.js 22.18+ 和 OpenSSL 3。
|
|
36
|
+
安装 OpenSSL:brew install openssl@3
|
|
37
|
+
手机需安装 Android App;扫码后,还要在电脑上确认连接。`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (args.includes('--version')) {
|
|
41
|
+
console.log(metadata.version);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const allowed = new Set(['--demo', '--no-open', '--doctor', '--state-dir', '--port', '--lan-port', '--hours', '--lan', '--url-file']);
|
|
45
|
+
for (let i = 0; i < args.length; i++) {
|
|
46
|
+
if (!allowed.has(args[i]))
|
|
47
|
+
throw Error(`未知参数:${args[i]}`);
|
|
48
|
+
if (['--state-dir', '--port', '--lan-port', '--hours', '--lan', '--url-file'].includes(args[i])) {
|
|
49
|
+
value(args[i]);
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (process.platform !== 'darwin')
|
|
54
|
+
throw Error('当前电脑服务包仅支持 macOS;Android APK 单独下载。');
|
|
55
|
+
const openssl = process.env.AGENT_SIGNAL_OPENSSL ?? ['/opt/homebrew/opt/openssl@3/bin/openssl', '/usr/local/opt/openssl@3/bin/openssl'].find(p => existsSync(p)) ?? 'openssl';
|
|
56
|
+
let version;
|
|
57
|
+
try {
|
|
58
|
+
version = execFileSync(openssl, ['version'], { encoding: 'utf8', timeout: 5000 }).trim();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
throw Error('找不到 OpenSSL 3,请先安装:brew install openssl@3;也可设置 AGENT_SIGNAL_OPENSSL。');
|
|
62
|
+
}
|
|
63
|
+
if (!/^OpenSSL 3\./.test(version))
|
|
64
|
+
throw Error('需要 OpenSSL 3;请安装 brew install openssl@3。');
|
|
65
|
+
process.env.AGENT_SIGNAL_OPENSSL = openssl;
|
|
66
|
+
if (!existsSync(qrExecutable()))
|
|
67
|
+
throw Error('二维码组件缺失,请重新下载完整服务端包。');
|
|
68
|
+
const runtime = resolve(value('--state-dir') ?? userRuntimeRoot());
|
|
69
|
+
if (args.includes('--doctor')) {
|
|
70
|
+
console.log(`依赖检查通过:Node ${process.version};${version}\n数据目录:${runtime}\n二维码组件:可用`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const { main } = await import("./composition/main.js");
|
|
74
|
+
await main({ runtimeRoot: runtime, openBrowser: true });
|
|
75
|
+
}
|
|
76
|
+
run().catch(e => { console.error(`Local Agent Dashboard:${e.message}`); process.exitCode = 1; });
|
package/src/collector.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { networkInterfaces, hostname } from 'node:os';
|
|
2
|
+
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { X509Certificate } from 'node:crypto';
|
|
6
|
+
import { Collector, demoSnapshot } from "../adapters/agents/log-collector.js";
|
|
7
|
+
import { Snapshots } from "../application/snapshots.js";
|
|
8
|
+
import { Pairing } from "../application/pairing.js";
|
|
9
|
+
import { certificates } from "../infrastructure/certificates.js";
|
|
10
|
+
import { pairingLink } from "../infrastructure/pairing-link.js";
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { qr } from "../infrastructure/qr.js";
|
|
13
|
+
import { createLan, tlsPolicy } from "../transport/lan/server.js";
|
|
14
|
+
import { createDashboard } from "../server.js";
|
|
15
|
+
export function interfaces() { return Object.entries(networkInterfaces()).flatMap(([name, addresses]) => (addresses ?? []).filter(a => a.family === 'IPv4' && !a.internal).map(a => ({ name, address: a.address }))); }
|
|
16
|
+
export async function main(options = {}) {
|
|
17
|
+
const args = process.argv.slice(2), value = (key) => { const i = args.indexOf(key); return i < 0 ? undefined : args[i + 1]; };
|
|
18
|
+
const port = Number(value('--port') ?? 0), lanPort = Number(value('--lan-port') ?? 63982), hours = Number(value('--hours') ?? 24);
|
|
19
|
+
if (![port, lanPort].every(p => Number.isInteger(p) && p >= 0 && p <= 65535) || !Number.isFinite(hours) || hours < 1 || hours > 168)
|
|
20
|
+
throw new Error('端口或 hours 参数无效');
|
|
21
|
+
const initial = value('--lan')?.split(',') ?? [];
|
|
22
|
+
const runtime = options.runtimeRoot ?? (value('--state-dir') ? resolve(value('--state-dir')) : fileURLToPath(new URL('../../.runtime/', import.meta.url)));
|
|
23
|
+
let interfacesConfigured = args.includes('--lan');
|
|
24
|
+
await mkdir(runtime, { recursive: true, mode: 0o700 });
|
|
25
|
+
const lock = join(runtime, 'host.lock');
|
|
26
|
+
try {
|
|
27
|
+
await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
const pid = Number(await readFile(lock, 'utf8'));
|
|
31
|
+
if (!Number.isInteger(pid) || pid < 1)
|
|
32
|
+
throw new Error('未知 writer 锁;请检查 .runtime/host.lock');
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
throw new Error('已有采集服务 writer 正在运行');
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e.code !== 'ESRCH')
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
await unlink(lock);
|
|
42
|
+
await writeFile(lock, String(process.pid), { flag: 'wx', mode: 0o600 });
|
|
43
|
+
}
|
|
44
|
+
const collector = new Collector({ hours });
|
|
45
|
+
const snapshots = new Snapshots(() => args.includes('--demo') ? demoSnapshot() : collector.snapshot());
|
|
46
|
+
const pairing = new Pairing();
|
|
47
|
+
const lanServers = new Map();
|
|
48
|
+
let localServer;
|
|
49
|
+
let identity;
|
|
50
|
+
let addresses = [], endpoints = [], servicePort = lanPort, changing = false, stopping = false;
|
|
51
|
+
let certificateWarning = '';
|
|
52
|
+
const close = (s) => { s.closeAllConnections(); s.close(); };
|
|
53
|
+
async function chooseInterfaces(next) {
|
|
54
|
+
const available = interfaces();
|
|
55
|
+
if (changing || stopping || next.length > 8 || new Set(next).size !== next.length || next.some(a => typeof a !== 'string' || !available.some(i => i.address === a)))
|
|
56
|
+
throw new Error('请选择本机现有的明确 IPv4 地址');
|
|
57
|
+
changing = true;
|
|
58
|
+
const additions = new Map();
|
|
59
|
+
try {
|
|
60
|
+
if (next.length) {
|
|
61
|
+
const renewed = await certificates(join(runtime, 'identity'), next);
|
|
62
|
+
if (identity && renewed.computerId !== identity.computerId)
|
|
63
|
+
throw new Error('电脑根已改变,需要明确重新配对');
|
|
64
|
+
for (const address of next)
|
|
65
|
+
if (!lanServers.has(address)) {
|
|
66
|
+
const server = createLan(renewed, pairing, snapshots, () => [`${address}:${servicePort}`]);
|
|
67
|
+
additions.set(address, server);
|
|
68
|
+
await new Promise((done, reject) => { server.once('error', reject); server.listen(servicePort, address, () => { servicePort = server.address().port; done(); }); });
|
|
69
|
+
}
|
|
70
|
+
// A failure to bind any new interface leaves every prior listener/context intact.
|
|
71
|
+
for (const [address, server] of lanServers)
|
|
72
|
+
if (next.includes(address))
|
|
73
|
+
server.setSecureContext({ key: renewed.key, cert: renewed.cert, ...tlsPolicy });
|
|
74
|
+
identity = renewed;
|
|
75
|
+
certificateWarning = '';
|
|
76
|
+
}
|
|
77
|
+
for (const [address, server] of lanServers)
|
|
78
|
+
if (!next.includes(address)) {
|
|
79
|
+
close(server);
|
|
80
|
+
lanServers.delete(address);
|
|
81
|
+
}
|
|
82
|
+
for (const [address, server] of additions)
|
|
83
|
+
lanServers.set(address, server);
|
|
84
|
+
addresses = [...next];
|
|
85
|
+
endpoints = next.map(a => `https://${a}:${servicePort}`);
|
|
86
|
+
return { endpoints };
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
for (const s of additions.values())
|
|
90
|
+
close(s);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
changing = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const renewal = setInterval(async () => {
|
|
98
|
+
if (changing || stopping || !identity || !addresses.length || Date.parse(new X509Certificate(identity.cert).validTo) - Date.now() > 2 * 86400000)
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
await chooseInterfaces(addresses);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
certificateWarning = '服务证书续签失败;不会关闭验证。请检查电脑身份文件及 OpenSSL。';
|
|
105
|
+
}
|
|
106
|
+
}, 3600000);
|
|
107
|
+
renewal.unref();
|
|
108
|
+
try {
|
|
109
|
+
await chooseInterfaces(initial);
|
|
110
|
+
const local = createDashboard(() => snapshots.get(), async (action, input) => {
|
|
111
|
+
if (action === 'status')
|
|
112
|
+
return { ...pairing.status(), available: interfaces(), selectedAddresses: addresses, interfacesConfigured, endpoints, computerId: identity?.computerId, name: hostname(), restartRequiresApproval: true, certificateWarning };
|
|
113
|
+
if (action === 'interfaces') {
|
|
114
|
+
if (!Array.isArray(input.addresses))
|
|
115
|
+
throw new Error('invalid addresses');
|
|
116
|
+
const result = await chooseInterfaces(input.addresses);
|
|
117
|
+
interfacesConfigured = true;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
if (action === 'invite') {
|
|
121
|
+
if (!identity || !endpoints.length || changing)
|
|
122
|
+
throw new Error('LAN disabled or changing');
|
|
123
|
+
const i = pairing.createInvite();
|
|
124
|
+
const descriptor = JSON.stringify({ protocol: 1, computerId: identity.computerId, name: hostname().slice(0, 60), endpoints, ca: identity.caDer, fingerprint: identity.fingerprint, invitation: i.secret, expiresAt: i.expiresAt });
|
|
125
|
+
const link = pairingLink(descriptor);
|
|
126
|
+
return { expiresAt: i.expiresAt, descriptor, link, image: `data:image/png;base64,${(await qr(link)).toString('base64')}` };
|
|
127
|
+
}
|
|
128
|
+
if (action === 'approve') {
|
|
129
|
+
pairing.approve(String(input.requestId), String(input.code));
|
|
130
|
+
return { approved: true };
|
|
131
|
+
}
|
|
132
|
+
if (action === 'revoke') {
|
|
133
|
+
pairing.revoke(String(input.deviceId));
|
|
134
|
+
return { revoked: true };
|
|
135
|
+
}
|
|
136
|
+
throw new Error('unknown action');
|
|
137
|
+
});
|
|
138
|
+
localServer = local.server;
|
|
139
|
+
await new Promise((done, reject) => { local.server.once('error', reject); local.server.listen(port, '127.0.0.1', done); });
|
|
140
|
+
const url = `http://127.0.0.1:${local.server.address().port}/`;
|
|
141
|
+
if (value('--url-file'))
|
|
142
|
+
await writeFile(value('--url-file'), `${url}\n`, { flag: 'wx', mode: 0o600 });
|
|
143
|
+
if ((options.openBrowser ?? !options.runtimeRoot) && !args.includes('--no-open') && process.platform === 'darwin') {
|
|
144
|
+
const browser = spawn('/usr/bin/open', [url + '#tasks'], { stdio: 'ignore' });
|
|
145
|
+
browser.on('error', () => { });
|
|
146
|
+
browser.unref();
|
|
147
|
+
}
|
|
148
|
+
console.log(`Local Agent Dashboard(${args.includes('--demo') ? '示例任务' : '读取本机任务状态'})\n电脑管理页面:${url}\n${endpoints.length ? `手机连接地址:${endpoints.join(', ')}\n服务重启后,需要在电脑上重新确认手机连接。` : '尚未开启手机连接。请在电脑页面点击「连接手机」,选择网络地址后启用。'}`);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
clearInterval(renewal);
|
|
152
|
+
for (const s of lanServers.values())
|
|
153
|
+
close(s);
|
|
154
|
+
if (localServer)
|
|
155
|
+
close(localServer);
|
|
156
|
+
await unlink(lock);
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
const stop = async () => { if (stopping)
|
|
160
|
+
return; stopping = true; clearInterval(renewal); for (const s of lanServers.values())
|
|
161
|
+
close(s); if (localServer)
|
|
162
|
+
close(localServer); await unlink(lock); };
|
|
163
|
+
process.once('SIGINT', stop);
|
|
164
|
+
process.once('SIGTERM', stop);
|
|
165
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The result version is an evidence binding, never a wall-clock approximation.
|
|
2
|
+
export function reduceSession(s, e) {
|
|
3
|
+
if (e.type === 'title')
|
|
4
|
+
return { ...s, title: e.title ?? s.title };
|
|
5
|
+
if (e.type === 'viewed')
|
|
6
|
+
return e.turn === s.turn && s.lifecycle === 'completed' && !!e.version && e.version === s.resultVersion ? { ...s, readState: 'read' } : s;
|
|
7
|
+
if (e.type === 'started')
|
|
8
|
+
return e.at < (s.activityAt ?? 0) ? s : { ...s, turn: e.turn, lifecycle: 'running', runningPhase: 'executing', activityAt: e.at, readState: undefined, resultVersion: undefined };
|
|
9
|
+
if (s.turn !== e.turn || e.at < (s.activityAt ?? 0))
|
|
10
|
+
return s;
|
|
11
|
+
if (e.type === 'activity' && s.lifecycle !== 'running')
|
|
12
|
+
return s;
|
|
13
|
+
return { ...s, activityAt: e.at, lifecycle: { completed: 'completed', failed: 'failed', cancelled: 'cancelled' }[e.type] ?? s.lifecycle,
|
|
14
|
+
runningPhase: e.type === 'waiting' ? e.phase : s.runningPhase,
|
|
15
|
+
...(e.type === 'completed' ? { readState: 'unconfirmed', resultVersion: e.version } : {}) };
|
|
16
|
+
}
|
|
17
|
+
export function queue(r) { return r.lifecycle === 'running' ? 'running' : r.lifecycle === 'failed' ? 'failed' : r.lifecycle === 'completed' && r.readState === 'unread' ? 'completed_unread' : null; }
|
|
18
|
+
export function select(rows, applications, filter = 'all') {
|
|
19
|
+
const selected = rows.filter(r => applications.includes(r.applicationId));
|
|
20
|
+
const counts = { running: 0, completed_unread: 0, failed: 0, awaiting_approval: 0, awaiting_feedback: 0, unconfirmed: 0 };
|
|
21
|
+
for (const r of selected) {
|
|
22
|
+
const q = queue(r);
|
|
23
|
+
if (q)
|
|
24
|
+
counts[q]++;
|
|
25
|
+
if (q === 'running' && r.runningPhase && r.runningPhase !== 'executing')
|
|
26
|
+
counts[r.runningPhase]++;
|
|
27
|
+
if (r.lifecycle === 'completed' && r.readState === 'unconfirmed')
|
|
28
|
+
counts.unconfirmed++;
|
|
29
|
+
}
|
|
30
|
+
return { counts, rows: selected.filter(r => filter === 'all' || queue(r) === filter || (r.lifecycle === 'running' && r.runningPhase === filter)).sort((a, b) => (b.activityAt ?? -1) - (a.activityAt ?? -1) || a.key.localeCompare(b.key)) };
|
|
31
|
+
}
|