@awak-app/simy-cli 0.6.0 → 0.6.2

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
@@ -104,6 +104,10 @@ Detached daemon and desktop-host diagnostics are saved to
104
104
  (`SIMY_HOME` defaults to `~/.simy`). Each log is limited to 1 MiB plus two
105
105
  backups; credentials, Cookie headers and URL queries are redacted. Installer
106
106
  handoff diagnostics remain in `~/.simy/installer/desktop-update.log`.
107
+ The macOS window also saves the latest nonzero background-host exit in
108
+ `$SIMY_HOME/logs/desktop-host-exit.log`, including its timestamp, exit status and
109
+ up to 16 KiB of stderr. Credential-bearing lines are omitted; each abnormal exit
110
+ replaces the previous record. This includes failures before JavaScript starts.
107
111
  `simy --version` or `simy -v` prints the package version without starting work.
108
112
 
109
113
  Closing the window keeps the agent running. Open the app again or choose
@@ -188,6 +192,22 @@ checks the bundled CLI in ChatGPT and Codex apps in system and user Applications
188
192
  Claude discovery likewise falls back to a compatible native or Desktop bundle
189
193
  when an older PATH installation cannot meet its version requirement.
190
194
 
195
+ On macOS and Linux, the desktop host reads the user's interactive login-shell
196
+ PATH once at startup, so providers installed through nvm, Volta or custom prefixes
197
+ can be found from GUI launches, including Finder or the Dock on macOS. Shell paths
198
+ take priority; existing launcher paths remain available. If the five-second shell
199
+ probe cannot read PATH, startup uses the launcher PATH. Windows keeps its existing PATH.
200
+
201
+ Installed Codex and Claude hooks use an absolute Node runtime path. When available,
202
+ the SIMY installer's `current` link or Homebrew's `opt` link replaces a versioned
203
+ runtime path, allowing hooks to follow updates without relying on the provider's PATH.
204
+
205
+ Use `simy delivery activity --watch` for a read-only local snapshot every minute,
206
+ or add `--json` for one JSON object per sample. It separates execution, waits,
207
+ stops, failures and repeated attempts without new verified outcomes, with task
208
+ identity, agent, reasons and outcome timestamps. Missing evidence is shown as
209
+ unknown; open time is never counted as productive time. See [work activity](docs/work-activity.md).
210
+
191
211
  The plugin also provides an opt-in, hook-based Delivery Loop in the user's existing
192
212
  Codex or Claude Code conversation. Install the plugin, review and enable SIMY hooks
193
213
  with `/hooks`, then request `/simy:delivery-loop` for the task. Installing or updating
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,7 @@
14
14
  "start": "node ./src/index.js",
15
15
  "test": "node --test",
16
16
  "test:e2e:console": "node ./scripts/console-e2e.js",
17
+ "test:e2e:work-activity": "node ./scripts/work-activity-e2e.js",
17
18
  "test:e2e:sqm-complete-loop": "node ./scripts/sqm-complete-loop-e2e.js",
18
19
  "test:e2e:sqm-local-review": "node ./scripts/sqm-local-review-e2e.js",
19
20
  "test:e2e:console:narrow": "CLI_E2E_COLUMNS=78 CLI_E2E_ROWS=40 node ./scripts/console-e2e.js",
@@ -42,7 +43,7 @@
42
43
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
43
44
  "check:sqm": "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",
44
45
  "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",
45
- "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/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/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/session-supervision/automatic-continuation.js",
46
+ "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/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/session-supervision/automatic-continuation.js",
46
47
  "check:package": "node ./scripts/check-package-contents.js",
47
48
  "build:macos-installer:dev": "bash packaging/build-macos-arm64.sh --unsigned-dev",
48
49
  "build:macos-installer": "bash packaging/build-macos-arm64.sh --production",
@@ -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.0";
16
+ export const CLI_VERSION = "0.6.2";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -689,6 +689,8 @@ function newVerifiedCriterion(run, id, now) {
689
689
  managed.verifiedCriterionHistory.push(id);
690
690
  } else if (Object.hasOwn(managed.verified, id)) return;
691
691
  managed.verifiedRevision += 1;
692
+ // Unlike the budget baseline, this is never initialized by enrollment/migration.
693
+ if (!Object.hasOwn(managed.verified, id)) managed.lastOutcomeAt = now;
692
694
  managed.lastVerifiedProgressAt = Math.max(managed.lastVerifiedProgressAt ?? run.startedAt, now);
693
695
  }
694
696
  export async function verifyAutomaticCriteria(record, now) {
@@ -0,0 +1,125 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { listRecords, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+
4
+ export const ACTIVITY_INTERVAL_MS = 60_000;
5
+ const clean = value => typeof value === 'string'
6
+ ? value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ').trim() : '';
7
+ const timestamp = (value, now) => Number.isFinite(value) && value >= 0 && value <= now ? value : null;
8
+ const iso = value => value === null ? null : new Date(value).toISOString();
9
+
10
+ function stateOf(record, now, liveRuntime) {
11
+ const run = record.run;
12
+ const state = run?.state;
13
+ if (['failed', 'stopped', 'completed', 'reported_done'].includes(state)) return state;
14
+ if (state === 'paused') return 'stopped';
15
+ if (['blocked', 'verification_pending', 'budget_exhausted'].includes(state)) return 'waiting';
16
+ const recent = value => timestamp(value, now) !== null && now - value < ACTIVITY_INTERVAL_MS;
17
+ const runtime = record.runtime;
18
+ // An open process alone is not execution evidence. A positive hook observation
19
+ // also needs a matching live identity and a recent timestamp.
20
+ if (runtime?.isRunning === true && liveRuntime && recent(runtime.observedAt)) return 'running';
21
+ const attempt = run?.managed?.executionAttempt;
22
+ if (state === 'auto_resuming' && attempt?.phase === 'dispatching' &&
23
+ recent(attempt.lastActivityAt)) return 'running';
24
+ if (state === 'recovery_required') return 'waiting';
25
+ if (runtime?.isRunning === false && recent(runtime.observedAt)) return 'waiting';
26
+ if (attempt?.phase === 'claimed' && recent(attempt.claimedAt)) return 'waiting';
27
+ return 'unknown';
28
+ }
29
+
30
+ export async function workActivitySnapshot(records, { now = Date.now(), inspectProcess = processIdentity } = {}) {
31
+ const items = [];
32
+ for (const record of records) {
33
+ const run = record.run;
34
+ if (!run && !record.monitor && !record.tracked) continue;
35
+ const managed = run?.managed;
36
+ const runtime = record.runtime;
37
+ let liveRuntime = false;
38
+ if (runtime?.isRunning === true && runtime.process?.identity &&
39
+ timestamp(runtime.observedAt, now) !== null && now - runtime.observedAt < ACTIVITY_INTERVAL_MS) {
40
+ try { liveRuntime = await inspectProcess(runtime.process.pid) === runtime.process.identity; }
41
+ catch { /* Unknown identity cannot prove execution. */ }
42
+ }
43
+ const executionState = stateOf(record, now, liveRuntime);
44
+ const lastOutcomeAt = timestamp(managed?.lastOutcomeAt, now);
45
+ const attempts = Number.isInteger(managed?.noProgress) && managed.noProgress >= 0 ? managed.noProgress : null;
46
+ const progressState = attempts >= 2 ? 'repeating_without_verified_progress'
47
+ : lastOutcomeAt !== null ? 'verified_outcome_recorded' : 'unknown';
48
+ const observed = [record.observedAt, runtime?.observedAt, managed?.executionAttempt?.lastActivityAt]
49
+ .map(value => timestamp(value, now)).filter(value => value !== null);
50
+ const observedAt = observed.length ? Math.max(...observed) : null;
51
+ items.push({
52
+ taskId: clean(managed?.taskId) || clean(record.associationTaskId) || null,
53
+ provider: clean(record.provider), session: clean(record.session),
54
+ agent: `${clean(record.provider)}:${clean(record.session)}`, humanAssignee: null,
55
+ objective: clean(run?.objective) || null, executionState, recordedState: clean(run?.state) || null,
56
+ progressState, noProgressAttempts: attempts,
57
+ lastOutcomeAt: iso(lastOutcomeAt),
58
+ minutesSinceOutcome: lastOutcomeAt === null ? null : Math.floor((now - lastOutcomeAt) / ACTIVITY_INTERVAL_MS),
59
+ observedAt: iso(observedAt), observationAgeMinutes: observedAt === null ? null : Math.floor((now - observedAt) / ACTIVITY_INTERVAL_MS),
60
+ reason: executionState === 'unknown' ? 'No fresh execution observation; activity is unknown.'
61
+ : clean(run?.reason) || (executionState === 'running' ? 'Recent execution observation.' : 'Provider turn is waiting.'),
62
+ recordedReason: clean(run?.reason) || null,
63
+ waitOwner: clean(managed?.dependencyWait?.owner) || null,
64
+ nextCheck: clean(managed?.dependencyWait?.nextCheck) || null,
65
+ });
66
+ }
67
+ items.sort((a, b) => a.agent.localeCompare(b.agent));
68
+ return { schemaVersion: 1, sampledAt: new Date(now).toISOString(), intervalMs: ACTIVITY_INTERVAL_MS,
69
+ measurement: 'point_in_time_observations_not_productive_duration', items };
70
+ }
71
+
72
+ function wrap(value, width = 78) {
73
+ const chars = Array.from(value);
74
+ const lines = [];
75
+ while (chars.length) lines.push(chars.splice(0, width).join(''));
76
+ return lines.join('\n');
77
+ }
78
+
79
+ export function formatWorkActivity(snapshot) {
80
+ const lines = [`Work activity | ${snapshot.sampledAt}`, 'Sampling: 60s. Observations are not productive time.'];
81
+ if (!snapshot.items.length) lines.push('No locally recorded tasks or monitored sessions.');
82
+ for (const item of snapshot.items) {
83
+ lines.push('', `Task: ${item.taskId || 'unassigned (see session)'}`,
84
+ `Agent: ${item.agent}`, 'Human assignee: unknown',
85
+ `State: ${item.executionState} | Progress: ${item.progressState}`,
86
+ `Last verified outcome: ${item.lastOutcomeAt || 'unknown'}`,
87
+ `Attempts without new verified outcome: ${item.noProgressAttempts ?? 'unknown'}`,
88
+ `Last observation: ${item.observedAt || 'unknown'}`,
89
+ `Reason: ${item.reason}`);
90
+ if (item.recordedReason && item.recordedReason !== item.reason) lines.push(`Recorded reason: ${item.recordedReason}`);
91
+ if (item.waitOwner) lines.push(`Waiting on: ${item.waitOwner}`);
92
+ if (item.nextCheck) lines.push(`Next check: ${item.nextCheck}`);
93
+ }
94
+ return lines.map(line => wrap(line)).join('\n');
95
+ }
96
+
97
+ export async function runActivityCommand(args, { root, output = console.log, now = Date.now,
98
+ readRecords = listRecords, inspectProcess = processIdentity, wait = delay, signal } = {}) {
99
+ const options = {};
100
+ for (let i = 0; i < args.length; i++) {
101
+ const key = args[i];
102
+ if (!['--watch', '--json', '--provider', '--session'].includes(key) || Object.hasOwn(options, key)) {
103
+ throw new Error('Use delivery activity [--watch] [--json] [--provider codex|claude] [--session ID].');
104
+ }
105
+ if (['--watch', '--json'].includes(key)) options[key] = true;
106
+ else {
107
+ const value = args[++i];
108
+ if (!value || value.startsWith('--')) throw new Error(`Missing value for ${key}.`);
109
+ options[key] = value;
110
+ }
111
+ }
112
+ if (options['--provider'] && !['codex', 'claude'].includes(options['--provider'])) throw new Error('Unsupported provider.');
113
+ do {
114
+ if (signal?.aborted) break;
115
+ const records = (await readRecords(root)).filter(record =>
116
+ (!options['--provider'] || record.provider === options['--provider']) &&
117
+ (!options['--session'] || record.session === options['--session']));
118
+ const snapshot = await workActivitySnapshot(records, { now: now(), inspectProcess });
119
+ output(options['--json'] ? JSON.stringify(snapshot) : formatWorkActivity(snapshot));
120
+ if (!options['--watch']) break;
121
+ try { await wait(ACTIVITY_INTERVAL_MS, undefined, { signal }); }
122
+ catch (error) { if (error.name === 'AbortError' && signal?.aborted) break; throw error; }
123
+ } while (!signal?.aborted);
124
+ return 0;
125
+ }
@@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
2
2
  import { startDelivery, checkpointDelivery, controlDelivery, listRecords, rootDirectory, verifyDelivery } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
3
  import { continueSession } from '../session-supervision/index.js';
4
4
  import { cancelHandoffSession, handoffSession } from '../session-supervision/handoff.js';
5
+ import { runActivityCommand } from './activity.js';
5
6
 
6
7
  async function readCheckpointInput(input) {
7
8
  const chunks = [];
@@ -17,7 +18,9 @@ async function readCheckpointInput(input) {
17
18
 
18
19
  export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = process.cwd(), input = process.stdin, output = console.log } = {}) {
19
20
  const [action, ...args] = argv;
21
+ if (action === 'activity') return runActivityCommand(args, { root, output });
20
22
  if (!action || action === '--help') {
23
+ output('simy delivery activity [--watch] [--json] [--provider codex|claude] [--session ID] — local observations every 60 seconds; not productive time.');
21
24
  output('Progress: --continuation-policy progress [--hard-max-minutes 1440] [--hard-max-continuations 50] [--provider-token-ceiling 15000000 (Codex only)]. Soft thresholds warn; approvals, missing usage and no verified progress still stop.');
22
25
  output('simy delivery continuation_policy --provider PROVIDER --session ID --task-id ID --continuation-policy bounded|progress');
23
26
  output('simy delivery extend --provider PROVIDER --session ID --task-id ID --hard-max-minutes N|--hard-max-continuations N|--provider-token-ceiling N');
@@ -23,7 +23,8 @@ import { dirname, join } from "node:path";
23
23
  import { homedir } from "node:os";
24
24
  import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
25
25
  import { startAgent } from "./agent.js";
26
- import { createRuntimeDiagnostics } from "./runtime-diagnostics.js";
26
+ import { createRuntimeDiagnostics, captureRuntimeOutput } from "./runtime-diagnostics.js";
27
+ import { applyLoginShellPath } from "./login-shell-path.js";
27
28
  import { createCliAutoUpdater, readCliPackageInfo } from "./auto-update.js";
28
29
  import { createAgentShutdown } from "./shutdown.js";
29
30
  import { resolveWebOrigin } from "./web-origin.js";
@@ -735,7 +736,7 @@ export async function openProviderLogin(provider, root, {
735
736
  const directory = await mkdtemp(join(root, `${provider}-login-`));
736
737
  const script = join(directory, `Sign in to ${label}.command`);
737
738
  const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
738
- await writeFile(script, `#!/bin/bash\nexport PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:$PATH"\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
739
+ await writeFile(script, `#!/bin/bash\nexport PATH=${quote(env.PATH || "/usr/bin:/bin")}\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
739
740
  await chmod(script, 0o700);
740
741
  try { await execFileImpl("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
741
742
  catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
@@ -750,8 +751,12 @@ if (isDesktopHostEntry()) {
750
751
  let host;
751
752
  const stop = () => { void host?.close().finally(() => process.exit(0)); };
752
753
  process.once("SIGTERM", stop); process.once("SIGINT", stop);
753
- startDesktopHost().then((value) => { host = value; }).catch((error) => {
754
- createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" }).write(`startup failed: ${error?.stack || error}`);
754
+ const diagnostics = createRuntimeDiagnostics(process.env.SIMY_HOME?.trim() || join(homedir(), ".simy"), { name: "desktop-host" });
755
+ // stdout is the native JSONL channel; stderr is the only unstructured output
756
+ // and the native window does not display it, so keep it for support.
757
+ captureRuntimeOutput(diagnostics, { stderr: process.stderr });
758
+ applyLoginShellPath({ diagnostics }).catch(() => {}).then(() => startDesktopHost()).then((value) => { host = value; }).catch((error) => {
759
+ diagnostics.write(`startup failed: ${error?.stack || error}`);
755
760
  const conflict = error?.code === PROCESS_CONFLICT_CODE;
756
761
  process.stdout.write(`${JSON.stringify({
757
762
  type: "error",
@@ -0,0 +1,29 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ // Version-specific runtimes are replaced by the stable alias that follows
4
+ // upgrades: the installer's `current` release link and Homebrew's `opt` link.
5
+ const STABLE_RUNTIME_ALIASES = [
6
+ [/^(.*)\/installer\/releases\/[^/]+\/[^/]+\.app\/(Contents\/Resources\/runtime\/bin\/node)$/, "$1/installer/current/$2"],
7
+ [/^(.*)\/Cellar\/([^/]+)\/[^/]+\/(bin\/node)$/, "$1/opt/$2/$3"],
8
+ ];
9
+
10
+ // Provider hooks are persisted into Codex/Claude configuration and run long
11
+ // after this process exits, often from GUI apps without the user's shell PATH.
12
+ // They must therefore name an absolute runtime that survives SIMY and Node updates.
13
+ export function stableHookRuntime({ execPath = process.execPath, fileExists = existsSync } = {}) {
14
+ for (const [pattern, replacement] of STABLE_RUNTIME_ALIASES) {
15
+ if (!pattern.test(execPath)) continue;
16
+ const alias = execPath.replace(pattern, replacement);
17
+ if (fileExists(alias)) return alias;
18
+ }
19
+ return execPath;
20
+ }
21
+
22
+ export function hookRuntimeCommand(command, { platform = process.platform, ...options } = {}) {
23
+ if (typeof command !== "string" || !command.startsWith("node ")) return command;
24
+ return `${quoteRuntime(stableHookRuntime(options), platform)} ${command.slice("node ".length)}`;
25
+ }
26
+
27
+ function quoteRuntime(value, platform) {
28
+ return platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
29
+ }
package/src/index.js CHANGED
@@ -57,6 +57,9 @@ if (!configuredWebOrigin) {
57
57
  } else if (dispatchArgv[0] === "doctor") {
58
58
  const { runDoctor } = await import("./doctor.js");
59
59
  process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
60
+ } else if (dispatchArgv[0] === "delivery" && dispatchArgv[1] === "activity") {
61
+ // A local observation must not install updates or require a network connection.
62
+ await runCommand();
60
63
  } else if (dispatchArgv[0] === "update") {
61
64
  const checkOnly = args.has("--check");
62
65
  const result = await runCliUpdateGate({
@@ -0,0 +1,67 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ const START = "__SIMY_LOGIN_PATH_START__";
6
+ const END = "__SIMY_LOGIN_PATH_END__";
7
+
8
+ // Apps opened from Finder or the Dock inherit launchd's minimal PATH, not the
9
+ // user's shell profile. Provider CLIs installed through nvm, volta, fnm, asdf,
10
+ // pnpm or custom prefixes are therefore invisible unless we ask the user's own
11
+ // login shell once. A slow or broken profile must never prevent startup.
12
+ export async function resolveLoginShellPath({
13
+ platform = process.platform,
14
+ env = process.env,
15
+ execFileImpl = execFile,
16
+ fileExists = existsSync,
17
+ timeoutMs = 5_000,
18
+ } = {}) {
19
+ if (platform === "win32") return null;
20
+ const shell = loginShell({ platform, env, fileExists });
21
+ if (!shell) return null;
22
+ const output = await new Promise((resolve) => {
23
+ // Read the exported environment rather than expanding $PATH in the shell:
24
+ // fish expands a quoted $PATH with spaces, but exports it colon-separated.
25
+ const child = execFileImpl(shell, ["-l", "-i", "-c", `echo ${START}; /usr/bin/env; echo ${END}`], {
26
+ env: { ...env, TERM: "dumb", DISABLE_AUTO_UPDATE: "true" },
27
+ timeout: timeoutMs,
28
+ killSignal: "SIGKILL",
29
+ maxBuffer: 1024 * 1024,
30
+ }, (_error, stdout) => resolve(String(stdout || "")));
31
+ child?.stdin?.end();
32
+ });
33
+ const start = output.lastIndexOf(START);
34
+ const end = output.indexOf(END, start);
35
+ if (start < 0 || end < 0) return null;
36
+ const line = output.slice(start + START.length, end).split(/\r?\n/).find((item) => item.startsWith("PATH="));
37
+ return line?.slice("PATH=".length) || null;
38
+ }
39
+
40
+ // Login-shell entries take priority, matching what the user sees in Terminal.
41
+ // Existing entries (including the launcher's fallback directories) are kept.
42
+ export function mergePathEntries(...values) {
43
+ const entries = [];
44
+ for (const value of values) {
45
+ for (const entry of String(value || "").split(path.delimiter)) {
46
+ if (path.isAbsolute(entry) && !entries.includes(entry)) entries.push(entry);
47
+ }
48
+ }
49
+ return entries.join(path.delimiter);
50
+ }
51
+
52
+ export async function applyLoginShellPath({ env = process.env, platform = process.platform, diagnostics, ...options } = {}) {
53
+ if (platform === "win32") return false;
54
+ const loginPath = await resolveLoginShellPath({ env, platform, ...options });
55
+ if (!loginPath) {
56
+ diagnostics?.write("login shell PATH unavailable; using launcher PATH");
57
+ return false;
58
+ }
59
+ env.PATH = mergePathEntries(loginPath, env.PATH);
60
+ diagnostics?.write(`login shell PATH applied (${env.PATH.split(path.delimiter).length} entries)`);
61
+ return true;
62
+ }
63
+
64
+ function loginShell({ platform, env, fileExists }) {
65
+ const candidates = [env.SHELL, platform === "darwin" ? "/bin/zsh" : null, "/bin/sh"];
66
+ return candidates.find((candidate) => candidate && path.isAbsolute(candidate) && fileExists(candidate)) || null;
67
+ }
@@ -3,6 +3,7 @@ import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { hookRuntimeCommand } from "../hook-runtime.js";
6
7
 
7
8
  import { inspectClaudePlugin, installClaudePlugin } from "./claude-plugin.js";
8
9
 
@@ -328,25 +329,14 @@ async function bundledPluginManifest({ overrides = new Map() } = {}) {
328
329
  async function installedPluginOverrides() {
329
330
  const relative = "hooks/hooks.json";
330
331
  const source = JSON.parse(await readFile(path.join(BUNDLED_CODEX_PLUGIN_ROOT, relative), "utf8"));
331
- const commandPrefix = "node ";
332
- const runtime = shellQuote(process.execPath);
333
332
  for (const groups of Object.values(source.hooks || {})) {
334
333
  for (const group of groups || []) {
335
- for (const hook of group.hooks || []) {
336
- if (typeof hook.command === "string" && hook.command.startsWith(commandPrefix)) {
337
- hook.command = `${runtime} ${hook.command.slice(commandPrefix.length)}`;
338
- }
339
- }
334
+ for (const hook of group.hooks || []) hook.command = hookRuntimeCommand(hook.command);
340
335
  }
341
336
  }
342
337
  return new Map([[relative, `${JSON.stringify(source, null, 2)}\n`]]);
343
338
  }
344
339
 
345
- function shellQuote(value) {
346
- if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`;
347
- return `'${value.replaceAll("'", "'\\''")}'`;
348
- }
349
-
350
340
  async function directoryDigest(root, { missing = "throw", manifest, overrides = new Map() } = {}) {
351
341
  let rootStat;
352
342
  try {
@@ -3,6 +3,7 @@ import { mkdir, readFile, readdir, rename, rm, lstat, writeFile } from "node:fs/
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { hookRuntimeCommand } from "../hook-runtime.js";
6
7
 
7
8
  const SOURCE = fileURLToPath(new URL("../codex-plugins/simy/", import.meta.url));
8
9
  export const CLAUDE_MARKETPLACE = "simy-local";
@@ -43,7 +44,7 @@ export async function claudePluginFiles() {
43
44
  } else if (event === "Notification") {
44
45
  hook.statusMessage = "SIMY Delivery Loop permission-notification tracking";
45
46
  }
46
- hook.command = hook.command.replace(/^node /, `${quoteRuntime(process.execPath)} `)
47
+ hook.command = hookRuntimeCommand(hook.command)
47
48
  .replaceAll("process.env.PLUGIN_ROOT", "process.env.CLAUDE_PLUGIN_ROOT")
48
49
  .replaceAll("hook.main('codex')", "hook.main('claude')");
49
50
  }
@@ -136,6 +137,3 @@ function json(value) { return `${JSON.stringify(value, null, 2)}\n`; }
136
137
  async function optionalRead(file) {
137
138
  try { return await readFile(file, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
138
139
  }
139
- function quoteRuntime(value) {
140
- return process.platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
141
- }
@@ -95,6 +95,7 @@ export function createWorkflowObservationSender({
95
95
  let terminal = null;
96
96
  let startPromise = null;
97
97
  let admittedAtMs = null;
98
+ let submittedTerminalAt = null;
98
99
 
99
100
  async function post(path, body, attempts) {
100
101
  let response = null;
@@ -191,10 +192,13 @@ export function createWorkflowObservationSender({
191
192
  // A very short provider turn can finish before the start RPC commits.
192
193
  // For a new run, record when the terminal event is submitted after
193
194
  // admission; the event itself remains the real provider event.
194
- const observedAt = new Date(Math.max(now().getTime(), admittedAtMs + 1)).toISOString();
195
+ // The intake's immutable replay identity includes observed_at. Freeze it
196
+ // before the first send so another flush after a lost response (including
197
+ // concurrent flushes) resubmits the exact same event.
198
+ submittedTerminalAt ??= new Date(Math.max(now().getTime(), admittedAtMs + 1)).toISOString();
195
199
  return sendTypedTerminal({
196
200
  idempotencyKey: terminalIdempotencyKey(normalizedIntent, normalizedBinding),
197
- observedAt,
201
+ observedAt: submittedTerminalAt,
198
202
  });
199
203
  }
200
204