@awak-app/simy-cli 0.7.45 → 0.7.47

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 (36) hide show
  1. package/package.json +2 -2
  2. package/src/cli-contract.js +1 -1
  3. package/src/codex-plugins/simy/.mcp.json +11 -0
  4. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +60 -4
  5. package/src/codex-plugins/simy/hooks/measurement-collector.mjs +130 -0
  6. package/src/codex-plugins/simy/hooks/measurement-contract.mjs +51 -0
  7. package/src/codex-plugins/simy/hooks/measurement-judge.mjs +46 -0
  8. package/src/codex-plugins/simy/hooks/measurement-snapshot.mjs +89 -0
  9. package/src/codex-plugins/simy/hooks/measurement-store.mjs +132 -0
  10. package/src/codex-plugins/simy/native/app-tools.mjs +45 -0
  11. package/src/codex-plugins/simy/native/bridge.mjs +156 -0
  12. package/src/codex-plugins/simy/native/guidance-intent.mjs +30 -0
  13. package/src/codex-plugins/simy/native/launch +36 -0
  14. package/src/codex-plugins/simy/native/server.mjs +26 -0
  15. package/src/codex-plugins/simy/native/submission-receipt.mjs +9 -0
  16. package/src/delivery-hooks/command.js +6 -0
  17. package/src/delivery-hooks/measurement.js +149 -0
  18. package/src/delivery-hooks/supervisor.js +20 -2
  19. package/src/desktop-host.js +180 -12
  20. package/src/session-supervision/claude-sessions.js +25 -14
  21. package/src/session-supervision/claude-stream-owner.js +266 -0
  22. package/src/session-supervision/claude-stream-presentation.js +27 -0
  23. package/src/session-supervision/claude-stream-transport.js +188 -0
  24. package/src/session-supervision/codex-app-server.js +30 -8
  25. package/src/session-supervision/codex-app-tools-connection.js +30 -0
  26. package/src/session-supervision/controller-store.js +58 -8
  27. package/src/session-supervision/conversation-presentation.js +2 -0
  28. package/src/session-supervision/index.js +5 -1
  29. package/src/session-supervision/native-handoff.js +69 -13
  30. package/src/session-supervision/native-turn-observer.js +196 -0
  31. package/src/session-supervision/purpose-verification.js +18 -7
  32. package/src/session-supervision/session-guidance.js +160 -0
  33. package/src/sqm/claude-plugin.js +4 -0
  34. package/src/sqm/history-store.js +28 -1
  35. package/src/sqm/review-worker.js +5 -1
  36. package/src/web-api.js +5 -2
@@ -0,0 +1,160 @@
1
+ import {dirname} from 'node:path';
2
+ import {realpath} from 'node:fs/promises';
3
+ import {openNativeBridge} from '../codex-plugins/simy/native/bridge.mjs';
4
+ import {guidanceAllowed, guidanceDigest, guidancePending, guidanceTarget, assertGuidanceIntent} from '../codex-plugins/simy/native/guidance-intent.mjs';
5
+ import {matchesNativeDelegation} from '../codex-plugins/simy/native/submission-receipt.mjs';
6
+ import {readRecord, updateRecord, hasPendingUserStop, managedBudgetReason, queuePurposeDiagnosis, advanceDeliveryControlRevision} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
7
+ import {requireProviderLink} from '../provider-links.js';
8
+
9
+ const fail = code => Object.assign(new Error(code), {code});
10
+ const terminal = phase => ['observed', 'cancelled'].includes(phase);
11
+ function assertWritable(root, record, id, text) {
12
+ const intent = assertGuidanceIntent(record, id, text);
13
+ if (hasPendingUserStop(root, record) || managedBudgetReason(record.run)) throw fail('native_guidance_changed');
14
+ return intent;
15
+ }
16
+ export function sessionGuidancePresentation(candidate, record, now = Date.now()) {
17
+ const local = !candidate.remoteHost && candidate.teamRemote !== true && (!candidate.hostId || candidate.hostId === 'local');
18
+ const matched = record?.provider === candidate.provider && record?.session === candidate.session;
19
+ const observation = matched ? record.run?.managed?.nativeConnectionObservation : null;
20
+ const pending = matched ? record.run?.managed?.userGuidance : null;
21
+ const age = now - observation?.at;
22
+ const available = local && matched && candidate.isRunning === false && candidate.workState?.stale !== true &&
23
+ candidate.dispatchReadiness?.state !== 'blocked' && guidanceAllowed(record) &&
24
+ observation?.session === candidate.session && observation.state === 'connected' && observation.transport === 'codex_app_tools' && observation.guidanceAuthorization === true && age >= 0 && age < 30_000;
25
+ return {available: Boolean(available), state: pending?.phase || 'not_sent',
26
+ ...(pending ? {requestId: pending.id} : {}), providerExecutionConfirmed: false,
27
+ ...(available ? {target: guidanceTarget(record)} : {}),
28
+ reason: candidate.provider !== 'codex' ? 'direct_guidance_unsupported' : pending && !terminal(pending.phase) ? 'guidance_pending' : available ? null : 'original_connection_required'};
29
+ }
30
+
31
+ // Read-only capability negotiation for the selected original conversation.
32
+ // A connection observation never resumes a writer or proves provider execution.
33
+ export async function observeSessionGuidanceConnection({root, record, signal, authorize = async () => {},
34
+ openClient = openNativeBridge, checkLink = requireProviderLink, now = Date.now}) {
35
+ if (!guidanceAllowed(record)) return record;
36
+ const target = JSON.stringify(guidanceTarget(record));
37
+ await authorize();
38
+ const link = await checkLink({root, provider: record.provider});
39
+ let client, observation;
40
+ try {
41
+ signal?.throwIfAborted();
42
+ client = await openClient({root: dirname(root), record, signal});
43
+ const thread = (await client?.request('thread/read', {threadId: record.session}))?.thread;
44
+ if (client?.transport !== 'codex_app_tools' || client.guidanceAuthorization !== true) throw fail('native_guidance_unavailable');
45
+ if (thread?.id !== record.session || thread.ephemeral || await realpath(thread.cwd) !== await realpath(record.cwd)) throw fail('native_tools_owner_mismatch');
46
+ if (thread.status?.type !== 'idle') throw fail('original_thread_not_idle');
47
+ observation = {state: 'connected', transport: client.transport, guidanceAuthorization: true};
48
+ } catch (error) {
49
+ observation = {state: 'unavailable', transport: 'codex_app_tools', guidanceAuthorization: false,
50
+ code: /^[a-zA-Z0-9_]{1,80}$/.test(error.code || '') ? error.code : 'native_connection_failed'};
51
+ } finally {await client?.close();}
52
+ await authorize();
53
+ await checkLink({root, provider: record.provider, expectedEpoch: link.epoch});
54
+ return updateRecord(root, record.provider, record.session, async fresh => {
55
+ if (!guidanceAllowed(fresh) || JSON.stringify(guidanceTarget(fresh)) !== target) return fresh;
56
+ // Recheck authority while holding the same task/intent lock used by sends.
57
+ await authorize();
58
+ await checkLink({root, provider: record.provider, expectedEpoch: link.epoch});
59
+ fresh.run.managed.nativeConnectionObservation = {...observation, session: record.session, at: now()};
60
+ return fresh;
61
+ });
62
+ }
63
+
64
+ // A human explicitly submits a new instruction to the same loaded conversation.
65
+ // A durable, exact intent fences automatic work; the app bridge claims it once
66
+ // before dispatch. Unknown writes are reconciled only, never replayed.
67
+ export async function sendSessionGuidance({root, provider, session, requestId, expectedRunId, taskId, expectedRevision, expectedEpoch,
68
+ text, signal, authorize = async () => {}, openClient = openNativeBridge, checkLink = requireProviderLink, now = Date.now}) {
69
+ if (provider !== 'codex' || !/^[0-9a-f-]{36}$/i.test(session || '') || !/^[0-9a-f-]{36}$/i.test(requestId || '') ||
70
+ typeof text !== 'string' || !text.trim() || text.length > 4000) throw fail('native_guidance_invalid');
71
+ const prompt = `[SIMY user guidance ${requestId}]\n${text}`;
72
+ await authorize();
73
+ const link = await checkLink({root, provider});
74
+ let duplicate;
75
+ const record = await updateRecord(root, provider, session, r => {
76
+ const old = r?.run?.managed?.userGuidance;
77
+ if (old?.id === requestId) {
78
+ if (old.digest !== guidanceDigest(prompt)) throw fail('native_guidance_conflict');
79
+ duplicate = {state: old.phase, requestId}; return r;
80
+ }
81
+ if (!guidanceAllowed(r) || hasPendingUserStop(root, r) || managedBudgetReason(r.run) || r.run.id !== expectedRunId ||
82
+ r.run.managed.taskId !== taskId || guidanceTarget(r).revision !== expectedRevision || r.autoContinueEpoch !== expectedEpoch ||
83
+ r.run.managed.guidanceHistory?.some(item => item.id === requestId)) throw fail('native_guidance_changed');
84
+ if (old) r.run.managed.guidanceHistory = [...(r.run.managed.guidanceHistory || []), {id: old.id, digest: old.digest, phase: old.phase}];
85
+ r.run.managed.userGuidance = {id: requestId, text: prompt, digest: guidanceDigest(prompt), target: guidanceTarget(r), phase: 'prepared', createdAt: now()};
86
+ return r;
87
+ });
88
+ if (duplicate) return duplicate;
89
+ let client;
90
+ try {
91
+ signal?.throwIfAborted();
92
+ client = await openClient({root: dirname(root), record, guidanceId: requestId, signal});
93
+ if (!client?.submitGuidance) throw fail('native_guidance_unavailable');
94
+ const thread = (await client.request('thread/read', {threadId: session}))?.thread;
95
+ if (thread?.id !== session || thread.ephemeral || thread.status?.type !== 'idle' || await realpath(thread.cwd) !== await realpath(record.cwd)) throw fail('native_guidance_unavailable');
96
+ await authorize(); await checkLink({root, provider, expectedEpoch: link.epoch});
97
+ await updateRecord(root, provider, session, r => {const intent = assertWritable(root, r, requestId, prompt); intent.phase = 'submitting'; return r;});
98
+ await client.submitGuidance({text: prompt, beforeSubmit: async () => {
99
+ await authorize(); await checkLink({root, provider, expectedEpoch: link.epoch});
100
+ assertWritable(root, await readRecord(root, provider, session), requestId, prompt);
101
+ }});
102
+ await updateRecord(root, provider, session, r => {
103
+ const intent = r?.run?.managed?.userGuidance;
104
+ if (intent?.id === requestId) {intent.phase = 'submitted'; intent.acceptedAt = now();}
105
+ return r;
106
+ });
107
+ return {state: 'submitted', requestId, providerExecutionConfirmed: false};
108
+ } catch (error) {
109
+ await updateRecord(root, provider, session, r => {
110
+ const intent = r?.run?.managed?.userGuidance;
111
+ if (intent?.id === requestId && intent.phase !== 'observed') {
112
+ intent.phase = intent.bridgeAttemptedAt || ['native_submission_unknown', 'native_tools_closed'].includes(error.code) ? 'unknown' : 'cancelled';
113
+ intent.failedAt = now();
114
+ }
115
+ return r;
116
+ });
117
+ throw Object.assign(fail(error.code === 'native_guidance_changed' ? error.code : 'native_guidance_unconfirmed'), {cause: error});
118
+ } finally { await client?.close(); }
119
+ }
120
+
121
+ // A fresh bridge can read the exact original receipt after a host restart.
122
+ // No code path here invokes submitGuidance or retries a write.
123
+ export async function reconcileSessionGuidance({root, record, signal, authorize = async () => {}, openClient = openNativeBridge, now = Date.now}) {
124
+ const intent = record?.run?.managed?.userGuidance;
125
+ if (!guidancePending(record) || !intent.bridgeAttemptedAt) return false;
126
+ let client;
127
+ try {
128
+ await authorize();
129
+ signal?.throwIfAborted();
130
+ client = await openClient({root: dirname(root), record, guidanceId: intent.id, signal});
131
+ if (!client) return false;
132
+ let cursor;
133
+ for (let page = 0; page < 3; page++) {
134
+ const result = await client.request('thread/turns/list', {threadId: record.session, ...(cursor ? {cursor} : {})});
135
+ if (!Array.isArray(result?.data)) return false;
136
+ const turn = result.data.find(turn => matchesNativeDelegation(turn, record.session, intent.text));
137
+ if (turn) {
138
+ await authorize();
139
+ let inspectedNext = false;
140
+ await updateRecord(root, record.provider, record.session, r => {
141
+ if (r?.run?.id !== record.run.id || r.run.managed?.userGuidance?.id !== intent.id) return r;
142
+ const guide = r.run.managed.userGuidance;
143
+ guide.receiptObservedAt = now(); guide.turnId = turn.id;
144
+ // A receipt alone must not release the old automatic plan. If the
145
+ // controller cannot queue a fresh inspection (stop/budget/backoff),
146
+ // retain the send hold and let later read-only reconciliation retry.
147
+ inspectedNext = !hasPendingUserStop(root, r) && !r.run.stopReason && queuePurposeDiagnosis(r.run,
148
+ 'Read the latest user guidance and current original conversation before choosing the next authorized action. Preserve corrections, stops, permissions, goals and budgets; do not repeat previous effects.',
149
+ `user-guidance:${intent.id}`, now(), 'user_guidance');
150
+ if (inspectedNext) {advanceDeliveryControlRevision(r.run); guide.phase = 'observed'; guide.observedAt = now();}
151
+ return r;
152
+ });
153
+ return inspectedNext;
154
+ }
155
+ if (!result.nextCursor || result.nextCursor === cursor) return false;
156
+ cursor = result.nextCursor;
157
+ }
158
+ return false;
159
+ } finally { await client?.close(); }
160
+ }
@@ -34,6 +34,8 @@ export async function claudePluginFiles() {
34
34
  for (const name of ["loop-kernel.mjs", "loop-kernel/index.mjs", "loop-kernel/policy.json", "loop-kernel/pin.json", "loop-kernel/outcome-review.mjs", "loop-kernel/work-frontier.mjs", "loop-kernel/task-pool.mjs"]) {
35
35
  files.set(`hooks/${name}`, await readFile(path.join(SOURCE, "hooks", name), "utf8"));
36
36
  }
37
+ // The shared hook imports this standalone helper; no native bridge is needed.
38
+ files.set("native/guidance-intent.mjs", await readFile(path.join(SOURCE, "native/guidance-intent.mjs"), "utf8"));
37
39
  const collector = await readFile(new URL("../cli-observability.js", import.meta.url), "utf8");
38
40
  // Standalone hooks do not ship the CLI capability dependency graph. Freeze the
39
41
  // maintained package version into their generated collector instead.
@@ -61,6 +63,8 @@ export async function claudePluginFiles() {
61
63
  files.set("hooks/purpose-evidence.mjs", await readFile(path.join(SOURCE, "hooks/purpose-evidence.mjs"), "utf8"));
62
64
  files.set("hooks/user-experience.mjs", await readFile(path.join(SOURCE, "hooks/user-experience.mjs"), "utf8"));
63
65
  files.set("hooks/result-cycle.mjs", await readFile(path.join(SOURCE, "hooks/result-cycle.mjs"), "utf8"));
66
+ for (const name of ['measurement-contract', 'measurement-store', 'measurement-snapshot', 'measurement-judge', 'measurement-collector'])
67
+ files.set(`hooks/${name}.mjs`, await readFile(path.join(SOURCE, `hooks/${name}.mjs`), 'utf8'));
64
68
  files.set("hooks/scene-reasoning.mjs", await readFile(path.join(SOURCE, "hooks/scene-reasoning.mjs"), "utf8"));
65
69
  files.set("hooks/purpose-cycle.mjs", await readFile(path.join(SOURCE, "hooks/purpose-cycle.mjs"), "utf8"));
66
70
  files.set("hooks/purpose-rule-policy.mjs", await readFile(path.join(SOURCE, "hooks/purpose-rule-policy.mjs"), "utf8"));
@@ -6,6 +6,9 @@ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promise
6
6
  import { basename, join } from "node:path";
7
7
  import { formatSqmCoverage, sqmCoverageWarnings } from "./report.js";
8
8
  import { defaultSessionRoot } from "../session-store.js";
9
+ import { recordMeasurementSqm } from '../codex-plugins/simy/hooks/measurement-collector.mjs';
10
+ import { measurementBinding, appendMeasurement, measurementCollectionEnabled } from '../codex-plugins/simy/hooks/measurement-store.mjs';
11
+ import { measurementDigest } from '../codex-plugins/simy/hooks/measurement-contract.mjs';
9
12
 
10
13
  // Limit the desktop projection, never the durable history.
11
14
  const DISPLAY_LIMIT = 100;
@@ -132,11 +135,35 @@ export function projectSqmHistory(items, live = { activeCount: 0, items: [] }) {
132
135
  }
133
136
 
134
137
  export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, providerSession, conversationProvider, onError = () => console.warn("Could not save the local SQM check history.") }) {
135
- if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return async () => {};
138
+ const historyEnabled = Boolean(scope?.webOrigin && scope?.accountId && scope?.organizationId);
136
139
  const history = createSqmHistoryStore({ root });
137
140
  const key = `sqm-check:${randomUUID()}`;
138
141
  const startedAt = new Date().toISOString();
142
+ const measurementRoot = join(root || defaultSessionRoot(), 'delivery-hooks');
143
+ // Pin the current trace before the check starts. A new task in the same
144
+ // provider conversation cannot inherit the old task's late SQM result.
145
+ const measurement = providerSession && conversationProvider ? measurementBinding(measurementRoot, conversationProvider, providerSession).catch(() => null) : Promise.resolve(null);
139
146
  return async (result, presentation = {}) => {
147
+ try {
148
+ const binding = await measurement;
149
+ if (binding && measurementCollectionEnabled(measurementRoot, conversationProvider, providerSession, binding.trace)) {
150
+ const context = { root: measurementRoot, provider: conversationProvider, session: providerSession, trace: binding.trace };
151
+ if (result.proof) await recordMeasurementSqm({ ...context, result });
152
+ else await appendMeasurement({ ...context, type: 'sqm_attempt', key,
153
+ data: { attempt: measurementDigest(key), phase: phase === 'min' ? 'min' : 'full',
154
+ outcome: result.status === 'unavailable' ? 'unavailable' : ['failed', 'preliminary_failed'].includes(result.status) ? 'failed' : ['passed', 'preliminary_passed'].includes(result.status) ? 'passed' : 'unknown',
155
+ candidate: result.repository_identity ? measurementDigest(result.repository_identity) : null,
156
+ diagnostic: result.diagnostic ? measurementDigest(result.diagnostic) : null } });
157
+ }
158
+ } catch {
159
+ console.warn('Adoption measurement could not retain this SQM observation.');
160
+ try {
161
+ const binding = await measurement;
162
+ if (binding) await appendMeasurement({ root: measurementRoot, provider: conversationProvider, session: providerSession, trace: binding.trace,
163
+ type: 'gap', key: `sqm:${key}`, data: { reason: 'capture_failed' } });
164
+ } catch { /* The collection audit must report the damaged journal. */ }
165
+ }
166
+ if (!historyEnabled) return;
140
167
  const proof = result.proof, identity = proof?.repository_identity || result.repository_identity || result.min_check?.repository_identity;
141
168
  const repository = identity?.repository || result.repository;
142
169
  const outcome = result.status === "stale" ? "stale" : proof?.outcome.status || result.min_check?.status || result.status;
@@ -475,6 +475,8 @@ async function runSingleProviderReview(
475
475
  ) {
476
476
  assertProvider(provider);
477
477
  assertStructuredRequest(request);
478
+ // Probe the same filtered environment used by the actual provider process.
479
+ environment = safeReviewEnvironment(environment, provider);
478
480
  if (signal?.aborted) throw new SqmLocalReviewError('Local analysis was cancelled.', { code: 'analysis_cancelled' });
479
481
  const label = provider === "claude" ? "Claude Code" : "Codex";
480
482
  const inspection = await inspectExecutor(provider);
@@ -621,7 +623,7 @@ function spawnReview(command, args, prompt, { spawnImpl, dispatchTransport, exec
621
623
  child = dispatchTransport(() => {
622
624
  try {
623
625
  return spawnImpl(command, args, {
624
- env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
626
+ env: environment, cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
625
627
  });
626
628
  } catch { throw providerFailure("provider_spawn_failed", { model }); }
627
629
  });
@@ -811,6 +813,8 @@ function providerSignInRequired(message) {
811
813
  function safeReviewEnvironment(environment, provider) {
812
814
  const allowed = [
813
815
  "HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR",
816
+ // Native credential lookup on macOS needs the local user identity too.
817
+ "USER", "LOGNAME",
814
818
  "SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR",
815
819
  "SSL_CERT_FILE", "SSL_CERT_DIR", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
816
820
  ];
package/src/web-api.js CHANGED
@@ -17,6 +17,9 @@ export function publicImageUrl(value) {
17
17
 
18
18
  const APP_DEV_ORIGIN = "https://app-dev.simy.one";
19
19
  const APP_DEV_GATEWAY_BASE_URL = "https://app.simy.one/api/local-cli-gateway/";
20
+ // An isolated dev build can explicitly select the dev gateway before a new
21
+ // route reaches stable. Never infer this endpoint or redirect a saved session.
22
+ const APP_DEV_LOCAL_GATEWAY_BASE_URL = "https://app-dev.simy.one/api/local-cli-gateway/";
20
23
 
21
24
  export function resolveWebApiBaseUrl(webOrigin, requestedBaseUrl = null) {
22
25
  const normalizedWebOrigin = new URL(webOrigin).origin;
@@ -36,9 +39,9 @@ export function resolveWebApiBaseUrl(webOrigin, requestedBaseUrl = null) {
36
39
  if (requested.href === directBase) return directBase;
37
40
  if (
38
41
  normalizedWebOrigin === APP_DEV_ORIGIN &&
39
- requested.href === APP_DEV_GATEWAY_BASE_URL
42
+ [APP_DEV_GATEWAY_BASE_URL, APP_DEV_LOCAL_GATEWAY_BASE_URL].includes(requested.href)
40
43
  ) {
41
- return APP_DEV_GATEWAY_BASE_URL;
44
+ return requested.href;
42
45
  }
43
46
  throw new Error("SIMY Web provided an untrusted CLI API endpoint.");
44
47
  }