@awak-app/simy-cli 0.7.21 → 0.7.22

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 CHANGED
@@ -60,6 +60,22 @@ Provider login is separate from model access; unverified model access remains
60
60
  files scoped to the Web/account/organization, rotate each writer's four 64 KiB
61
61
  segments, and keep lifetime failure counts after event rotation.
62
62
 
63
+ Each AI app card separates **SIMY connection: on / stopped** from CLI, sign-in
64
+ and plugin health. Disconnecting collapses those details and explains that SIMY
65
+ monitoring and automatic continuation are stopped. Details can still be opened;
66
+ reconnecting restores the item statuses. Plugin inspection failure never changes
67
+ the connection setting or hides its disconnect action.
68
+
69
+ Connection settings show **Repair automatically** beside a failed, missing or
70
+ disabled SIMY plugin. Clicking it rechecks only that provider with a longer,
71
+ bounded timeout; a missing or disabled managed plugin is installed or enabled,
72
+ then its actual inventory is checked again. Success is shown only after that
73
+ inventory confirms the plugin is enabled. Repair progress and failure reasons
74
+ are displayed in the card; safe reason codes and elapsed times are saved in
75
+ `$SIMY_HOME/logs/desktop-host.log`. Repair preserves SIMY connection consent,
76
+ provider sign-in and native hook permissions. Project/managed settings and
77
+ conflicting plugin sources require review instead of being overwritten.
78
+
63
79
  The desktop host continues sampling connection and access while optional
64
80
  conversation metadata is pending. It retains the last completed conversation
65
81
  metadata only within the same verified scope, clears it after access or account
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.21",
3
+ "version": "0.7.22",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,7 @@
42
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",
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
- "check:sqm": "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",
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",
46
46
  "check:real-provider-syntax": "node --check ./scripts/real-codex-probe.js && node --check ./scripts/real-claude-probe.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/claude-parity-runtime-e2e.js && node --check ./scripts/claude-plugin-runtime-e2e.js && node --check ./scripts/codex-plugin-runtime-e2e.js",
47
47
  "check:syntax": "node --check ./src/purpose-local-proof.js && node --check ./src/device-signing-identity.js && node --check ./src/index.js && node --check ./src/install-update.js && node --check ./src/agent.js && node --check ./src/connection-discovery.js && node --check ./src/connection-diagnostics.js && node --check ./src/launch-authorization.js && node --check ./src/codex-plugins/simy/hooks/connection-reminder.mjs && node --check ./src/codex-plugins/simy/hooks/dev-work.mjs && node --check ./src/dev-work-events.js && node --check ./src/browser.js && node --check ./src/web-api.js && node --check ./src/durable-local-task-worker.js && node --check ./src/durable-local-task-steps-contract.js && node --check ./src/bounded-local-task-subtasks-contract.js && node --check ./src/bounded-local-task-subtask-pool.js && node --check ./src/local-task-artifact-contract.js && node --check ./src/execution-capability-contract.js && node --check ./src/shutdown.js && node --check ./src/run-registry.js && node --check ./src/direct-executor.js && node --check ./src/execution-guardrail.js && node --check ./src/backend-executable.js && node --check ./src/login-shell-path.js && node --check ./src/hook-runtime.js && node --check ./src/provider-stream.js && node --check ./src/console/index.js && node --check ./src/console/app.js && node --check ./src/console/commands.js && node --check ./src/local-attachments.js && node --check ./src/repository-inventory.js && node --check ./src/session-store.js && node --check ./src/web-origin.js && node --check ./src/workspace-context.js && node --check ./src/orchestrator/index.js && node --check ./src/orchestrator/shared.js && node --check ./src/orchestrator/recovery.js && node --check ./src/orchestrator/risk.js && node --check ./src/orchestrator/contract.js && node --check ./src/orchestrator/completion-contract.js && node --check ./src/orchestrator/pr-preparation.js && node --check ./src/orchestrator/minimum-completion.js && node --check ./src/orchestrator/delivery/scripts/check-minimum-completion.mjs && node --check ./src/orchestrator/instruction.js && node --check ./src/orchestrator/problem-solving.js && node --check ./src/orchestrator/budget.js && node --check ./src/orchestrator/execution-io.js && node --check ./src/orchestrator/presentation.js && node --check ./src/orchestrator/result.js && node --check ./src/orchestrator/retry.js && node --check ./src/orchestrator/evidence.js && node --check ./src/orchestrator/independent-audit.js && node --check ./src/orchestrator/audit.js && node --check ./src/orchestrator/loop.js && node --check ./src/runner.js && node --check ./src/coding-run-store.js && node --check ./scripts/fixtures/completion-contract-fixture.js && node --check ./scripts/fixtures/fake-coding-backend.js && node --check ./scripts/e2e-terminal-evidence.js && node --check ./scripts/audit-gate-e2e.js && node --check ./scripts/completion-contract-e2e.js && node --check ./scripts/retry-circuit-e2e.js && node --check ./scripts/follow-up-charter-e2e.js && node --check ./scripts/task-routing-e2e.js && node --check ./scripts/executor-version-preflight-e2e.js && node --check ./scripts/blocked-recovery-e2e.js && node --check ./scripts/process-localization-e2e.js && node --check ./scripts/hypothesis-retries-e2e.js && node --check ./scripts/budget-enforcement-e2e.js && node --check ./scripts/local-task-default-workspace-e2e.js && node --check ./scripts/local-task-idempotency-e2e.js && node --check ./scripts/console-e2e-fixture.js && node --check ./scripts/console-e2e.js && node --check ./scripts/console-recovery-e2e-fixture.js && node --check ./scripts/console-recovery-e2e.js && node --check ./scripts/real-provider-console-fixture.js && node --check ./scripts/real-provider-console-e2e.js && node --check ./scripts/fullstack-cli-smoke.js && node --check ./scripts/check-package-contents.js && node --check ./src/codex-plugins/simy/hooks/delivery-loop.mjs && node --check ./src/delivery-hooks/command.js && node --check ./src/delivery-hooks/desktop.js && node --check ./src/delivery-hooks/supervisor.js && node --check ./src/delivery-hooks/recovery.js && node --check ./src/delivery-hooks/supervisor-owner.js && node --check ./src/delivery-hooks/supervisor-watchdog.js && node --check ./src/delivery-hooks/supervisor-service.js && node --check ./scripts/supervisor-watchdog-live-e2e.js && node --check ./src/codex-plugins/simy/hooks/recovery-incident.mjs && node --check ./src/session-supervision/automatic-continuation.js && node --check ./src/session-supervision/stop-notifications.js",
48
48
  "check:package": "node ./scripts/check-package-contents.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.21";
17
+ export const CLI_VERSION = "0.7.22";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -15,6 +15,7 @@ import { randomUUID } from "node:crypto";
15
15
  import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
16
16
  import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
17
17
  import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-plugin-status.js';
18
+ import { repairProviderPlugin } from './sqm/provider-plugin-repair.js';
18
19
  import { openCodexAppServer } from './session-supervision/codex-app-server.js';
19
20
  import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
20
21
  import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
@@ -503,6 +504,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
503
504
  const changeProviderLink = async (provider, connected) => {
504
505
  if (!PROVIDER_LINKS.includes(provider)) throw new Error("Unsupported SIMY provider connection.");
505
506
  if (providerLinkBusy.has(provider)) throw new Error("SIMY connection is already changing.");
507
+ providerRepairControllers.get(provider)?.abort();
506
508
  providerLinkBusy.add(provider);
507
509
  providerLinkFeedback[provider] = { state: connected ? "connecting" : "disconnecting" };
508
510
  let phase = "read_settings";
@@ -575,6 +577,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
575
577
  const providerAuthBusy = new Set();
576
578
  let pluginInstallationActive = false, pluginSetupEpoch = 0;
577
579
  let providerPlugins = { codex: { state: 'checking' }, claude: { state: 'checking' } };
580
+ const providerRepairs = {}, providerRepairControllers = new Map(), providerRepairJobs = new Set();
581
+ const providerPluginEpochs = { codex: 0, claude: 0 };
582
+ const pluginDiagnostic = ({ provider, reason, elapsedMs }) => {
583
+ diagnostics.write(`provider plugin inspection provider=${provider} reason=${reason} elapsedMs=${elapsedMs}`);
584
+ };
578
585
  let pluginInspection = null, lastPluginInspectionAt = -Infinity;
579
586
  let pluginInspectionAbort = new AbortController();
580
587
  const refreshProviderPlugins = () => {
@@ -582,9 +589,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
582
589
  if (pluginInspection) return pluginInspection;
583
590
  if (pluginInspectionAbort.signal.aborted) pluginInspectionAbort = new AbortController();
584
591
  lastPluginInspectionAt = Date.now();
585
- pluginInspection = Promise.resolve().then(() => (dependencies.inspectProviderPlugins || inspectProviderPlugins)({ signal: pluginInspectionAbort.signal }))
586
- .then(value => { providerPlugins = value; })
587
- .catch(() => { providerPlugins = { codex: unavailablePluginStatus(), claude: unavailablePluginStatus() }; })
592
+ const epochs = { ...providerPluginEpochs };
593
+ const apply = value => {
594
+ for (const provider of PROVIDER_LINKS) {
595
+ if (!providerRepairControllers.has(provider) && epochs[provider] === providerPluginEpochs[provider]) {
596
+ providerPlugins[provider] = value[provider] || unavailablePluginStatus();
597
+ }
598
+ }
599
+ };
600
+ pluginInspection = Promise.resolve().then(() => (dependencies.inspectProviderPlugins || inspectProviderPlugins)({ signal: pluginInspectionAbort.signal, details: true, onDiagnostic: pluginDiagnostic }))
601
+ .then(apply)
602
+ .catch(() => apply({ codex: unavailablePluginStatus(), claude: unavailablePluginStatus() }))
588
603
  .finally(() => { pluginInspection = null; if (!closed && !relay) void publish().catch(() => {}); });
589
604
  return pluginInspection;
590
605
  };
@@ -608,6 +623,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
608
623
  }]));
609
624
  status.pluginSetup = { ...pluginSetup };
610
625
  status.providerPlugins = structuredClone(providerPlugins);
626
+ status.providerRepairs = structuredClone(providerRepairs);
611
627
  for (const provider of ["codex", "claude"]) {
612
628
  if (providerAuth[provider]?.state === "awaiting_sign_in" && !providerAuthBusy.has(provider) && status.providers?.[provider]?.state === "ready") delete providerAuth[provider];
613
629
  }
@@ -804,6 +820,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
804
820
  clearInterval(timer);
805
821
  teamThreads?.close();
806
822
  pluginInspectionAbort.abort();
823
+ for (const controller of providerRepairControllers.values()) controller.abort();
807
824
  activeStatisticsRead?.controller.abort();
808
825
  discoveryStopped = true;
809
826
  encodedStatus = null; lastStatus = null;
@@ -812,6 +829,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
812
829
  // reader must never postpone cancellation of an owned Provider process.
813
830
  const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing, connectionPublishing,
814
831
  pluginInspection,
832
+ ...providerRepairJobs,
815
833
  remoteHosts?.close(), actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
816
834
  managedSupervisor.close(), recordReader.close(), stopWorkflows(),
817
835
  agent ? createAgentShutdown(agent)() : undefined]);
@@ -1163,7 +1181,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1163
1181
  }
1164
1182
 
1165
1183
  let commandProviderLink;
1166
- if (command.provider && !["refresh", "provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
1184
+ if (command.provider && !["provider_repair", "refresh", "provider_connect", "provider_disconnect", "delivery_stop", "delivery_pause", "delivery_reject", "session_untrack", "session_unmonitor"].includes(action) &&
1167
1185
  !(action === "session_set_auto_continue" && command.enabled === false)) {
1168
1186
  commandProviderLink = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
1169
1187
  }
@@ -1188,7 +1206,48 @@ export async function startDesktopHost({ input = process.stdin, output = process
1188
1206
  await remoteHosts.save({ alias: command.alias, label: command.label }, action === 'remote_host_remove');
1189
1207
  emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
1190
1208
  }
1191
- if (action === "provider_connect" || action === "provider_disconnect") {
1209
+ if (action === 'provider_repair') {
1210
+ const provider = command.provider;
1211
+ if (!PROVIDER_LINKS.includes(provider)) throw new Error('Unsupported provider.');
1212
+ if (providerRepairControllers.has(provider) || providerLinkBusy.has(provider)) throw new Error('Provider repair is already running.');
1213
+ const controller = new AbortController();
1214
+ providerRepairControllers.set(provider, controller);
1215
+ providerPluginEpochs[provider]++;
1216
+ providerRepairs[provider] = { state: 'repairing' };
1217
+ const beganAt = Date.now();
1218
+ let repairJob;
1219
+ try {
1220
+ await publish();
1221
+ repairJob = Promise.resolve().then(() => (dependencies.repairProviderPlugin || repairProviderPlugin)({
1222
+ provider, signal: controller.signal, onDiagnostic: pluginDiagnostic,
1223
+ authorize: async () => {
1224
+ if (closed || relay || controller.signal.aborted) throw Object.assign(new Error('Repair cancelled.'), { reason: 'cancelled' });
1225
+ await authorizeWork(commandScope);
1226
+ },
1227
+ }));
1228
+ providerRepairJobs.add(repairJob);
1229
+ const result = await repairJob;
1230
+ if (closed || controller.signal.aborted) throw Object.assign(new Error('Repair cancelled.'), { reason: 'cancelled' });
1231
+ await authorizeWork(commandScope);
1232
+ if (result?.plugin?.state !== 'enabled') throw Object.assign(new Error('Repair was not verified.'), { reason: 'verification_failed' });
1233
+ providerPlugins[provider] = result.plugin;
1234
+ providerRepairs[provider] = { state: 'repaired' };
1235
+ diagnostics.write(`provider plugin repair provider=${provider} result=repaired elapsedMs=${Date.now() - beganAt}`);
1236
+ } catch (error) {
1237
+ const reasons = ['timeout', 'cli_missing', 'invalid_inventory', 'command_failed', 'verification_failed',
1238
+ 'scope_requires_attention', 'source_conflict', 'busy', 'permission_denied', 'cancelled'];
1239
+ const reason = reasons.includes(error?.reason) ? error.reason : 'command_failed';
1240
+ providerRepairs[provider] = { state: 'failed', reason };
1241
+ diagnostics.write(`provider plugin repair provider=${provider} result=failed reason=${reason} elapsedMs=${Date.now() - beganAt}`);
1242
+ throw error;
1243
+ } finally {
1244
+ providerRepairJobs.delete(repairJob);
1245
+ providerRepairControllers.delete(provider);
1246
+ await publish();
1247
+ }
1248
+ emit({ type: 'result', id: command.id, ok: true });
1249
+ return;
1250
+ } else if (action === "provider_connect" || action === "provider_disconnect") {
1192
1251
  await changeProviderLink(command.provider, action === "provider_connect");
1193
1252
  emit({ type: "result", id: command.id, ok: true });
1194
1253
  return;
@@ -1598,7 +1657,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1598
1657
  void execute();
1599
1658
  } else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
1600
1659
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
1601
- } else if (/^(codex|claude)_login$/.test(command.action || "") || ["provider_connect", "provider_disconnect"].includes(command.action)) {
1660
+ } else if (/^(codex|claude)_login$/.test(command.action || "") || ["provider_repair", "provider_connect", "provider_disconnect"].includes(command.action)) {
1602
1661
  // Opening an auth terminal must not wait for an unrelated provider turn.
1603
1662
  // Per-provider busy guards prevent overlapping connection changes.
1604
1663
  void execute();
@@ -100,14 +100,16 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
100
100
  };
101
101
  }
102
102
 
103
- export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS) } = {}) {
103
+ export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS), migrateLegacyGate = true } = {}) {
104
104
  const requested = [...new Set(platforms)];
105
105
  if (!requested.length || requested.some((platform) => !Object.hasOwn(TARGETS, platform))) {
106
106
  throw new Error("platforms must contain codex and/or claude");
107
107
  }
108
108
  if (requested.includes("codex")) await installCodexPlugin({ home });
109
109
  if (requested.includes("claude")) await installClaudePlugin({ home });
110
- await retireLegacySqmGate({ home });
110
+ // Explicit single-provider repair must not migrate another provider's hooks.
111
+ // Ordinary setup and workflow updates retain the existing migration behavior.
112
+ if (migrateLegacyGate) await retireLegacySqmGate({ home });
111
113
  for (const platform of requested) {
112
114
  await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
113
115
  }
@@ -16,7 +16,7 @@ export function runProviderCommand(command, args, { env, cwd, timeout = 10_000,
16
16
  };
17
17
  const timer = setTimeout(() => {
18
18
  child.kill("SIGTERM");
19
- finish(new Error("Provider plugin command timed out."));
19
+ finish(Object.assign(new Error("Provider plugin command timed out."), { code: 'provider_timeout' }));
20
20
  }, timeout);
21
21
  timer.unref?.();
22
22
  const capture = (stream, append) => {
@@ -0,0 +1,105 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir, readFile, realpath, rm } from 'node:fs/promises';
4
+ import { resolveBackendExecutable } from '../backend-executable.js';
5
+ import { inspectProviderPlugins } from './provider-plugin-status.js';
6
+ import { runProviderCommand } from './provider-command.js';
7
+ import { inspectSqmAgentSkill, installSqmAgentSkill } from './agent-skill.js';
8
+ import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
9
+
10
+ const fail = reason => Object.assign(new Error('SIMY plugin repair could not be verified.'), { code: 'plugin_repair_failed', reason });
11
+
12
+ // Explicit user action only. Repair one provider, under the same lock as the
13
+ // background workflow updater. Never change SIMY consent, login or hook trust.
14
+ export async function repairProviderPlugin({ provider, home = homedir(),
15
+ codexHome = process.env.CODEX_HOME || join(home, '.codex'),
16
+ claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
17
+ signal, authorize = async () => {}, onDiagnostic = () => {},
18
+ inspect = inspectProviderPlugins, inspectSources = inspectSqmAgentSkill,
19
+ prepare = installSqmAgentSkill, resolveExecutable = resolveBackendExecutable,
20
+ run = runProviderCommand,
21
+ } = {}) {
22
+ if (!['codex', 'claude'].includes(provider)) throw fail('unsupported_provider');
23
+ const startedAt = Date.now();
24
+ const check = async () => {
25
+ if (signal?.aborted) throw fail('cancelled');
26
+ await authorize();
27
+ if (signal?.aborted) throw fail('cancelled');
28
+ };
29
+ const options = { home, codexHome, claudeHome, providers: [provider], details: true,
30
+ timeoutMs: 20_000, signal, onDiagnostic, resolveExecutable, run };
31
+ const inventory = async () => (await inspect(options))[provider];
32
+ const command = async args => {
33
+ await check();
34
+ const executable = await resolveExecutable(provider, { signal, timeoutMs: 5_000 });
35
+ if (!executable) throw fail('cli_missing');
36
+ return run(executable, args, { cwd: home, signal, timeout: 20_000, maxBuffer: 1024 * 1024,
37
+ env: { ...process.env, HOME: home, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) } });
38
+ };
39
+ let locked = false;
40
+ const lock = join(home, '.simy', 'integrations', 'workflow-update.lock');
41
+ try {
42
+ await check();
43
+ let current = await inventory();
44
+ if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
45
+ if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
46
+ if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
47
+ await mkdir(join(home, '.simy', 'integrations'), { recursive: true });
48
+ try { await mkdir(lock); locked = true; }
49
+ catch (error) { if (error.code === 'EEXIST') throw fail('busy'); throw error; }
50
+ await check();
51
+ // Reinspect after obtaining the lock; another updater may have finished.
52
+ current = await inventory();
53
+ if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
54
+ if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
55
+ if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
56
+ let sources = await inspectSources({ home });
57
+ if (provider === 'codex' && current.state === 'disabled') {
58
+ const payload = JSON.parse((await command(['plugin', 'list', '--json'])).stdout);
59
+ const entry = payload.installed?.find(item => item.pluginId === current.selector);
60
+ if (entry?.source?.source !== 'local' || !entry.source.path ||
61
+ await realpath(entry.source.path) !== await realpath(sources.codex_plugin.path)) throw fail('source_conflict');
62
+ const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
63
+ if (current.selector !== `simy@${marketplace.name}`) throw fail('source_conflict');
64
+ }
65
+ if (provider === 'claude') {
66
+ const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
67
+ if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
68
+ const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
69
+ if (existing && (existing.source !== 'directory' || !existing.path ||
70
+ await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
71
+ }
72
+ await check();
73
+ sources = await prepare({ home, platforms: [provider], migrateLegacyGate: false });
74
+ if (provider === 'codex') {
75
+ const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
76
+ if (!/^[A-Za-z0-9_-]+$/.test(marketplace.name || '')) throw fail('source_conflict');
77
+ const selector = `simy@${marketplace.name}`;
78
+ if (current.selector && current.selector !== selector) throw fail('source_conflict');
79
+ await command(['plugin', 'add', selector, '--json']);
80
+ } else {
81
+ // The managed marketplace installer preserves unrelated provider settings.
82
+ const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
83
+ if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
84
+ const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
85
+ if (existing && (existing.source !== 'directory' || !existing.path ||
86
+ await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
87
+ if (!existing) {
88
+ await command(['plugin', 'marketplace', 'add', sources.claude_plugin.marketplace_root]);
89
+ }
90
+ await command(['plugin', current.state === 'missing' ? 'install' : 'enable', CLAUDE_PLUGIN_SELECTOR, '--scope', 'user']);
91
+ }
92
+ await check();
93
+ const verified = await inventory();
94
+ if (verified?.state !== 'enabled') throw fail(verified?.reason || 'verification_failed');
95
+ return { state: 'repaired', plugin: verified };
96
+ } catch (error) {
97
+ const reason = error?.reason || (signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
98
+ error instanceof SyntaxError ? 'invalid_inventory' :
99
+ ['EACCES', 'EPERM', 'EROFS'].includes(error?.code) ? 'permission_denied' : 'command_failed');
100
+ onDiagnostic({ provider, reason, elapsedMs: Date.now() - startedAt });
101
+ throw fail(reason);
102
+ } finally {
103
+ if (locked) await rm(lock, { recursive: true, force: true });
104
+ }
105
+ }
@@ -14,13 +14,22 @@ export async function inspectProviderPlugins({ home = homedir(),
14
14
  codexHome = process.env.CODEX_HOME || join(home, '.codex'),
15
15
  claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
16
16
  resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
17
- timeoutMs = 5_000, signal,
17
+ timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
18
+ onDiagnostic = () => {},
18
19
  } = {}) {
20
+ if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
19
21
  const inspect = async provider => {
22
+ const startedAt = Date.now();
23
+ const unavailable = reason => {
24
+ const elapsedMs = Date.now() - startedAt;
25
+ onDiagnostic({ provider, reason, elapsedMs });
26
+ return { ...unavailablePluginStatus(), ...(details ? { reason, elapsedMs } : {}) };
27
+ };
20
28
  try {
21
- if (signal?.aborted) return unavailablePluginStatus();
29
+ if (signal?.aborted) return unavailable('cancelled');
22
30
  const executable = await resolveExecutable(provider, { signal, timeoutMs });
23
- if (!executable || signal?.aborted) return unavailablePluginStatus();
31
+ if (signal?.aborted) return unavailable('cancelled');
32
+ if (!executable) return unavailable('cli_missing');
24
33
  const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
25
34
  timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
26
35
  env: { ...process.env, HOME: home,
@@ -28,7 +37,7 @@ export async function inspectProviderPlugins({ home = homedir(),
28
37
  });
29
38
  const payload = JSON.parse(stdout);
30
39
  const entries = provider === 'codex' ? payload?.installed : payload;
31
- if (!Array.isArray(entries)) return unavailablePluginStatus();
40
+ if (!Array.isArray(entries)) return unavailable('invalid_inventory');
32
41
  const matches = entries.filter(entry => provider === 'codex'
33
42
  ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
34
43
  : entry?.id === CLAUDE_PLUGIN_SELECTOR);
@@ -42,11 +51,14 @@ export async function inspectProviderPlugins({ home = homedir(),
42
51
  const entry = matches[0];
43
52
  if (!entry || provider === 'codex' && entry.installed === false) return { state: 'missing', installed: false, enabled: false };
44
53
  if (typeof entry.enabled !== 'boolean' || provider === 'codex' && entry.installed !== true ||
45
- provider === 'claude' && !scopes.includes(entry.scope)) return unavailablePluginStatus();
54
+ provider === 'claude' && !scopes.includes(entry.scope)) return unavailable('invalid_inventory');
46
55
  return { state: entry.enabled ? 'enabled' : 'disabled', installed: true, enabled: entry.enabled,
56
+ ...(details ? { selector: provider === 'codex' ? entry.pluginId : entry.id } : {}),
47
57
  ...(provider === 'claude' ? { scope: entry.scope, enablementContext: 'home' } : {}) };
48
- } catch { return unavailablePluginStatus(); }
58
+ } catch (error) {
59
+ return unavailable(signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
60
+ error instanceof SyntaxError ? 'invalid_inventory' : 'command_failed');
61
+ }
49
62
  };
50
- const [codex, claude] = await Promise.all([inspect('codex'), inspect('claude')]);
51
- return { codex, claude };
63
+ return Object.fromEntries(await Promise.all(providers.map(async provider => [provider, await inspect(provider)])));
52
64
  }