@awak-app/simy-cli 0.6.5 → 0.6.7

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
@@ -218,8 +218,13 @@ Codex or Claude Code conversation. Install the plugin, review and enable SIMY ho
218
218
  with `/hooks`, then request `/simy:delivery-loop` for the task. Installing or updating
219
219
  the plugin never approves hook trust. SIMY's Connection & settings panel links to
220
220
  Codex settings and the Claude setup guide and shows observed hook activity and
221
- blocked/recovery states. The legacy `agentic-delivery-loop` skill remains retired;
222
- the Web/CLI executor is unchanged. See [hook delivery behavior and limits](docs/delivery-hooks.md).
221
+ blocked/recovery states.
222
+ The Connection tab also checks each provider's actual SIMY plugin inventory and
223
+ shows installed/enabled, installed/disabled, missing, or unavailable separately
224
+ from app sign-in. Claude enablement is checked from the home folder; project
225
+ settings can differ, and project-scoped installations are labeled explicitly.
226
+ The legacy `agentic-delivery-loop` skill remains retired; the Web/CLI executor is
227
+ unchanged. See [hook delivery behavior and limits](docs/delivery-hooks.md).
223
228
  `--no-auto-update` or `SIMY_AUTO_UPDATE=0` disables these background updates too;
224
229
  `--no-workflow-prompt` only suppresses the initial installation prompt.
225
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.5";
16
+ export const CLI_VERSION = "0.6.7";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -1,5 +1,6 @@
1
1
  import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
2
2
  import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
3
+ import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-plugin-status.js';
3
4
  import { openCodexAppServer } from './session-supervision/codex-app-server.js';
4
5
  import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
5
6
  import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
@@ -196,12 +197,27 @@ export async function startDesktopHost({ input = process.stdin, output = process
196
197
  onChange: () => { if (!closed && !relay) refreshSessions(true); },
197
198
  });
198
199
  let pluginSetup = { state: "unknown" };
200
+ let providerPlugins = { codex: { state: 'checking' }, claude: { state: 'checking' } };
201
+ let pluginInspection = null, lastPluginInspectionAt = -Infinity;
202
+ let pluginInspectionAbort = new AbortController();
203
+ const refreshProviderPlugins = () => {
204
+ if (closed || relay) return Promise.resolve();
205
+ if (pluginInspection) return pluginInspection;
206
+ if (pluginInspectionAbort.signal.aborted) pluginInspectionAbort = new AbortController();
207
+ lastPluginInspectionAt = Date.now();
208
+ pluginInspection = Promise.resolve().then(() => (dependencies.inspectProviderPlugins || inspectProviderPlugins)({ signal: pluginInspectionAbort.signal }))
209
+ .then(value => { providerPlugins = value; })
210
+ .catch(() => { providerPlugins = { codex: unavailablePluginStatus(), claude: unavailablePluginStatus() }; })
211
+ .finally(() => { pluginInspection = null; if (!closed && !relay) void publish().catch(() => {}); });
212
+ return pluginInspection;
213
+ };
199
214
  const inspectPlugins = dependencies.inspectPlugins || inspectSqmAgentSkill;
200
215
  const refreshPlugins = async () => {
201
216
  try {
202
217
  const status = await inspectPlugins();
203
218
  pluginSetup = { state: status.installed ? "ready" : "missing", refreshPending: status.installed && pluginSetup.refreshPending === true };
204
219
  } catch { pluginSetup = { state: "error" }; }
220
+ void refreshProviderPlugins();
205
221
  };
206
222
  const packageInfo = dependencies.packageInfo || await readCliPackageInfo();
207
223
  diagnostics.write(`desktop starting version=${packageInfo.version} pid=${process.pid} role=${candidate ? "update-candidate" : "supervisor"}`);
@@ -224,6 +240,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
224
240
  }
225
241
  return scheduledPublish.promise;
226
242
  };
243
+ let lastDeadlineFailureAt = -Infinity;
227
244
  const projectStatus = () => {
228
245
  if (closed || relay || !agent) return Promise.resolve();
229
246
  if (publishing) { publishAgain = true; return publishing; }
@@ -233,6 +250,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
233
250
  status.access = currentAccess;
234
251
  const requestedScope = accessScope(currentAccess);
235
252
  status.pluginSetup = { ...pluginSetup };
253
+ status.providerPlugins = structuredClone(providerPlugins);
236
254
  if (!desktopAccessReady(currentAccess)) {
237
255
  for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
238
256
  if (currentAccess.state !== 'checking') await analysisManager.update([]);
@@ -258,9 +276,18 @@ export async function startDesktopHost({ input = process.stdin, output = process
258
276
  for (const session of sessions) {
259
277
  session.statistics = statisticsCache.get(`${session.provider}:${session.session}`) || unavailableStatistics();
260
278
  session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
261
- const context = await actionAssociations.deadlineContext?.(session, projection);
262
- if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
263
- else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
279
+ session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
280
+ try {
281
+ const context = await actionAssociations.deadlineContext?.(session, projection);
282
+ if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
283
+ } catch {
284
+ // Optional per-conversation metadata must not stop catalog/connection
285
+ // heartbeats. Keep the fallback and bound diagnostics across refreshes.
286
+ if (Date.now() - lastDeadlineFailureAt >= 30_000) {
287
+ lastDeadlineFailureAt = Date.now();
288
+ diagnostics.write('conversation deadline unavailable; desktop status retained');
289
+ }
290
+ }
264
291
  const run = runsBySession.get(`${session.provider}:${session.session}`);
265
292
  if (run) run.actionDeadline = session.actionDeadline;
266
293
  }
@@ -274,12 +301,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
274
301
  };
275
302
  const stopAgent = async () => {
276
303
  clearInterval(timer);
304
+ pluginInspectionAbort.abort();
277
305
  discoveryStopped = true;
278
306
  encodedStatus = null; lastStatus = null;
279
307
  if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
280
308
  // Start every cancellation before draining discovery/network work. A hung
281
309
  // reader must never postpone cancellation of an owned Provider process.
282
310
  const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing,
311
+ pluginInspection,
283
312
  actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
284
313
  managedSupervisor.close(), recordReader.close(), stopWorkflows(),
285
314
  agent ? createAgentShutdown(agent)() : undefined]);
@@ -406,6 +435,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
406
435
  tick(actionAssociations);
407
436
  tick(managedSupervisor);
408
437
  timer = setInterval(() => {
438
+ if (Date.now() - lastPluginInspectionAt >= 30_000) void refreshProviderPlugins();
409
439
  if (desktopAccessReady(currentAccess)) {
410
440
  tick(actionAssociations);
411
441
  tick(managedSupervisor);
@@ -75,6 +75,11 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
75
75
  const local = latest?.run?.managed?.actionAssociation;
76
76
  if (latest?.run?.managed?.taskId !== record.run.managed.taskId) return latest;
77
77
  latest.run.managed.actionAssociation = { ...local, authRevision: expected };
78
+ // Successful empty responses restore availability without erasing matches
79
+ // or pending work that still belongs to this authenticated scope.
80
+ if (local?.status === 'unavailable' && !local.binding && !local.job && !local.outbox) {
81
+ latest.run.managed.actionAssociation.status = 'waiting_boundary';
82
+ }
78
83
  if (!local?.job) delete latest.run.managed.actionAssociation.invalidated;
79
84
  return latest;
80
85
  });
@@ -218,6 +223,11 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
218
223
  if (record?.cwd !== candidate.cwd) return null;
219
224
  const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
220
225
  if (!expected || projection && auth !== expected || local?.authRevision !== expected || !(candidate.actionAssociation?.canCorrect || candidate.actionAssociation?.canReadBinding)) return null;
226
+ // Correction can be available before the first scoped binding exists.
227
+ // Such a conversation cannot yet address a durable deadline record.
228
+ if (typeof local.namespace !== 'string' || !local.namespace.trim() ||
229
+ typeof candidate.taskId !== 'string' || !candidate.taskId.trim() ||
230
+ (record.run?.managed?.taskId || record.associationTaskId) !== candidate.taskId) return null;
221
231
  const binding = candidate.actionAssociation.binding;
222
232
  const fingerprint = value => JSON.stringify([value?.revision, value?.action?.id, value?.action?.revision, value?.action?.dueAt, value?.action?.timeZone, value?.action?.status]);
223
233
  return { namespace: local.namespace, provider: candidate.provider, session: candidate.session, taskId: candidate.taskId, binding,
@@ -2,9 +2,9 @@ import spawn from "cross-spawn";
2
2
 
3
3
  // cross-spawn resolves npm .cmd shims on Windows without interpolating command
4
4
  // text. Keep updater commands bounded and preserve execFile-like test injection.
5
- export function runProviderCommand(command, args, { env, timeout = 10_000, maxBuffer = 1024 * 1024 } = {}) {
5
+ export function runProviderCommand(command, args, { env, cwd, timeout = 10_000, maxBuffer = 1024 * 1024, signal } = {}) {
6
6
  return new Promise((resolve, reject) => {
7
- const child = spawn(command, args, { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
7
+ const child = spawn(command, args, { env, cwd, signal, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
8
8
  let stdout = "";
9
9
  let stderr = "";
10
10
  let settled = false;
@@ -0,0 +1,52 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { resolveBackendExecutable } from '../backend-executable.js';
4
+ import { runProviderCommand } from './provider-command.js';
5
+ import { CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
6
+
7
+ export const unavailablePluginStatus = () => ({ state: 'unknown', installed: null, enabled: null });
8
+
9
+ // Provider inventories describe the actual installation and enablement. Managed
10
+ // source files and previously observed hooks cannot establish either state.
11
+ // Inspect from home so a launch directory cannot silently change Claude's merged
12
+ // enablement; expose that context because individual project overrides can differ.
13
+ export async function inspectProviderPlugins({ home = homedir(),
14
+ codexHome = process.env.CODEX_HOME || join(home, '.codex'),
15
+ claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
16
+ resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
17
+ timeoutMs = 5_000, signal,
18
+ } = {}) {
19
+ const inspect = async provider => {
20
+ try {
21
+ if (signal?.aborted) return unavailablePluginStatus();
22
+ const executable = await resolveExecutable(provider, { signal, timeoutMs });
23
+ if (!executable || signal?.aborted) return unavailablePluginStatus();
24
+ const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
25
+ timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
26
+ env: { ...process.env, HOME: home,
27
+ ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) },
28
+ });
29
+ const payload = JSON.parse(stdout);
30
+ const entries = provider === 'codex' ? payload?.installed : payload;
31
+ if (!Array.isArray(entries)) return unavailablePluginStatus();
32
+ const matches = entries.filter(entry => provider === 'codex'
33
+ ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
34
+ : entry?.id === CLAUDE_PLUGIN_SELECTOR);
35
+ // Prefer the account installation; report a project-only install explicitly
36
+ // rather than silently treating it as an installation for every project.
37
+ const scopes = ['user', 'managed', 'project', 'local'];
38
+ if (provider === 'claude') matches.sort((a, b) => {
39
+ const rank = value => scopes.includes(value) ? scopes.indexOf(value) : scopes.length;
40
+ return rank(a.scope) - rank(b.scope);
41
+ });
42
+ const entry = matches[0];
43
+ if (!entry || provider === 'codex' && entry.installed === false) return { state: 'missing', installed: false, enabled: false };
44
+ if (typeof entry.enabled !== 'boolean' || provider === 'codex' && entry.installed !== true ||
45
+ provider === 'claude' && !scopes.includes(entry.scope)) return unavailablePluginStatus();
46
+ return { state: entry.enabled ? 'enabled' : 'disabled', installed: true, enabled: entry.enabled,
47
+ ...(provider === 'claude' ? { scope: entry.scope, enablementContext: 'home' } : {}) };
48
+ } catch { return unavailablePluginStatus(); }
49
+ };
50
+ const [codex, claude] = await Promise.all([inspect('codex'), inspect('claude')]);
51
+ return { codex, claude };
52
+ }