@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
package/README.md CHANGED
@@ -1425,3 +1425,9 @@ interrupted write or approve a pending external action. Explicit task
1425
1425
  stop/pause, provider permission prompts and unanswered user questions remain
1426
1426
  boundaries. `runtime supervisor-disable` is a durable explicit stop and is never
1427
1427
  reversed by waking or booting. A later explicit enable is required.
1428
+
1429
+ A recovery read can inspect the one unknown operation it is bound to while
1430
+ that operation continues to block writes. Observing the result settles local
1431
+ occupancy without accepting the task. Long-running tasks transmit current
1432
+ recovery context and recent work; the full history remains in durable state
1433
+ and can be inspected through `delivery status`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.25",
3
+ "version": "0.7.26",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
40
40
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
41
41
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
42
- "check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js",
42
+ "check:session-supervision-syntax": "node --check ./src/session-supervision/session-list.js && node --check ./src/console/sessions.js && node --check ./src/session-supervision/remote-hosts.js && node --check ./src/session-supervision/remote-entry.js && node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs && node --check ./src/session-supervision/purpose-instruction.js && node --check ./src/session-supervision/purpose-recovery.js && node --check ./src/session-supervision/purpose-candidate.js && node --check ./src/codex-plugins/simy/hooks/scene-reasoning.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-cycle.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-rule-review.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-search.mjs && node --check ./src/codex-plugins/simy/hooks/purpose-evidence.mjs && node --check ./src/session-supervision/purpose-observer.js && node --check ./src/session-supervision/purpose-verification.js && node --check ./src/codex-plugins/simy/hooks/purpose-workspace.mjs && node --check ./scripts/purpose-search-live-e2e.js && node --check ./src/session-supervision/remote-managed-delivery.js",
43
43
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
44
44
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
45
45
  "check:sqm": "node --check ./src/sqm/provider-plugin-repair.js && node --check ./src/sqm/provider-plugin-status.js && node --check ./src/sqm/advisory-guidance.js && node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
@@ -14,7 +14,7 @@ import {
14
14
  } from "./durable-local-task-steps-contract.js";
15
15
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
16
16
 
17
- export const CLI_VERSION = "0.7.25";
17
+ export const CLI_VERSION = "0.7.26";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -1,3 +1,48 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+
5
+ // Keep every original receipt outside the mutable task record before collapsing
6
+ // repeated inspections of the same contract, stage, decision and lease. This is
7
+ // storage maintenance, never a completed action or permission to replay one.
8
+ export async function compactConsistencyOutbox(root, record) {
9
+ const managed = record.run?.managed, outbox = managed?.consistencyOutbox;
10
+ if (!Array.isArray(outbox) || outbox.length < 2) return false;
11
+ const groups = new Map();
12
+ for (const item of outbox) {
13
+ const compatible = item.stage === 'executing' && item.decisionId && item.leaseId ||
14
+ item.stage === 'inspected' && item.targetTask && item.objectiveDigest;
15
+ const key = compatible
16
+ ? JSON.stringify([item.targetTask, item.version, item.objectiveDigest, item.stage, item.decisionId, item.leaseId, item.advisory === true, item.result])
17
+ : item;
18
+ if (!groups.has(key)) groups.set(key, []);
19
+ groups.get(key).push(item);
20
+ }
21
+ if (groups.size === outbox.length) return false;
22
+ const payload = JSON.stringify({ schema: 1, kind: 'execution_handoff_snapshot', provider: record.provider,
23
+ session: record.session, runId: record.run.id, requests: outbox });
24
+ const digest = createHash('sha256').update(payload).digest('hex');
25
+ const directory = join(root, 'consistency', 'handoff-history');
26
+ const file = join(directory, `${digest}.json`), temporary = `${file}.${randomUUID()}.tmp`;
27
+ await mkdir(directory, { recursive: true, mode: 0o700 });
28
+ try {
29
+ await writeFile(temporary, payload, { mode: 0o600 });
30
+ await rename(temporary, file);
31
+ if (await readFile(file, 'utf8') !== payload) throw new Error('Handoff history could not be retained.');
32
+ } finally { await rm(temporary, { force: true }); }
33
+ managed.consistencyOutbox = [...groups.values()].map(items => {
34
+ if (items.length === 1) return items[0];
35
+ const latest = items.at(-1);
36
+ return { ...latest, findings: [...new Set(items.flatMap(item => item.findings || []))],
37
+ ...Object.fromEntries(['createdAt', 'dueAt'].flatMap(key => {
38
+ const values = items.map(item => item[key]).filter(Number.isFinite);
39
+ return values.length ? [[key, Math.min(...values)]] : [];
40
+ })),
41
+ retainedHistory: { digest, requestCount: items.reduce((count, item) => count + (item.retainedHistory?.requestCount || 1), 0) } };
42
+ });
43
+ return true;
44
+ }
45
+
1
46
  // Durable handoff deadlines concern unowned work, never a live effect lease.
2
47
  // Expiry queues diagnosis; it does not replay a write or grant permission.
3
48
  export const HANDOFF_RECEIPT_MS = 2 * 60_000;
@@ -8,7 +8,7 @@ import { currentRecoveryIncident, recordRecoveryIncident, recordRecoveryStage, s
8
8
  import { rememberPerspectives, perspectiveScope, recallPerspectives, perspectiveInstruction } from './perspective-memory.mjs';
9
9
  import { recordHypothesisSheet } from './hypothesis-sheet.mjs';
10
10
  import { bindControllerCompletion } from './completion-binding.mjs';
11
- import { recordHandoffStage } from './consistency-handoff.mjs';
11
+ import { recordHandoffStage, compactConsistencyOutbox } from './consistency-handoff.mjs';
12
12
  import { retainApplicableRules } from './purpose-rule-policy.mjs';
13
13
  import { reconcileRequirements, retainControllerEvidence, reconcileFindings, findingText, evidenceLedger, currentPurposeObservation } from './purpose-evidence.mjs';
14
14
  import { workspaceIdentity } from './purpose-workspace.mjs';
@@ -116,11 +116,13 @@ export async function linkedProvider(root, provider) {
116
116
  const digest = value => createHash('sha256').update(value).digest('hex');
117
117
  const text = (value, max = 4000) => typeof value === 'string' ? value.trim().slice(0, max) : '';
118
118
  const exactText = value => typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 1000 ? value : null;
119
- export function canUpgradeDelivery(run) {
119
+ export function canUpgradeDelivery(run, { automatic = false } = {}) {
120
120
  return Boolean(run && !run.managed && text(run.id) && text(run.objective) && Number.isFinite(run.startedAt) &&
121
121
  Number.isSafeInteger(run.maxFailures) && run.maxFailures > 0 &&
122
122
  (['active', 'paused', 'recovery_required'].includes(run.state) ||
123
- run.state === 'blocked' && run.blocker === 'external_dependency'));
123
+ run.state === 'blocked' && run.blocker === 'external_dependency') &&
124
+ (!automatic || run.stopReason?.actor !== 'user' && (run.state !== 'paused' || run.stopReason?.actor === 'system' ||
125
+ /^(?:Paused because no progress was confirmed in \d+ minutes\.|No verified acceptance progress in \d+ minutes\.|Paused after \d+ steps without confirmed progress\.) Review the original conversation, then resume\.$/.test(run.reason || ''))));
124
126
  }
125
127
  const contextText = (value, max = 500) => text(value, max).replace(/\b(?:sk|key)-[A-Za-z0-9_-]{12,}\b|\bBearer\s+\S+|\b(?:password|token|api[_-]?key)\s*[:=]\s*\S+/gi, '[redacted]');
126
128
  export function recordPath(root, provider, session) {
@@ -275,7 +277,9 @@ export async function updateRecord(root, provider, session, mutate, { inspectPro
275
277
  delete criterion.check.path;
276
278
  }
277
279
  }
278
- const serialized = JSON.stringify(next);
280
+ let serialized = JSON.stringify(next);
281
+ if (Buffer.byteLength(serialized) > MAX_STATE_BYTES / 2 && await compactConsistencyOutbox(root, next))
282
+ serialized = JSON.stringify(next);
279
283
  if (Buffer.byteLength(serialized) > MAX_STATE_BYTES) throw new Error('Delivery state exceeds 4 MiB; retain history before reducing task details.');
280
284
  if (serialized === previousBytes) return next;
281
285
  temporary = `${file}.${randomUUID()}.tmp`;
@@ -864,7 +868,8 @@ export async function startDelivery({ root = rootDirectory(), provider, session,
864
868
  const fences = Object.values(gate.taskFences?.records || {}).filter(item => item.taskId === (prior.managed?.taskId || prior.id) && item.identity.provider === provider && item.identity.session === session);
865
869
  if (fences.some(item => !readTaskAdmission({ root: gateRoot, identity: item.identity, taskId: item.taskId }).canResume)) throw new Error('Existing task admission must be reconciled before enrolling follow-up work.');
866
870
  }
867
- if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run)) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
871
+ if (upgrading && ((!reconciling && !canUpgradeDelivery(previous.run, { automatic: Boolean(autoContinueEpoch) })) ||
872
+ autoContinueEpoch && hasPendingUserStop(root, previous) || previous.run.id !== upgradeRunId)) throw new Error('Only the exact nonterminal ordinary loop can be upgraded; resolve its blocker or resume its original task first.');
868
873
  if (previous.run && !upgrading && !following) throw new Error('This session already has a Delivery Loop. Resume or stop it; budgets cannot be reset by starting again.');
869
874
  if (upgrading && (text(objective) !== previous.run.objective || executionCwd && previous.run.executionCwd && executionCwd !== previous.run.executionCwd)) throw new Error('Upgrade must preserve the original objective and task directory.');
870
875
  const managedConfig = managed && { ...(managed === true ? {} : managed),
@@ -1201,6 +1206,18 @@ export async function checkpointDelivery({ root = rootDirectory(), provider, ses
1201
1206
  const localReconciliation = flexibleReasoning(run.managed) && ['read','local_edit','local_test'].includes(uncertainAction?.kind);
1202
1207
  if (run.managed?.executionUncertain && run.managed.recoveryDiagnosisDecisionId === lastWork?.result?.decisionId && lastWork.result.status === 'observed' && (lastWork.result.evidence?.length || lastWork.result.observationSource === 'controller_tool_result' || localReconciliation) && lastWork.action.kind === 'read') {
1203
1208
  run.managed.recoveryEvidence = { decisionId: lastWork.result.decisionId, source: lastWork.result.observationSource || 'provider_reported_read', checkedAt: now };
1209
+ const priorUnit = run.managed.purpose?.evidence?.units?.find(unit =>
1210
+ unit.id === uncertain.decisionId && unit.status === 'unknown' &&
1211
+ (!uncertain.attemptId || unit.attemptId === uncertain.attemptId));
1212
+ if (priorUnit) {
1213
+ priorUnit.status = 'observed';
1214
+ // The read already met the reconciliation guard above, including a
1215
+ // local observation reported without separate evidence references.
1216
+ // Release only local occupancy, never acceptance or external effects.
1217
+ if (['local_edit', 'local_test'].includes(priorUnit.action?.kind)) {
1218
+ priorUnit.localExecutionSettledAt = now;
1219
+ }
1220
+ }
1204
1221
  delete run.managed.executionUncertain;
1205
1222
  resolveRecoveryReceipt(run, uncertain?.attemptId);
1206
1223
  delete run.managed.recoveryDiagnosisDecisionId;
@@ -1873,14 +1890,15 @@ export async function processIdentity(pid) {
1873
1890
  export async function claudeHookChannel(record, { session, cwd, inspectProcess = processIdentity, platform = process.platform } = {}) {
1874
1891
  const unavailable = (code, reason) => ({ ready: false, code, reason });
1875
1892
  if (platform === 'win32') return unavailable('platform_unsupported', 'Claude process verification is unavailable on Windows; monitoring remains available.');
1876
- if (!record) return unavailable('hook_missing', 'SIMY has not observed a session-start event for this conversation. Check the SIMY plugin and hook execution in the original Claude window.');
1893
+ if (!record) return unavailable('hook_missing', 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
1877
1894
  if (record.provider !== 'claude' || record.session !== session) return unavailable('session_mismatch', 'The observed hook belongs to a different Claude conversation. Refresh this conversation.');
1878
1895
  try { if (record.cwd !== await realpath(cwd)) return unavailable('workspace_mismatch', 'The observed Claude hook belongs to a different folder. Refresh this conversation.'); }
1879
1896
  catch { return unavailable('workspace_unavailable', 'The Claude conversation folder could not be verified. Check the folder and refresh.'); }
1880
1897
  if (record.hookEndedAt || record.lastEvent === 'SessionEnd') return unavailable('session_ended', 'This Claude session has ended. Return to the original conversation to continue.');
1898
+ if (!record.hookStartedAt && record.hookRecoveryBlocked) return unavailable('process_changed', 'A different Claude process reported this conversation. Reopen the original conversation to establish its hook owner.');
1881
1899
  if (!record.hookStartedAt) return unavailable(record.hookObservedAt ? 'process_unverified' : 'hook_missing', record.hookObservedAt
1882
1900
  ? 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.'
1883
- : 'SIMY has not observed a session-start event for this conversation. Check the SIMY plugin and hook execution in the original Claude window.');
1901
+ : 'Waiting for the original Claude hook. SIMY recovers automatically when the next verified prompt or response-end hook arrives. If no notifications arrive, check the SIMY plugin in the original Claude window.');
1884
1902
  const owner = record.hookSessionProcess || record.process; // Existing verified records remain compatible.
1885
1903
  if (!owner?.identity || !Number.isSafeInteger(owner.pid) || owner.pid < 2) return unavailable('process_unverified', 'The session-start hook ran, but SIMY could not identify its Claude process. Check the hook diagnostics.');
1886
1904
  if (record.process?.pid !== owner.pid || record.process?.identity !== owner.identity) return unavailable('process_changed', 'A different Claude process reported this conversation. Refresh the original conversation.');
@@ -2019,6 +2037,22 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2019
2037
  backgroundIdle: event === 'Stop' && Array.isArray(input.background_tasks) && input.background_tasks.length === 0 && Array.isArray(input.session_crons) && input.session_crons.length === 0 };
2020
2038
  }
2021
2039
  record.lastNotificationType = event === 'Notification' ? text(input.notification_type, 80) : null;
2040
+ if (provider === 'claude' && event !== 'SessionStart' && identity && record.process &&
2041
+ (record.process.pid !== identity.pid || record.process.identity !== identity.identity)) {
2042
+ record.hookRecoveryBlocked = 'process_changed';
2043
+ }
2044
+ // A plugin loaded after the conversation opened can miss SessionStart.
2045
+ // A real prompt/stop hook proves the same exact session and provider parent
2046
+ // without fabricating a start event or adopting an existing task/owner.
2047
+ if (provider === 'claude' && ['UserPromptSubmit', 'Stop'].includes(event) &&
2048
+ !record.run && !record.hookStartedAt && !record.hookSessionProcess && !record.hookEndedAt && !record.hookRecoveryBlocked &&
2049
+ !record.parentSession && !env.SIMY_PARENT_SESSION && !env.CODEX_THREAD_ID &&
2050
+ !record.providerArchived && identity?.identity && Number.isSafeInteger(identity.pid) && identity.pid > 1 &&
2051
+ (!record.process || record.process.pid === identity.pid && record.process.identity === identity.identity)) {
2052
+ record.hookStartedAt = now;
2053
+ record.hookSessionProcess = { ...identity };
2054
+ record.hookRecovery = { source: event, recoveredAt: now, reason: 'session_start_missing' };
2055
+ }
2022
2056
  if (identity) {
2023
2057
  // Legacy records have no frozen hook owner; a new process must not inherit
2024
2058
  // an old SessionStart admission through a later prompt or stop event.
@@ -2028,6 +2062,8 @@ async function handleHookState({ input, provider, root = rootDirectory(), now =
2028
2062
  }
2029
2063
  if (event === 'SessionStart') {
2030
2064
  record.hookObservedAt = now;
2065
+ delete record.hookRecovery;
2066
+ delete record.hookRecoveryBlocked;
2031
2067
  delete record.hookEndedAt;
2032
2068
  if (identity) { record.hookStartedAt = now; record.hookSessionProcess = { ...identity }; }
2033
2069
  else { delete record.hookStartedAt; delete record.hookSessionProcess; delete record.process; }
@@ -37,7 +37,7 @@ export function compactWorkHistory(ledger, historyLimit = 64) {
37
37
  }
38
38
 
39
39
  /** Pure projection of the existing evidence.units; never grants authority or dispatches. */
40
- export function planWorkFrontier(ledger, { prObservations = {}, stopped = false, preferredUnitId = null } = {}) {
40
+ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false, preferredUnitId = null, reconcileUnknownUnitId = null } = {}) {
41
41
  if (!ledger || !Array.isArray(ledger.units)) throw new Error('Existing work-unit ledger required.');
42
42
  const units = ledger.units;
43
43
  const ids = new Set();
@@ -88,9 +88,11 @@ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false,
88
88
  }
89
89
  if (!reason) {
90
90
  const conflict = [...holders, ...proposals.map(id => byId.get(id))].find(other => other.id !== unit.id && conflicts(unit, other) &&
91
- // A read establishes the result of an observed effect. Check all
92
- // other holders; a label must never bypass live/unknown writers.
93
- !(unit.action?.kind === 'read' && unit.writeResources.length === 0 && other.status === 'observed'));
91
+ // Inspect observed effects, or one controller-bound unknown effect.
92
+ // Keep all writers fenced and check every other resource holder.
93
+ !(unit.action?.kind === 'read' && unit.writeResources.length === 0 &&
94
+ (other.status === 'observed' || unit.id === preferredUnitId &&
95
+ other.status === 'unknown' && other.id === reconcileUnknownUnitId)));
94
96
  if (conflict) reason = `resource_conflict:${conflict.id}`;
95
97
  }
96
98
  if (!reason) { state = 'proposed'; proposals.push(unit.id); }
@@ -103,7 +105,7 @@ export function planWorkFrontier(ledger, { prObservations = {}, stopped = false,
103
105
  const inputDigest = hash({ purposeRevision: ledger.purposeRevision, unitsOmitted: ledger.unitsOmitted,
104
106
  units: units.map(({ id, owner, status, purposeRevision, dependsOn, readResources, writeResources, waitReason, pr, action, effectReconciledRevision, localExecutionSettledAt }) =>
105
107
  ({ id, owner, status, purposeRevision, dependsOn, readResources, writeResources, waitReason, pr, action, effectReconciledRevision, localExecutionSettledAt })),
106
- prObservations, stopped, preferredUnitId });
108
+ prObservations, stopped, preferredUnitId, reconcileUnknownUnitId });
107
109
  return { version: 1, inputDigest, proposals, rows, authority: 'proposal_only' };
108
110
  }
109
111
 
@@ -66,6 +66,42 @@ export function confirmDispatch({ root = detachmentRoot(), attemptId }) {
66
66
  if (!attempt || !Object.values(state.receipts).some(item => item.attemptId === attemptId && item.resultRef)) fail('result_required');
67
67
  attempt.phase = 'confirmed'; state.revision++; atomicSave(path(root), state); return state; });
68
68
  }
69
+ // Transport settlement is independent of acceptance of the provider's report.
70
+ // Only an observed terminal event for the exact owned turn may use this path.
71
+ export function queueProviderTerminalReceipt({ root = detachmentRoot(), attemptId, providerSession, providerExecutionId, status, response = '' }) {
72
+ if (!['completed', 'failed', 'interrupted'].includes(status) || typeof response !== 'string' ||
73
+ !providerSession || !providerExecutionId) fail('terminal_evidence_invalid');
74
+ root = realpathSync(root);
75
+ const state = read(root), attempt = state.attempts[digest(attemptId)];
76
+ if (!attempt?.provenance || attempt.provenance.providerSession !== providerSession) fail('terminal_identity_mismatch');
77
+ const ref = join(root, 'update-detachment', 'provider-results', digest([attemptId, providerExecutionId]) + '.json');
78
+ const evidence = { schemaVersion: 1, kind: 'provider_terminal', attemptId, providerSession, providerExecutionId, status,
79
+ taskHash: attempt.taskHash, identityHash: attempt.identityHash, generation: attempt.generation,
80
+ responseDigest: digest(response), response: response.slice(0, 65536), truncated: response.length > 65536 };
81
+ const prior = readState(ref);
82
+ if (prior && digest(prior) !== digest(evidence)) fail('terminal_evidence_conflict');
83
+ if (!prior) atomicSave(ref, evidence);
84
+ const item = { schemaVersion: 1, kind: 'terminal_receipt', attemptId, providerExecutionId, resultRef: ref,
85
+ receiptId: `terminal:${attemptId}:${providerExecutionId}`, evidenceDigest: digest(evidence) };
86
+ atomicSave(join(root, 'update-detachment', 'intake', digest(item) + '.json'), item);
87
+ try { flushDispatchIntake(root); } catch (error) { if (error.code !== 'state_busy') throw error; }
88
+ return ref;
89
+ }
90
+ function applyTerminalReceipt(state, item, root) {
91
+ const ref = item.resultRef, attempt = state.attempts[digest(item.attemptId)];
92
+ const expected = join(resolve(root), 'update-detachment', 'provider-results', digest([item.attemptId, item.providerExecutionId]) + '.json');
93
+ if (ref !== expected || realpathSync(ref) !== ref) fail('terminal_evidence_invalid');
94
+ const evidence = readState(ref);
95
+ if (!evidence || digest(evidence) !== item.evidenceDigest || evidence.schemaVersion !== 1 || evidence.kind !== 'provider_terminal' ||
96
+ !['completed', 'failed', 'interrupted'].includes(evidence.status) || evidence.attemptId !== item.attemptId ||
97
+ evidence.providerExecutionId !== item.providerExecutionId || !attempt?.provenance ||
98
+ evidence.providerSession !== attempt.provenance.providerSession || evidence.taskHash !== attempt.taskHash ||
99
+ evidence.identityHash !== attempt.identityHash || evidence.generation !== attempt.generation) fail('terminal_identity_mismatch');
100
+ const priorTurns = Object.values(state.receipts).filter(row => row.attemptId === item.attemptId).map(row => row.providerExecutionId).filter(Boolean);
101
+ if (priorTurns.some(id => id !== item.providerExecutionId)) fail('terminal_evidence_conflict');
102
+ applyReceipt(state, item);
103
+ attempt.phase = 'confirmed';
104
+ }
69
105
  export function dispatchWithFence(owner, { taskId, attemptId = randomUUID(), identity = null, provenance = null }, send) {
70
106
  if (!owner || typeof send !== 'function' || typeof taskId !== 'string' || !taskId || typeof attemptId !== 'string' || !attemptId) fail('dispatch_identity_required');
71
107
  return withLock(path(owner.root), () => {
@@ -165,7 +201,9 @@ export function flushDispatchIntake(root = detachmentRoot()) {
165
201
  for (const name of names) {
166
202
  if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
167
203
  const item = readState(join(directory, name));
168
- if (item?.kind === 'receipt') applyReceipt(state, item); else applyIdentityIntake(state, item);
204
+ if (item?.kind === 'receipt') applyReceipt(state, item);
205
+ else if (item?.kind === 'terminal_receipt') applyTerminalReceipt(state, item, root);
206
+ else applyIdentityIntake(state, item);
169
207
  applied.push(name);
170
208
  }
171
209
  if (applied.length) { state.revision++; atomicSave(path(root), state); for (const name of applied) unlinkSync(join(directory, name)); }
@@ -0,0 +1,253 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { lookup } from 'node:dns/promises';
3
+ import { networkInterfaces, homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { readSession, isSessionValid } from './session-store.js';
7
+ import { readProviderLinks } from './provider-links.js';
8
+ import { inspectProviderStatus } from './desktop-status.js';
9
+ import { resolveWebOrigin } from './web-origin.js';
10
+
11
+ const execute = promisify(execFile);
12
+ const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
13
+ export const shellQuote = value => "'" + String(value).replaceAll("'", "'\\''") + "'";
14
+ const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, '').slice(0, 180);
15
+ const check = (layer, state, code, message) => ({ layer, state, code, message });
16
+
17
+ // Fixed messages deliberately exclude response bodies, SSH output and credentials.
18
+ const remedies = {
19
+ dns: ['名前解決に失敗しました', '接続先名・SSHのHostName・DNS設定を確認してください。社内名やVPN内の名前なら、必要なVPNに接続して再確認してください。'],
20
+ proxy_dns: ['プロキシの名前解決に失敗しました', 'HTTP_PROXY・HTTPS_PROXY・ALL_PROXYの接続先名と、その名前を解決するVPN/DNSを確認してください。'],
21
+ proxy_auth: ['プロキシ認証が必要です', 'プロキシの認証期限・アカウントを確認し、組織指定の認証方法で更新してください。'],
22
+ proxy_config: ['プロキシ設定を解釈できません', 'プロキシ環境変数のURL形式・ポート・対応方式を確認してください。'],
23
+ proxy_connect: ['プロキシ経由の接続に失敗しました', 'プロキシへの到達性と転送先の許可、NO_PROXYの適用範囲を確認してください。'],
24
+ refused: ['接続先が接続を拒否しました', '接続先のサービスが起動しているか、ポートと待受アドレス、拒否するファイアウォール規則を確認してください。'],
25
+ route: ['接続先への経路がありません', 'ネットワーク・VPN・ルーティングと接続先の稼働状態を確認してください。'],
26
+ timeout: ['接続がタイムアウトしました', 'VPN・ファイアウォール・接続先停止が候補です。タイムアウトだけでは原因を特定できません。経路と接続先の受信規則を確認してください。'],
27
+ reset: ['接続が途中で切断されました', '接続先サービス・プロキシ・VPNの状態を確認し、診断を再実行してください。'],
28
+ tls_expired: ['証明書の期限または端末時刻に問題があります', '端末の日付と時刻を確認してください。期限切れのサーバー証明書は管理者が更新し、更新後に再確認してください。'],
29
+ tls_name: ['証明書と接続先名が一致しません', '証明書が対象とする正しいDNS名を使うか、管理者に証明書の修正を依頼してください。'],
30
+ tls_trust: ['証明書の信頼を検証できません', '証明書チェーンを管理者に確認してください。社内CAを使う場合は正しいCAをCURL_CA_BUNDLEまたはSSL_CERT_FILEに設定して再確認してください。'],
31
+ tls_client: ['クライアント証明書を利用できません', 'サービスが要求するクライアント証明書の有効期限・秘密鍵・読取権限を確認してください。'],
32
+ tls: ['TLS接続に失敗しました', '接続先のHTTPSポート・TLS対応・証明書と、TLSを中継するプロキシを確認してください。'],
33
+ key_changed: ['SSHホスト鍵が以前と異なります', '相手側でssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pubを実行し、正しい端末か管理者と照合してからknown_hostsを更新してください。'],
34
+ key_unknown: ['SSHホスト鍵が未確認です', '相手側のホスト鍵指紋を確認したうえで、通常のssh接続で初回登録してください。'],
35
+ key_permissions: ['SSH秘密鍵の権限が広すぎます', '自分が所有する秘密鍵のアクセス権を本人のみ読書き可能に修正してください。秘密鍵の内容を共有する必要はありません。'],
36
+ key_missing: ['SSH秘密鍵を読み取れません', 'SSH設定のIdentityFile、鍵ファイルの存在・所有者・読取権限を確認してください。'],
37
+ ssh_certificate: ['SSH証明書が無効または期限切れです', '端末時刻とSSH証明書の期限を確認し、発行元の手順で更新してください。'],
38
+ ssh_auth: ['SSH認証に失敗しました', 'SSHのUser・IdentityFile・相手側のauthorized_keysを確認してください。パスフレーズが必要な鍵はssh-agentへ読み込みます。'],
39
+ ssh_agent: ['SSHエージェントが署名できません', 'ssh-add -lでエージェントの鍵を確認し、鍵のロックやハードウェアキーの状態を確認してください。'],
40
+ ssh_config: ['SSH設定を読み取れません', 'SSHのHost・HostName・Port・ProxyJump・ProxyCommandの設定とIncludeファイルを確認してください。'],
41
+ ssh_forward: ['SSHのポート転送が許可されていません', '相手側のAllowTcpForwarding・PermitListen・鍵ごとの転送制限を管理者に確認してください。'],
42
+ ssh_missing: ['OpenSSHが見つかりません', 'この端末にOpenSSHクライアントをインストールして再確認してください。'],
43
+ cli_missing: ['相手のSIMY CLIが見つかりません', '相手側にSIMY CLIをインストールし、SSHのログインシェルのPATHでsimyが見つかることを確認してください。'],
44
+ auth: ['認証が必要、または期限切れです', 'サービスのログイン・OAuth再認証またはAPIキー更新を行ってください。APIキーは--token-envで環境変数名を指定できます。'],
45
+ permission: ['接続先がアクセスを拒否しました(403)', '対象アカウント・組織・APIスコープとサービス側のアクセス規則を確認してください。403だけでは権限不足とWAFによる拒否を区別できません。'],
46
+ rate_limit: ['サービスの利用制限に達しました(429)', 'サービスの利用制限と再試行時刻を確認し、待ってから再確認してください。'],
47
+ service: ['接続先サービスで障害が発生しています', 'サービスの稼働状況を確認し、復旧後に再確認してください。'],
48
+ endpoint: ['APIの接続先が見つかりません', 'APIのURL・バージョン・パスが正しいか確認してください。'],
49
+ method: ['この接続先はHEAD診断に対応していません', 'サービスが公開する読取専用のヘルスチェックURLで再確認してください。'],
50
+ redirect: ['別のページへ転送されました', 'ログイン画面または移転先の可能性があります。サービスの正しい接続先と認証方法を確認してください。'],
51
+ curl_missing: ['Web診断用のcurlが見つかりません', 'curlをインストールして再確認してください。'],
52
+ unknown: ['接続状態を確定できません', '対象の接続先と設定を確認し、診断を再実行してください。'],
53
+ };
54
+
55
+ export function issueFor(code, layer = 'connection', state = 'failed') {
56
+ const [message, instruction] = remedies[code] || remedies.unknown;
57
+ return { ...check(layer, state, code, message), instruction };
58
+ }
59
+
60
+ export function classifySshFailure(error) {
61
+ const text = `${error?.stderr || ''}\n${error?.stdout || ''}`;
62
+ if (error?.code === 'ENOENT') return 'ssh_missing';
63
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Offending .* key/i.test(text)) return 'key_changed';
64
+ if (/Host key verification failed|No .* host key is known/i.test(text)) return 'key_unknown';
65
+ if (/UNPROTECTED PRIVATE KEY|bad permissions|Permissions .* too open/i.test(text)) return 'key_permissions';
66
+ if (/Certificate invalid|certificate has expired|certificate is not yet valid/i.test(text)) return 'ssh_certificate';
67
+ if (/sign_and_send_pubkey.*signing failed|agent refused operation/i.test(text)) return 'ssh_agent';
68
+ if (/Load key .* (?:Permission denied|invalid format)|no such identity|Identity file .* not accessible/i.test(text)) return 'key_missing';
69
+ if (/Permission denied|Too many authentication failures|Authentication failed/i.test(text)) return 'ssh_auth';
70
+ if (/Could not resolve hostname|Name or service not known|Temporary failure in name resolution/i.test(text)) return 'dns';
71
+ if (/Connection refused/i.test(text)) return 'refused';
72
+ if (/No route to host|Network is unreachable/i.test(text)) return 'route';
73
+ if (/timed out|timeout/i.test(text) || error?.killed) return 'timeout';
74
+ if (/administratively prohibited|remote port forwarding failed/i.test(text)) return 'ssh_forward';
75
+ if (/Bad configuration|Bad port|unsupported option|Bad owner or permissions on/i.test(text)) return 'ssh_config';
76
+ if (/Connection reset|Connection closed|kex_exchange_identification/i.test(text)) return 'reset';
77
+ if (text.includes('SIMY_CLI_MISSING')) return 'cli_missing';
78
+ return 'unknown';
79
+ }
80
+
81
+ export function classifyWebFailure(result) {
82
+ const error = String(result.stderr || '');
83
+ if (result.missing) return 'curl_missing';
84
+ if (result.exitCode) {
85
+ if (/Unsupported proxy syntax|Unsupported proxy|Malformed.*proxy/i.test(error)) return 'proxy_config';
86
+ if (result.exitCode === 5) return 'proxy_dns';
87
+ if (result.exitCode === 6) return 'dns';
88
+ if (result.exitCode === 28) return 'timeout';
89
+ if (result.exitCode === 58) return 'tls_client';
90
+ if ([60, 77, 83, 90].includes(result.exitCode)) {
91
+ if (/expired|not yet valid/i.test(error)) return 'tls_expired';
92
+ if (/no alternative certificate subject|does not match|subject name/i.test(error)) return 'tls_name';
93
+ return 'tls_trust';
94
+ }
95
+ if (result.exitCode === 35) return 'tls';
96
+ if (result.exitCode === 97 || result.connectStatus >= 400) return result.connectStatus === 407 ? 'proxy_auth' : 'proxy_connect';
97
+ if (/Connection refused|Couldn't connect to server/i.test(error) && result.exitCode === 7) return /Connection refused/i.test(error) ? 'refused' : 'unknown';
98
+ if (/No route to host|Network is unreachable/i.test(error)) return 'route';
99
+ if ([52, 55, 56].includes(result.exitCode)) return 'reset';
100
+ return 'unknown';
101
+ }
102
+ const status = result.httpStatus;
103
+ if (status >= 200 && status < 300) return null;
104
+ if (status === 401) return 'auth';
105
+ if (status === 403) return 'permission';
106
+ if (status === 407) return 'proxy_auth';
107
+ if (status === 404) return 'endpoint';
108
+ if (status === 405 || status === 501) return 'method';
109
+ if (status === 429) return 'rate_limit';
110
+ if (status >= 500) return 'service';
111
+ if (status >= 300 && status < 400) return 'redirect';
112
+ return 'unknown';
113
+ }
114
+
115
+ export function parseConnectionTarget(value = 'simy', webOrigin = resolveWebOrigin()) {
116
+ if (value === 'simy') return { kind: 'simy', url: new URL(webOrigin), display: 'SIMY' };
117
+ if (['provider:codex', 'provider:claude'].includes(value)) return { kind: 'provider', provider: value.slice(9), display: value };
118
+ if (/^https?:\/\//i.test(value)) {
119
+ const url = new URL(value);
120
+ if (url.username || url.password || /[\x00-\x20\x7f]/.test(value)) throw new Error('ログイン名やパスワードを含まないWebアドレスを指定してください。');
121
+ url.hash = '';
122
+ return { kind: 'web', url, display: clean(url.origin) };
123
+ }
124
+ const alias = value.replace(/^ssh:/, '');
125
+ if (!aliasPattern.test(alias)) throw new Error('接続先を確認してください。SIMYを調べるなら simy connections recover、別のパソコンを調べるなら simy connections で一覧を確認してください。');
126
+ return { kind: 'ssh', alias, display: alias };
127
+ }
128
+
129
+ export function networkContext(env = process.env, interfaces = networkInterfaces()) {
130
+ return {
131
+ activeInterfaceCount: Object.values(interfaces).filter(addresses => addresses?.some(address => !address.internal)).length,
132
+ vpnInterfaces: Object.entries(interfaces).filter(([name, addresses]) => /^(tun|tap|utun|wg|tailscale|ppp)/i.test(name) && addresses?.some(a => !a.internal)).map(([name]) => clean(name)),
133
+ proxyConfigured: Boolean(env.https_proxy || env.HTTPS_PROXY || env.http_proxy || env.HTTP_PROXY || env.all_proxy || env.ALL_PROXY),
134
+ noProxyConfigured: Boolean(env.no_proxy || env.NO_PROXY),
135
+ customCaConfigured: Boolean(env.CURL_CA_BUNDLE || env.SSL_CERT_FILE || env.SSL_CERT_DIR),
136
+ };
137
+ }
138
+
139
+ // curl applies the user's proxy/NO_PROXY and CA configuration. Credentials and
140
+ // query strings travel over stdin, not child argv; redirects are never followed.
141
+ export function probeWeb(url, { token, env = process.env, spawnImpl = spawn, timeoutMs = 10_000 } = {}) {
142
+ if (token && /[\r\n\x00]/.test(token)) throw new Error('APIトークンに改行を含めることはできません。');
143
+ const configQuote = value => String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"');
144
+ const config = `url = "${configQuote(url.href)}"\n` + (token ? `header = "Authorization: Bearer ${configQuote(token)}"\n` : '');
145
+ return new Promise(resolve => {
146
+ let done = false, stdout = '', stderr = '', timer;
147
+ const finish = result => { if (done) return; done = true; clearTimeout(timer); resolve(result); };
148
+ const child = spawnImpl('curl', ['--disable', '--config', '-', '--head', '--silent', '--show-error',
149
+ '--proto', '=http,https', '--connect-timeout', '5', '--max-time', String(timeoutMs / 1000), '--max-redirs', '0',
150
+ '--output', process.platform === 'win32' ? 'NUL' : '/dev/null', '--write-out', '%{http_code}\n%{http_connect}\n%{ssl_verify_result}\n'],
151
+ { stdio: ['pipe', 'pipe', 'pipe'], shell: false, env: { ...env, ...(env.HTTP_PROXY && !env.http_proxy ? { http_proxy: env.HTTP_PROXY } : {}) }, windowsHide: true });
152
+ timer = setTimeout(() => { child.kill(); finish({ exitCode: 28, httpStatus: 0, stderr: '' }); }, timeoutMs + 1000);
153
+ child.on('error', error => finish({ exitCode: 1, missing: error.code === 'ENOENT', httpStatus: 0, stderr: '' }));
154
+ child.stdin.on('error', () => {});
155
+ child.stdout.on('data', data => { stdout = (stdout + data).slice(-4096); });
156
+ child.stderr.on('data', data => { stderr = (stderr + data).slice(-8192); });
157
+ child.on('close', code => {
158
+ const [status, connect, verify] = stdout.trim().split(/\r?\n/).map(Number);
159
+ finish({ exitCode: Number.isInteger(code) ? code : 1, httpStatus: status || 0, connectStatus: connect || 0, tlsVerifyResult: verify, stderr });
160
+ });
161
+ child.stdin.end(config);
162
+ });
163
+ }
164
+
165
+ async function dnsCheck(host, lookupImpl) {
166
+ let timer;
167
+ try {
168
+ await Promise.race([lookupImpl(host.replace(/^\[|\]$/g, '')), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 3000); })]);
169
+ return check('dns', 'passed', 'dns_resolved', '接続先名を解決できました');
170
+ } catch { return issueFor('dns', 'dns'); }
171
+ finally { clearTimeout(timer); }
172
+ }
173
+
174
+ export async function diagnoseConnection(value, { webOrigin = resolveWebOrigin(), root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
175
+ env = process.env, tokenEnv, executeImpl = execute, probeWebImpl = probeWeb, lookupImpl = lookup,
176
+ inspectProvider = inspectProviderStatus, readLinks = readProviderLinks, readSessionImpl = readSession,
177
+ interfaces = networkInterfaces(), now = Date.now() } = {}) {
178
+ const target = parseConnectionTarget(value, webOrigin);
179
+ if (tokenEnv && (target.kind !== 'web' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokenEnv) || !env[tokenEnv])) throw new Error('接続キーの指定を読み取れませんでした。--token-env は外部Webサービス用です。接続キーを保存した設定の名前を指定してください。分からない場合はサービスの担当者に確認してください。');
180
+ if (tokenEnv && target.url.protocol !== 'https:' && !['localhost', '127.0.0.1', '[::1]'].includes(target.url.hostname)) throw new Error('接続キーを送るときは、https:// で始まる安全なWebアドレス(HTTPS)を指定してください。');
181
+ const report = { schemaVersion: 1, target: { kind: target.kind, display: target.display }, status: 'unverified', checks: [], context: networkContext(env, interfaces), actions: [] };
182
+ const addAction = (id, title, instructions, command) => report.actions.push({ id, title, instructions, ...(command ? { command } : {}) });
183
+ if (target.kind === 'ssh') {
184
+ let config;
185
+ try {
186
+ const resolved = await executeImpl('ssh', ['-G', '--', target.alias], { timeout: 5000, maxBuffer: 256 * 1024, env });
187
+ const fields = new Map(resolved.stdout.split(/\r?\n/).map(line => { const at = line.indexOf(' '); return [line.slice(0, at), line.slice(at + 1)]; }));
188
+ config = { hostname: fields.get('hostname'), port: Number(fields.get('port')), jump: fields.get('proxyjump'), proxy: fields.get('proxycommand') };
189
+ report.checks.push(check('ssh_config', 'passed', 'ssh_config_resolved', '現在のSSH設定を読み取りました'));
190
+ if (config.hostname && ![config.jump, config.proxy].some(v => v && v !== 'none')) {
191
+ report.checks.push(await dnsCheck(config.hostname, lookupImpl));
192
+ } else report.checks.push(check('dns', 'unknown', 'ssh_proxy_dns', '踏み台・プロキシ経由の名前解決はSSH接続で確認します'));
193
+ const result = await executeImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=8', '--', target.alias,
194
+ "sh -lc 'if command -v simy >/dev/null 2>&1; then printf SIMY_CLI_PRESENT; else printf SIMY_CLI_MISSING; exit 127; fi'"],
195
+ { timeout: 12_000, maxBuffer: 64 * 1024, env });
196
+ if (!result.stdout?.endsWith('SIMY_CLI_PRESENT')) throw new Error('unexpected probe');
197
+ // Successful SSH can resolve a name unavailable to the local DNS probe.
198
+ report.checks = report.checks.filter(c => !(c.layer === 'dns' && c.state === 'failed'));
199
+ report.checks.push(check('ssh', 'passed', 'ssh_ready', 'SSH認証と相手のSIMY CLIを確認しました'));
200
+ report.checks.push(check('cli_auth', 'unknown', 'cli_auth_unverified', '相手のSIMYログインはCLIを開いて確認してください'));
201
+ addAction('open_cli', '相手のCLIを開く', '相手側のログイン・組織を確認して接続します。', `simy connections connect ${target.alias}`);
202
+ } catch (error) {
203
+ const code = classifySshFailure(error);
204
+ report.checks.push(issueFor(code, 'ssh'));
205
+ if (['ssh_auth', 'ssh_agent', 'key_missing', 'key_permissions'].includes(code)) {
206
+ addAction('ssh_login', 'SSHの対話認証を試す', 'パスフレーズや本人の操作が必要な鍵を使って再認証し、その後に再確認します。', `ssh -T -o StrictHostKeyChecking=yes -- ${target.alias} true`);
207
+ }
208
+ if (['timeout', 'route', 'refused'].includes(code)) addAction('reverse', '逆向き経路を利用する', '接続を開始できる側で相手のSSHエイリアスを指定します。', 'simy connections tunnel <相手のSSHエイリアス>');
209
+ }
210
+ } else if (target.kind === 'provider') {
211
+ const state = await inspectProvider(target.provider).catch(() => ({ state: 'unknown' }));
212
+ const links = await readLinks(root).catch(() => ({}));
213
+ if (state.state === 'ready') report.checks.push(check('provider_auth', 'passed', 'provider_ready', 'CLIのインストールと保存されたログインを確認しました'));
214
+ else if (state.state === 'signed_out') {
215
+ report.checks.push(issueFor('auth', 'provider_auth'));
216
+ addAction('provider_login', 'プロバイダーにログインする', 'プロバイダーの公式ログインを起動し、完了後に再確認します。', target.provider === 'codex' ? 'codex login' : 'claude auth login');
217
+ } else {
218
+ report.checks.push(check('provider_auth', 'failed', 'provider_unavailable', 'CLIが未インストール・更新待ち、またはログイン状態を確認できません'));
219
+ addAction('provider_setup', '公式CLIのインストール・更新を確認', 'プロバイダー公式のインストール手順でCLIを導入・更新し、PATHとログイン状態を確認してください。');
220
+ }
221
+ if (links[target.provider]?.connected === true) report.checks.push(check('provider_link', 'passed', 'provider_linked', 'SIMYとの連携は有効です'));
222
+ else {
223
+ report.checks.push(check('provider_link', 'failed', 'provider_disconnected', 'SIMYとの連携が無効、または設定を読み取れません'));
224
+ if (links[target.provider]?.connected === false && !links[target.provider]?.settingsError) addAction('provider_link', 'SIMYとの連携を有効にする', 'このプロバイダーをSIMYに再接続します。会話の実行許可は別途設定します。');
225
+ }
226
+ report.checks.push(check('provider_service', 'unknown', 'provider_api_unverified', 'API接続・モデルの利用権限はサービスのURLを指定して診断してください'));
227
+ } else {
228
+ report.checks.push(await dnsCheck(target.url.hostname, lookupImpl));
229
+ const web = await Promise.resolve().then(() => probeWebImpl(target.url, { token: tokenEnv ? env[tokenEnv] : undefined, env })).catch(() => ({ exitCode: 1, httpStatus: 0 }));
230
+ const code = classifyWebFailure(web);
231
+ if (!web.exitCode && web.httpStatus > 0) report.checks = report.checks.filter(c => !(c.layer === 'dns' && c.state === 'failed'));
232
+ if (!web.exitCode && web.httpStatus > 0) report.checks.push(check('http_transport', 'passed', 'http_reached', `HTTP応答を受信しました(${web.httpStatus})`));
233
+ report.checks.push(code ? issueFor(code, 'web', ['method', 'redirect', 'unknown'].includes(code) ? 'unknown' : 'failed') : check('web', 'passed', 'web_ready', '指定URLへのHEADリクエストが成功しました'));
234
+ if (target.kind === 'simy') {
235
+ const session = await readSessionImpl(webOrigin, root).catch(() => null);
236
+ if (isSessionValid(session, now, webOrigin)) report.checks.push(check('simy_auth', 'passed', 'session_current', '保存されたSIMY認証の期限は有効です(サーバー側の失効は未確認)'));
237
+ else {
238
+ report.checks.push(issueFor('auth', 'simy_auth'));
239
+ addAction('simy_login', 'SIMYの接続・ログインを開く', '別のターミナルで下のコマンドを実行し、表示されたブラウザで認証を完了してから戻って再確認してください。', `simy --daemon --host ${shellQuote(webOrigin)}`);
240
+ }
241
+ } else if (code === 'auth') addAction('web_login', 'サービスのログインページを開く', 'サービス側でログイン・APIキー・OAuthを更新して再確認します。ブラウザのログインとAPIキーは別の認証です。');
242
+ }
243
+ for (const item of report.checks.filter(c => c.instruction)) addAction(`resolve_${item.code}`, item.message, item.instruction);
244
+ if (report.checks.some(c => ['timeout', 'route', 'refused', 'dns'].includes(c.code))) {
245
+ addAction('network_settings', 'VPN・経路・ファイアウォールを確認', report.context.vpnInterfaces.length
246
+ ? 'VPN用と思われるネットワークインターフェースはあります。対象のVPNへのログインと到達先ルート、相手側の受信規則を確認してください。'
247
+ : 'VPN接続・OSの経路設定・相手の稼働状態・必要なポートの受信規則を確認してください。VPNインターフェースがないことだけでVPN障害とは断定できません。');
248
+ }
249
+ report.status = report.checks.some(c => c.state === 'failed') ? 'needs_action'
250
+ : report.checks.some(c => ['web_ready', 'ssh_ready', 'provider_ready'].includes(c.code)) ? 'reachable' : 'unverified';
251
+ addAction('retry', '修復後に再確認', '設定・認証の更新後、同じ接続先を再診断します。');
252
+ return report;
253
+ }