@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.
Files changed (38) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -2
  3. package/src/cli-contract.js +1 -1
  4. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
  6. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  8. package/src/connection-health.js +253 -0
  9. package/src/connection-presentation.js +85 -0
  10. package/src/connection-recovery.js +102 -0
  11. package/src/console/app.js +60 -6
  12. package/src/console/commands.js +3 -0
  13. package/src/console/index.js +1 -0
  14. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  15. package/src/delivery-hooks/supervisor.js +14 -14
  16. package/src/desktop-host.js +43 -8
  17. package/src/direct-executor.js +8 -2
  18. package/src/doctor.js +1 -0
  19. package/src/index.js +12 -2
  20. package/src/remote-connection-guide.js +222 -0
  21. package/src/session-supervision/analysis-manager.js +24 -4
  22. package/src/session-supervision/automatic-continuation.js +69 -18
  23. package/src/session-supervision/codex-app-server.js +52 -7
  24. package/src/session-supervision/codex-peer-opinion.js +27 -5
  25. package/src/session-supervision/consistency-auditor.js +1 -1
  26. package/src/session-supervision/continuation-analysis.js +7 -3
  27. package/src/session-supervision/decision-presentation.js +1 -1
  28. package/src/session-supervision/index.js +21 -13
  29. package/src/session-supervision/outcome-plan.js +76 -0
  30. package/src/session-supervision/purpose-instruction.js +28 -0
  31. package/src/session-supervision/recovery-frontier.js +14 -4
  32. package/src/session-supervision/remote-command-queue.js +15 -0
  33. package/src/session-supervision/remote-control.js +3 -0
  34. package/src/session-supervision/remote-hosts.js +70 -19
  35. package/src/session-supervision/remote-managed-delivery.js +94 -0
  36. package/src/session-supervision/remote-session-list.js +1 -1
  37. package/src/session-supervision/statistics.js +10 -3
  38. 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
- return { alias: value.alias, label: label || value.alias, id: remoteHostId(value.alias) };
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 save(value, remove = false) {
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)).filter(h => h.id !== host.id);
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
- if (!allowedActions.has(command.action) || !['codex', 'claude'].includes(command.provider) || typeof command.session !== 'string' ||
175
- !c.status?.sessionSupervision?.sessions?.some(s => s?.provider === command.provider && s.session === command.session)) throw error('Select an available remote conversation.', 'remote_host_invalid');
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
- return new Promise((resolve, reject) => {
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 this.matched = true;
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
- if (!this.matched || this.wrongSession || this.earliest > now) return unavailableStatistics();
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(this.earliest, now - WINDOW_MS), end = now;
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', '依存条件待ち', '条件が変わるまで待機します。');