@awak-app/simy-cli 0.7.25 → 0.7.26
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 +6 -0
- package/package.json +2 -2
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
- package/src/connection-health.js +253 -0
- package/src/connection-presentation.js +85 -0
- package/src/connection-recovery.js +102 -0
- package/src/console/app.js +60 -6
- package/src/console/commands.js +3 -0
- package/src/console/index.js +1 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +43 -8
- package/src/direct-executor.js +8 -2
- package/src/doctor.js +1 -0
- package/src/index.js +12 -2
- package/src/remote-connection-guide.js +222 -0
- package/src/session-supervision/analysis-manager.js +24 -4
- package/src/session-supervision/automatic-continuation.js +69 -18
- package/src/session-supervision/codex-app-server.js +52 -7
- package/src/session-supervision/codex-peer-opinion.js +27 -5
- package/src/session-supervision/consistency-auditor.js +1 -1
- package/src/session-supervision/continuation-analysis.js +7 -3
- package/src/session-supervision/decision-presentation.js +1 -1
- package/src/session-supervision/index.js +21 -13
- package/src/session-supervision/outcome-plan.js +76 -0
- package/src/session-supervision/purpose-instruction.js +28 -0
- package/src/session-supervision/recovery-frontier.js +14 -4
- package/src/session-supervision/remote-command-queue.js +15 -0
- package/src/session-supervision/remote-control.js +3 -0
- package/src/session-supervision/remote-hosts.js +70 -19
- package/src/session-supervision/remote-managed-delivery.js +94 -0
- package/src/session-supervision/remote-session-list.js +1 -1
- package/src/session-supervision/statistics.js +10 -3
- package/src/session-supervision/work-state.js +5 -0
|
@@ -24,7 +24,8 @@ function normalizedHost(value) {
|
|
|
24
24
|
if (!value || typeof value.alias !== 'string' || !aliasPattern.test(value.alias)) throw error('Enter an SSH host alias, for example nvidia-workstation.', 'remote_host_invalid');
|
|
25
25
|
const label = typeof value.label === 'string' ? value.label.trim() : '';
|
|
26
26
|
if (label.length > 80 || /[\x00-\x1f\x7f]/.test(label)) throw error('Choose a shorter host name.', 'remote_host_invalid');
|
|
27
|
-
|
|
27
|
+
if (value.enabled !== undefined && typeof value.enabled !== 'boolean') throw error('Invalid connection preference.', 'remote_host_invalid');
|
|
28
|
+
return { alias: value.alias, label: label || value.alias, id: remoteHostId(value.alias), ...(value.enabled === false ? { enabled: false } : {}) };
|
|
28
29
|
}
|
|
29
30
|
export async function readHosts(root) {
|
|
30
31
|
let value;
|
|
@@ -67,7 +68,7 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
function connect(connection) {
|
|
70
|
-
if (closed || connection.child || now() < connection.retryAt) return;
|
|
71
|
+
if (closed || connection.enabled === false || connection.child || now() < connection.retryAt) return;
|
|
71
72
|
connection.state = 'connecting'; connection.reason = null;
|
|
72
73
|
let child;
|
|
73
74
|
try { child = spawnImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5',
|
|
@@ -144,52 +145,102 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
144
145
|
for (const host of hosts) {
|
|
145
146
|
let c = connections.get(host.id);
|
|
146
147
|
if (!c) { c = { ...host, pending: new Map(), retryAt: 0, ready: false, status: null }; connections.set(host.id, c); }
|
|
147
|
-
c.label = host.label;
|
|
148
|
+
c.label = host.label; c.enabled = host.enabled !== false;
|
|
149
|
+
if (!c.enabled) {
|
|
150
|
+
if (c.child) disconnect(c);
|
|
151
|
+
c.ready = false; c.status = null; c.state = 'disconnected'; c.reason = null;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (c.state === 'disconnected') c.retryAt = 0;
|
|
148
155
|
if (c.ready && now() - c.receivedAt > 45_000) disconnect(c, 'Remote status is stale. Reconnecting.');
|
|
149
156
|
connect(c);
|
|
150
157
|
}
|
|
151
158
|
}
|
|
152
|
-
async function
|
|
153
|
-
const host = normalizedHost(value);
|
|
159
|
+
async function persistHosts(change, apply) {
|
|
154
160
|
const work = writes.catch(() => {}).then(async () => {
|
|
155
|
-
const hosts = (await readHosts(root))
|
|
156
|
-
if (!remove) hosts.push(host);
|
|
161
|
+
const hosts = await change(await readHosts(root));
|
|
157
162
|
if (hosts.length > MAX_HOSTS) throw error('At most eight remote hosts can be connected.', 'remote_host_invalid');
|
|
158
163
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
159
164
|
const path = join(root, 'remote-hosts.json'), temporary = `${path}.${randomUUID()}.tmp`;
|
|
160
165
|
try { await writeFile(temporary, JSON.stringify({ schemaVersion: 1, hosts }) + '\n', { mode: 0o600 }); await rename(temporary, path); }
|
|
161
166
|
finally { await rm(temporary, { force: true }); }
|
|
162
|
-
generation++;
|
|
163
|
-
const c = connections.get(host.id);
|
|
164
|
-
if (c) { disconnect(c); if (remove) connections.delete(host.id); else c.retryAt = 0; }
|
|
165
|
-
changed();
|
|
167
|
+
generation++; apply(); changed();
|
|
166
168
|
});
|
|
167
169
|
writes = work; await work;
|
|
168
170
|
}
|
|
171
|
+
async function save(value, remove = false) {
|
|
172
|
+
const host = normalizedHost(value);
|
|
173
|
+
await persistHosts(hosts => [...hosts.filter(h => h.id !== host.id), ...(remove ? [] : [host])], () => {
|
|
174
|
+
const c = connections.get(host.id);
|
|
175
|
+
if (c) { disconnect(c); if (remove) connections.delete(host.id); else { c.enabled = host.enabled !== false; c.retryAt = 0; } }
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function edit(value) {
|
|
179
|
+
const previous = normalizedHost({ alias: value.previousAlias });
|
|
180
|
+
const next = normalizedHost(value);
|
|
181
|
+
await persistHosts(hosts => {
|
|
182
|
+
const existing = hosts.find(h => h.id === previous.id);
|
|
183
|
+
if (!existing) throw error('This device is no longer registered. Refresh the device list.', 'remote_host_invalid');
|
|
184
|
+
if (next.id !== previous.id && hosts.some(h => h.id === next.id)) throw error('That SSH host is already registered.', 'remote_host_invalid');
|
|
185
|
+
return hosts.map(h => h.id === previous.id ? { ...next, ...(existing.enabled === false ? { enabled: false } : {}) } : h);
|
|
186
|
+
}, () => {
|
|
187
|
+
const c = connections.get(previous.id);
|
|
188
|
+
if (!c) return;
|
|
189
|
+
if (next.id === previous.id) c.label = next.label;
|
|
190
|
+
else { disconnect(c); connections.delete(previous.id); }
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async function setEnabled(alias, enabled) {
|
|
194
|
+
const host = normalizedHost({ alias });
|
|
195
|
+
if (typeof enabled !== 'boolean') throw error('Invalid connection preference.', 'remote_host_invalid');
|
|
196
|
+
await persistHosts(hosts => {
|
|
197
|
+
if (!hosts.some(h => h.id === host.id)) throw error('This device is no longer registered. Refresh the device list.', 'remote_host_invalid');
|
|
198
|
+
return hosts.map(h => h.id === host.id ? normalizedHost({ ...h, enabled }) : h);
|
|
199
|
+
}, () => {
|
|
200
|
+
const c = connections.get(host.id);
|
|
201
|
+
if (!c) return;
|
|
202
|
+
c.enabled = enabled;
|
|
203
|
+
if (!enabled) { disconnect(c); c.status = null; c.state = 'disconnected'; c.reason = null; }
|
|
204
|
+
else { c.retryAt = 0; if (c.state === 'disconnected') c.state = 'connecting'; }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
169
207
|
async function forward(command, status) {
|
|
170
208
|
if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
|
|
171
209
|
if (status.providerLinks?.[command.provider]?.connected === false) throw error('Provider is disconnected.', 'remote_host_access');
|
|
172
210
|
const c = connections.get(command.hostId);
|
|
173
211
|
if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('The remote host is disconnected. Reconnect before using this conversation.');
|
|
174
|
-
|
|
175
|
-
|
|
212
|
+
const manualAdd = command.action === 'session_add' && command.provider === 'codex' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.session || '');
|
|
213
|
+
if ((!manualAdd && !allowedActions.has(command.action)) || !['codex', 'claude'].includes(command.provider) || typeof command.session !== 'string' ||
|
|
214
|
+
(!manualAdd && !c.status?.sessionSupervision?.sessions?.some(s => s?.provider === command.provider && s.session === command.session))) throw error('Select an available remote conversation.', 'remote_host_invalid');
|
|
215
|
+
const restoreHidden = manualAdd && c.status?.sessionSupervision?.sessions?.some(row =>
|
|
216
|
+
row.provider === command.provider && row.session === command.session && row.localHidden === true);
|
|
176
217
|
const id = randomUUID();
|
|
177
218
|
const { hostId, ...payload } = command;
|
|
178
|
-
|
|
219
|
+
const result = await new Promise((resolve, reject) => {
|
|
179
220
|
const timer = setTimeout(() => { c.pending.delete(id); reject(error('Remote operation timed out. Check its result in the original conversation before retrying.')); }, requestTimeoutMs);
|
|
180
221
|
c.pending.set(id, { id: command.id, resolve, reject, timer });
|
|
181
|
-
c.child.stdin.write(JSON.stringify({ ...payload, id }) + '\n', e => {
|
|
222
|
+
c.child.stdin.write(JSON.stringify({ ...payload, ...(manualAdd ? { action: 'session_monitor', monitor: true } : {}), id }) + '\n', e => {
|
|
182
223
|
if (e && c.pending.has(id)) { c.pending.delete(id); clearTimeout(timer); reject(error('Remote command could not be delivered.')); }
|
|
183
224
|
});
|
|
184
225
|
});
|
|
226
|
+
// Existing remote runtimes understand these two idempotent navigation
|
|
227
|
+
// operations. Monitoring alone does not clear a remote local archive.
|
|
228
|
+
// Re-enter the normal forwarding path to recheck scope and connectivity;
|
|
229
|
+
// never restore a provider archive or resume the conversation's task.
|
|
230
|
+
if (restoreHidden && result.ok === true) return forward({ ...command, action: 'session_restore', scope: 'local' }, status);
|
|
231
|
+
return result;
|
|
185
232
|
}
|
|
186
233
|
function project(status, { providerLinks = status.providerLinks } = {}) {
|
|
187
234
|
if (!usable(status) || scopeOf(status) !== scope) return status;
|
|
188
235
|
const hosts = [...connections.values()];
|
|
189
|
-
const sessions = [], runs = [];
|
|
236
|
+
const sessions = [], runs = [], selectionCandidates = [];
|
|
190
237
|
for (const c of hosts) {
|
|
191
238
|
const ready = c.ready && now() - c.receivedAt <= 45_000;
|
|
192
239
|
const unavailableReason = c.reason || 'Remote status is stale.';
|
|
240
|
+
if (ready) for (const row of c.status?.sessionSupervision?.selectionCandidates || c.status?.sessionSupervision?.sessions || []) {
|
|
241
|
+
if (row?.provider === 'codex' && typeof row.session === 'string') selectionCandidates.push({ provider: 'codex', session: row.session,
|
|
242
|
+
title: typeof row.title === 'string' ? row.title : '', hostId: c.id, hostLabel: c.label === c.alias ? c.label : `${c.label} (${c.alias})` });
|
|
243
|
+
}
|
|
193
244
|
for (const row of c.status?.sessionSupervision?.sessions || []) {
|
|
194
245
|
if (!row || !['codex', 'claude'].includes(row.provider) || typeof row.session !== 'string' ||
|
|
195
246
|
providerLinks?.[row.provider]?.connected === false) continue;
|
|
@@ -212,11 +263,11 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
212
263
|
}
|
|
213
264
|
if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (['codex', 'claude'].includes(run?.provider) && providerLinks?.[run.provider]?.connected !== false) runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in ${run.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${run.session}.` } });
|
|
214
265
|
}
|
|
215
|
-
return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, state: c.state, reason: c.reason })) },
|
|
216
|
-
sessionSupervision: { ...status.sessionSupervision, sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
|
|
266
|
+
return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, reason: c.reason })) },
|
|
267
|
+
sessionSupervision: { ...status.sessionSupervision, selectionCandidates: [...(status.sessionSupervision?.selectionCandidates || []).filter(row => !row.hostId || row.hostId === 'local'), ...selectionCandidates], sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
|
|
217
268
|
deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
|
|
218
269
|
}
|
|
219
|
-
return { update, save, forward, project,
|
|
270
|
+
return { update, save, edit, setEnabled, forward, project,
|
|
220
271
|
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => ['codex', 'claude'].includes(s?.provider) && s.session === session)),
|
|
221
272
|
async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); } };
|
|
222
273
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const sessionId = value => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
|
|
7
|
+
const actions = new Set(['delivery_resume', 'delivery_pause', 'delivery_stop']);
|
|
8
|
+
|
|
9
|
+
// A host-local adapter, not an execution controller. Supply the installed
|
|
10
|
+
// runtime's resumeSession/controlDelivery, record reader and status projection.
|
|
11
|
+
// The existing managed supervisor remains the only owner of claims/dispatches.
|
|
12
|
+
export async function createRemoteManagedDelivery({ root, stateRoot, scope, authorize, select,
|
|
13
|
+
readRecord, listRecords, resumeSession, controlDelivery, deliveryHookStatus }) {
|
|
14
|
+
const automaticRoot = join(stateRoot, 'targets');
|
|
15
|
+
const manualRoot = join(stateRoot, 'manual-targets');
|
|
16
|
+
await mkdir(automaticRoot, { recursive: true, mode: 0o700 });
|
|
17
|
+
await mkdir(manualRoot, { recursive: true, mode: 0o700 });
|
|
18
|
+
async function target(directory, session) {
|
|
19
|
+
if (!sessionId(session)) throw new Error('Select a known conversation');
|
|
20
|
+
try { return JSON.parse(await readFile(join(directory, session + '.json'), 'utf8')); }
|
|
21
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
22
|
+
}
|
|
23
|
+
const read = (provider, session) => readRecord(root, provider, session);
|
|
24
|
+
const matches = (binding, record) => binding && record?.run?.id === binding.runId &&
|
|
25
|
+
record.run.managed?.taskId === binding.taskId;
|
|
26
|
+
async function guard(candidate) {
|
|
27
|
+
if (candidate?.provider !== 'codex' || !sessionId(candidate.session)) throw new Error('Select a known conversation');
|
|
28
|
+
const automatic = await target(automaticRoot, candidate.session);
|
|
29
|
+
const record = await read('codex', candidate.session);
|
|
30
|
+
if (automatic) {
|
|
31
|
+
await authorize(automatic.scope);
|
|
32
|
+
if (!record?.autoContinue || record.autoContinueEpoch !== automatic.epoch) throw new Error('Continuation consent changed');
|
|
33
|
+
return automatic;
|
|
34
|
+
}
|
|
35
|
+
const manual = await target(manualRoot, candidate.session);
|
|
36
|
+
if (!matches(manual, record)) throw new Error('Conversation not selected');
|
|
37
|
+
await authorize(manual.scope);
|
|
38
|
+
return manual;
|
|
39
|
+
}
|
|
40
|
+
async function records() {
|
|
41
|
+
const selectedScope = scope();
|
|
42
|
+
if (!selectedScope) return [];
|
|
43
|
+
const rows = new Map();
|
|
44
|
+
for (const [directory, automatic] of [[automaticRoot, true], [manualRoot, false]]) {
|
|
45
|
+
for (const file of await readdir(directory)) {
|
|
46
|
+
if (!file.endsWith('.json') || !sessionId(file.slice(0, -5))) continue;
|
|
47
|
+
const session = file.slice(0, -5), binding = await target(directory, session);
|
|
48
|
+
if (!binding || !isDeepStrictEqual(binding.scope, selectedScope)) continue;
|
|
49
|
+
const record = await read('codex', session);
|
|
50
|
+
if (automatic ? record?.autoContinue && record.autoContinueEpoch === binding.epoch : matches(binding, record))
|
|
51
|
+
rows.set(session, record);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...rows.values()];
|
|
55
|
+
}
|
|
56
|
+
async function control(command) {
|
|
57
|
+
if (!actions.has(command?.action) || command.provider !== 'codex' || !sessionId(command.session) ||
|
|
58
|
+
typeof command.taskId !== 'string' || !command.taskId) throw new Error('Unsupported remote command');
|
|
59
|
+
const selectedScope = await authorize();
|
|
60
|
+
const candidate = await select(command);
|
|
61
|
+
if (candidate?.provider !== command.provider || candidate.session !== command.session) throw new Error('Select a known conversation');
|
|
62
|
+
const automatic = await target(automaticRoot, command.session);
|
|
63
|
+
if (automatic || command.action !== 'delivery_resume') await guard(command);
|
|
64
|
+
const record = await read(command.provider, command.session);
|
|
65
|
+
if (record?.run?.managed?.taskId !== command.taskId ||
|
|
66
|
+
command.expectedRunId !== undefined && record.run.id !== command.expectedRunId)
|
|
67
|
+
throw new Error('Task changed; refresh before operating');
|
|
68
|
+
await authorize(selectedScope);
|
|
69
|
+
// Forward only the native control contract. A command cannot provide an
|
|
70
|
+
// admission, resume observation, permission override, or dispatch receipt.
|
|
71
|
+
await (command.action === 'delivery_resume' ? resumeSession : controlDelivery)({
|
|
72
|
+
root, provider: command.provider, session: command.session, taskId: command.taskId,
|
|
73
|
+
expectedRunId: command.expectedRunId ?? record.run.id, expectedRevision: command.expectedRevision,
|
|
74
|
+
action: command.action.slice('delivery_'.length),
|
|
75
|
+
});
|
|
76
|
+
if (!automatic && command.action === 'delivery_resume') {
|
|
77
|
+
await authorize(selectedScope);
|
|
78
|
+
if (!matches({ runId: record.run.id, taskId: command.taskId }, await read(command.provider, command.session)))
|
|
79
|
+
throw new Error('Task changed; refresh before operating');
|
|
80
|
+
const file = join(manualRoot, command.session + '.json');
|
|
81
|
+
const temporary = file + '.' + randomUUID() + '.tmp';
|
|
82
|
+
await writeFile(temporary, JSON.stringify({ scope: selectedScope, runId: record.run.id, taskId: command.taskId }) + '\n', { mode: 0o600 });
|
|
83
|
+
await rename(temporary, file);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function status() {
|
|
87
|
+
const selectedScope = await authorize();
|
|
88
|
+
// Supplying records is essential: display must not reconcile/mutate runs.
|
|
89
|
+
const result = await deliveryHookStatus({ root, records: await listRecords(root) });
|
|
90
|
+
await authorize(selectedScope);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
return { control, guard, records, status, readCurrentRecord: read };
|
|
94
|
+
}
|
|
@@ -6,7 +6,7 @@ import { readHosts } from './remote-hosts.js';
|
|
|
6
6
|
// This read-only command never boots a remote desktop host or supervisor.
|
|
7
7
|
export async function readRemoteSessionLists({ root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
|
|
8
8
|
providers, includeArchived = false, signal, spawnImpl = spawn, timeoutMs = 30_000 } = {}) {
|
|
9
|
-
const hosts = await readHosts(root);
|
|
9
|
+
const hosts = (await readHosts(root)).filter(host => host.enabled !== false);
|
|
10
10
|
return Promise.all(hosts.map(async host => {
|
|
11
11
|
try {
|
|
12
12
|
signal?.throwIfAborted();
|
|
@@ -61,7 +61,13 @@ class StatisticsAccumulator {
|
|
|
61
61
|
if (!row || typeof row !== 'object' || Array.isArray(row)) { this.missing = true; return; }
|
|
62
62
|
if (this.provider === 'codex' && row.type === 'session_meta') {
|
|
63
63
|
if (row.payload?.id !== this.session) this.wrongSession = true;
|
|
64
|
-
else
|
|
64
|
+
else {
|
|
65
|
+
this.matched = true;
|
|
66
|
+
// Creation can precede the first usage/activity record. Forked history
|
|
67
|
+
// can also contain older events belonging to the parent conversation.
|
|
68
|
+
const created = Date.parse(row.payload?.timestamp ?? row.timestamp);
|
|
69
|
+
if (Number.isFinite(created)) this.startedAt = created;
|
|
70
|
+
}
|
|
65
71
|
}
|
|
66
72
|
if (this.provider === 'claude') {
|
|
67
73
|
if (row.sessionId !== this.session || row.isSidechain === true) return;
|
|
@@ -104,9 +110,10 @@ class StatisticsAccumulator {
|
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
*finishSteps(now, incomplete = false, coverage = {}) {
|
|
107
|
-
|
|
113
|
+
const first = this.startedAt ?? this.earliest;
|
|
114
|
+
if (!this.matched || this.wrongSession || first > now) return unavailableStatistics();
|
|
108
115
|
const missing = this.missing || incomplete;
|
|
109
|
-
const start = Math.max(
|
|
116
|
+
const start = Math.max(first, now - WINDOW_MS), end = now;
|
|
110
117
|
const hours = new Map();
|
|
111
118
|
for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
|
|
112
119
|
let batch = 0;
|
|
@@ -28,7 +28,12 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
|
|
|
28
28
|
return result('approval', '承認待ち', candidate.decisionRequest?.context || '実行する操作の確認が必要です。', 'review_approval', true);
|
|
29
29
|
if (question || phase === 'needs_clarification' || candidate.analysis?.state === 'needs_user' || candidate.decisionRequest?.kind === 'question')
|
|
30
30
|
return result('question', '回答待ち', candidate.decisionRequest?.question || '続けるために回答が必要です。', 'answer', true);
|
|
31
|
+
if (candidate.autoContinue === true && ['starting', 'recovering'].includes(candidate.autoContinueStatus))
|
|
32
|
+
return result(candidate.autoContinueStatus, candidate.autoContinueStatus === 'starting' ? '自動継続を準備中' : '自動復旧中',
|
|
33
|
+
candidate.autoContinueReason || '自動継続はオンで保存されています。');
|
|
31
34
|
if ((run?.autoRecovery || run?.managed?.autoRecovery) && ['recovery_required', 'auto_resuming'].includes(run.state)) return result('checking', 'AIが状況確認中', '自動復旧の結果を待っています。');
|
|
35
|
+
if (candidate.autoContinue === true && candidate.autoContinueStatus === 'supervising' && run?.state === 'verification_pending')
|
|
36
|
+
return result('checking', '結果を確認中', '作業の結果を自動で確認しています。');
|
|
32
37
|
if (['failed', 'recovery_required', 'budget_exhausted'].includes(run?.state) || candidate.analysis?.state === 'failed' || candidate.autoContinueStatus === 'error')
|
|
33
38
|
return result('error', '対応が必要', run?.reason || '作業が中断されています。', run?.canResume || run?.canReplan ? 'resume' : 'open_original', true);
|
|
34
39
|
if (run?.dependencyWait || run?.managed?.dependencyWait) return result('waiting', '依存条件待ち', '条件が変わるまで待機します。');
|