@awak-app/simy-cli 0.7.25 → 0.7.27
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 +8 -0
- package/package.json +2 -2
- package/src/agent.js +22 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +262 -0
- package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +76 -11
- 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/command.js +8 -0
- package/src/delivery-hooks/legacy-hook-routing.js +24 -0
- package/src/delivery-hooks/supervisor.js +14 -14
- package/src/desktop-host.js +49 -10
- 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 +23 -15
- 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/stop-notifications.js +26 -10
- package/src/session-supervision/work-state.js +5 -0
- package/src/sqm/claude-plugin.js +8 -1
|
@@ -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;
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { deliveryStop } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
3
3
|
|
|
4
|
+
const pendingManualChecks = run => (run.managed.acceptanceCriteria || []).filter(item =>
|
|
5
|
+
item.check?.type === 'manual' && !Object.hasOwn(run.managed.verified || {}, item.id));
|
|
6
|
+
|
|
4
7
|
// Auto-continue works while nobody is watching, so a person must learn when it
|
|
5
8
|
// stops. Map a stopped run to a notification kind; the native window owns copy.
|
|
6
9
|
export function stopNotificationKind(run, now = Date.now()) {
|
|
7
10
|
if (!run?.managed?.autoContinueEpoch && !run?.managed?.repairRequest) return null;
|
|
11
|
+
if (run.stopReason?.actor === 'user') return null;
|
|
8
12
|
// The clock keeps aging while paused; the persisted human decision takes
|
|
9
13
|
// precedence over a threshold that was reached only after that decision.
|
|
10
|
-
if (run.state === 'paused') return
|
|
14
|
+
if (run.state === 'paused') return /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '') ? null : deliveryStop(run, now) ? 'paused' : null;
|
|
11
15
|
if (run.state === 'auto_resuming' && !run.managed.resumeLease?.owner) return 'needs_decision';
|
|
12
|
-
if (run.state === 'blocked'
|
|
16
|
+
if (run.state === 'blocked') return run.blocker === 'external_dependency' || run.managed.dependencyWait ? null : 'needs_decision';
|
|
17
|
+
if (run.state === 'verification_pending') return pendingManualChecks(run).length ? 'needs_decision' : null;
|
|
13
18
|
if (run.state === 'recovery_required' && run.managed.autoRecovery !== true) return 'needs_decision';
|
|
14
19
|
if (run.state === 'failed' || run.state === 'budget_exhausted') return 'failed';
|
|
15
20
|
if (run.state === 'completed') return 'finished';
|
|
@@ -19,24 +24,35 @@ export function stopNotificationKind(run, now = Date.now()) {
|
|
|
19
24
|
// The first observation is a baseline: restarting SIMY must not replay alerts
|
|
20
25
|
// for runs that stopped earlier. Each distinct stop is announced once.
|
|
21
26
|
export function createStopNotifier({ emit, now = Date.now } = {}) {
|
|
22
|
-
// Retain one stop fingerprint per
|
|
23
|
-
//
|
|
27
|
+
// Retain one stop fingerprint per task for this process. A temporarily empty
|
|
28
|
+
// discovery result must not turn the same historical stop into a new alert.
|
|
24
29
|
const seen = new Map();
|
|
25
30
|
let baseline = true;
|
|
26
31
|
return {
|
|
27
32
|
observe(records) {
|
|
28
33
|
for (const record of records) {
|
|
29
|
-
|
|
34
|
+
if (record.providerArchived || record.localHidden) continue;
|
|
35
|
+
const run = record?.run;
|
|
36
|
+
const kind = stopNotificationKind(run, now());
|
|
30
37
|
const key = JSON.stringify([record.provider, record.session, run?.managed?.taskId]);
|
|
31
|
-
if (!kind)
|
|
32
|
-
|
|
38
|
+
if (!kind) {
|
|
39
|
+
if (run?.state === 'active' || run?.state === 'auto_resuming') seen.delete(key);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// Revisions and generated summaries change during maintenance without
|
|
43
|
+
// creating a new human task. Only a different stop/explicit request does.
|
|
44
|
+
const request = run.managed.pendingApproval || run.managed.understandingQuestion;
|
|
45
|
+
const id = createHash('sha256').update(JSON.stringify([record.provider, record.session, run.managed.taskId,
|
|
46
|
+
run.id, run.managed.autoContinueEpoch, kind, run.blocker || null, request?.id || null,
|
|
47
|
+
run.managed.userQuestion || null])).digest('hex').slice(0, 32);
|
|
33
48
|
if (seen.get(key) === id) continue;
|
|
34
49
|
seen.set(key, id);
|
|
50
|
+
const detail = request?.text || request?.summary || run.managed.userQuestion ||
|
|
51
|
+
(run.state === 'verification_pending' ? pendingManualChecks(run).map(item => item.description).filter(Boolean).join('; ') : null);
|
|
35
52
|
if (!baseline) emit({ type: 'notify', id, kind, provider: record.provider, session: record.session,
|
|
36
|
-
objective: String(run.objective || '').slice(0, 200)
|
|
53
|
+
objective: String(run.objective || '').slice(0, 200),
|
|
54
|
+
...(detail ? { detail: String(detail).slice(0, 500) } : {}) });
|
|
37
55
|
}
|
|
38
|
-
// Temporary provider absence or a diagnostic pass is not a new stop.
|
|
39
|
-
// Retain fingerprints until a materially different outcome is observed.
|
|
40
56
|
baseline = false;
|
|
41
57
|
},
|
|
42
58
|
};
|
|
@@ -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', '依存条件待ち', '条件が変わるまで待機します。');
|
package/src/sqm/claude-plugin.js
CHANGED
|
@@ -28,7 +28,14 @@ export async function claudePluginFiles() {
|
|
|
28
28
|
for (const name of ["loop-kernel.mjs", "loop-kernel/index.mjs", "loop-kernel/policy.json", "loop-kernel/pin.json", "loop-kernel/outcome-review.mjs", "loop-kernel/work-frontier.mjs", "loop-kernel/task-pool.mjs"]) {
|
|
29
29
|
files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
const collector = await readFile(new URL("../cli-observability.js", import.meta.url), "utf8");
|
|
32
|
+
files.set("hooks/cli-observability.mjs", collector.replace("./web-api.js", "./web-api.mjs"));
|
|
33
|
+
files.set("hooks/web-api.mjs", await readFile(new URL("../web-api.js", import.meta.url), "utf8"));
|
|
34
|
+
const deliveryLoop = await readFile(path.join(SOURCE, "hooks/delivery-loop.mjs"), "utf8");
|
|
35
|
+
files.set("hooks/delivery-loop.mjs", deliveryLoop.replace(
|
|
36
|
+
"const observeDeliveryRecord = (...args) => import('../../../cli-observability.js')\n .then(({ observeDeliveryRecord: observe }) => observe(...args)).catch(() => false);",
|
|
37
|
+
"import { observeDeliveryRecord } from './cli-observability.mjs';",
|
|
38
|
+
));
|
|
32
39
|
files.set("hooks/recovery-incident.mjs", await readFile(path.join(SOURCE, "hooks/recovery-incident.mjs"), "utf8"));
|
|
33
40
|
files.set("hooks/minimal-sufficient.mjs", await readFile(path.join(SOURCE, "hooks/minimal-sufficient.mjs"), "utf8"));
|
|
34
41
|
files.set("hooks/outcome-progress.mjs", await readFile(path.join(SOURCE, "hooks/outcome-progress.mjs"), "utf8"));
|