@awak-app/simy-cli 0.7.23 → 0.7.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.7.23",
3
+ "version": "0.7.25",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { connectionOverview, readSqmConnectionObservation } from './connection-overview.js';
1
2
  import { readLocalPhotoSource } from "./local-photos.js";
2
3
  import { resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
3
4
  import { observePairedNativeIdentities } from './native-provider-identity.js';
@@ -1217,7 +1218,7 @@ export async function startAgent({
1217
1218
 
1218
1219
  if (req.method === "GET" && url.pathname === "/v1/auth/browser") {
1219
1220
  if (req.headers.host !== `127.0.0.1:${port}`) { json(res, 403, { error: "Invalid local host." }); return; }
1220
- const page = browserHandoffPage();
1221
+ const page = browserHandoffPage(apiOrigin);
1221
1222
  res.writeHead(200, page.headers).end(page.html);
1222
1223
  return;
1223
1224
  }
@@ -1516,6 +1517,7 @@ export async function startAgent({
1516
1517
  const previousSession = session ?? reclaimableSession;
1517
1518
  const authorizedSession = {
1518
1519
  token: String(body.token || ""),
1520
+ email: typeof body.email === "string" && body.email.length <= 254 ? body.email : null,
1519
1521
  device_id: typeof body.device_id === "string" ? body.device_id : null,
1520
1522
  api_origin: apiOrigin,
1521
1523
  api_base_url: apiBaseUrl,
@@ -2857,8 +2859,21 @@ export async function startAgent({
2857
2859
  const { codex, claude } = deferStartupSynchronization
2858
2860
  ? providerStatuses
2859
2861
  : await readProviderStatuses();
2862
+ const overviewSession = session;
2863
+ const overviewAccess = desktopAccess.snapshot();
2864
+ let overviewSqm = { state: 'unknown' };
2865
+ if (auth.state === 'signed_in' && !pendingOrganization) {
2866
+ try { overviewSqm = await readSqmConnectionObservation({ endpoint: process.env.SIMY_SQM_BUNDLE_URL?.trim() || new URL('sqm/knowledge-bundle', resolveWebApiBaseUrl(apiOrigin, overviewSession?.api_base_url)).href,
2867
+ accountId: overviewSession?.account_id || overviewSession?.auth_user_id || overviewSession?.device_id,
2868
+ organizationId: overviewSession?.organization_id || overviewSession?.org_id }, { root: process.env.SIMY_SQM_CACHE_DIR || join(sessionRoot, 'sqm') }); } catch { /* A missing endpoint must not block the status frame. */ }
2869
+ }
2870
+ // An organization switch/sign-out while reading local metadata must not
2871
+ // publish an old tenant's identity or SQM result in a fresh frame.
2872
+ const overview = connectionOverview({ webOrigin: apiOrigin, session, access: session === overviewSession ? overviewAccess : null,
2873
+ signedIn: session === overviewSession && auth.state === 'signed_in', switching: Boolean(pendingOrganization), sqm: overviewSqm });
2860
2874
  return {
2861
2875
  schemaVersion: 1,
2876
+ connectionOverview: overview,
2862
2877
  sampledAt: new Date().toISOString(),
2863
2878
  version: CLI_VERSION,
2864
2879
  webOrigin: apiOrigin,
@@ -15,6 +15,7 @@ import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { promisify } from "node:util";
17
17
 
18
+ import { daemonRestartCommand } from "./web-origin.js";
18
19
  import { openExternalUrl } from "./browser.js";
19
20
  import {
20
21
  installMacosInstallerRelease,
@@ -1094,7 +1095,7 @@ export function autoUpdateHelpText({ installMode = null, webOrigin = null } = {}
1094
1095
  return [
1095
1096
  "Recommended installation (enables verified CLI auto-update):",
1096
1097
  ` npm install -g ${CLI_PACKAGE_NAME}`,
1097
- " simy --daemon",
1098
+ ` ${daemonRestartCommand(webOrigin)}`,
1098
1099
  ].join("\n");
1099
1100
  }
1100
1101
 
@@ -1131,7 +1132,7 @@ export function updateAction(version, { installMode = "global_npm", webOrigin }
1131
1132
  return {
1132
1133
  id: "install_global_cli",
1133
1134
  label: "Install and restart SIMY",
1134
- command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && simy --daemon`,
1135
+ command: `npm install -g ${CLI_PACKAGE_NAME}@${version} && ${daemonRestartCommand(webOrigin)}`,
1135
1136
  };
1136
1137
  }
1137
1138
 
@@ -1,3 +1,4 @@
1
+ import { normalizeWebOrigin } from "./web-origin.js";
1
2
  import { createDecipheriv, createHash } from "node:crypto";
2
3
 
3
4
  export const BROWSER_HANDOFF_TRANSPORT = "loopback_redirect_v1";
@@ -33,6 +34,14 @@ const copy = ja ? {
33
34
  };
34
35
  document.documentElement.lang = ja ? 'ja' : 'en';
35
36
  const title = document.getElementById('title'), message = document.getElementById('message');
37
+ const environment = document.getElementById('environment');
38
+ const origin = environment.dataset.origin;
39
+ const host = new URL(origin).hostname;
40
+ const label = origin === 'https://app.simy.one' ? (ja ? '本番' : 'Production')
41
+ : origin === 'https://app-dev.simy.one' ? (ja ? '開発' : 'Development')
42
+ : ['localhost', '127.0.0.1', '[::1]'].includes(host) ? (ja ? 'ローカル開発' : 'Local development')
43
+ : (ja ? 'カスタムホスト' : 'Custom host');
44
+ environment.textContent = (ja ? '接続先環境' : 'Connection environment') + ': ' + label + ' · ' + origin;
36
45
  title.textContent = copy.title; message.textContent = copy.waiting;
37
46
  const handoff = new URLSearchParams(location.hash.slice(1)).get('handoff');
38
47
  history.replaceState(null, '', location.pathname);
@@ -54,15 +63,17 @@ history.replaceState(null, '', location.pathname);
54
63
 
55
64
  // A self-contained local page: credentials never enter the URL query, HTTP
56
65
  // request, history or third-party resources. Only the encrypted fragment does.
57
- const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}body[data-state=failed] h1{color:var(--ink)}`;
66
+ const style = `:root{color-scheme:light dark;--bg:#ffffff;--card:#f6f5f4;--ink:#0d0d0d;--muted:#615d59;--line:#e6e6e6;--accent:#0075de} @media(prefers-color-scheme:dark){:root{--bg:#1a1918;--card:#252320;--ink:#f5f3f0;--muted:#b8b3ad;--line:#31302f;--accent:#3b9eff}}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:grid;place-items:center;padding:24px;background:var(--bg);color:var(--ink);font:16px/1.5 system-ui,sans-serif}main{max-width:480px;width:100%;padding:32px;border:1px solid var(--line);border-radius:12px;background:var(--card)}.brand{color:var(--accent);font-weight:600;font-size:14px}h1{font-size:24px;font-weight:600;margin:16px 0}p{color:var(--muted);margin:0}#environment{margin-top:16px;font-size:14px;overflow-wrap:anywhere}body[data-state=failed] h1{color:var(--ink)}`;
58
67
  const hash = value => createHash("sha256").update(value).digest("base64");
59
- export function browserHandoffPage() {
68
+ export function browserHandoffPage(webOrigin) {
69
+ const origin = normalizeWebOrigin(webOrigin);
70
+ const escapedOrigin = origin.replace(/[&<>"']/g, value => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[value]);
60
71
  return {
61
72
  headers: {
62
73
  "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store",
63
74
  "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
64
75
  "Content-Security-Policy": `default-src 'none'; script-src 'sha256-${hash(script)}'; style-src 'sha256-${hash(style)}'; connect-src 'self'; img-src data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`,
65
76
  },
66
- html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
77
+ html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SIMY connection</title><link rel="icon" href="data:,"><style>${style}</style></head><body><main aria-live="polite" aria-atomic="true"><div class="brand">SIMY</div><h1 id="title">Connecting to SIMY</h1><p id="message">Keep SIMY open while connecting.</p><p id="environment" data-origin="${escapedOrigin}">Connection environment: ${escapedOrigin}</p><noscript>JavaScript is required. Return to SIMY and reconnect using a browser with JavaScript enabled.</noscript></main><script>${script}</script></body></html>`,
67
78
  };
68
79
  }
@@ -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.23";
17
+ export const CLI_VERSION = "0.7.25";
18
18
  export const CLI_API_CONTRACT_VERSION = 10;
19
19
 
20
20
  export function narrativeImageCapabilityMetadata() {
@@ -0,0 +1,58 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { hostname } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
+ import { defaultSessionRoot } from './session-store.js';
6
+
7
+ const safeText = (value, limit = 160) => typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, '').slice(0, limit) : null;
8
+ const scopeKey = ({ endpoint, accountId, organizationId }) => {
9
+ if (!endpoint || !accountId || !organizationId) return null;
10
+ try { const url = new URL(endpoint); url.username = ''; url.password = ''; url.search = ''; url.hash = '';
11
+ return createHash('sha256').update(JSON.stringify([url.href, accountId, organizationId])).digest('hex');
12
+ } catch { return null; }
13
+ };
14
+ const observationPath = (scope, root) => { const key = scopeKey(scope); return key ? join(root, 'connection-observations', `${key}.json`) : null; };
15
+
16
+ // Only metadata of an actual online bundle lookup; never credentials, rules,
17
+ // executable checks or inferred cross-organization registrations.
18
+ export async function recordSqmConnectionObservation(scope, result, { root = process.env.SIMY_SQM_CACHE_DIR || join(defaultSessionRoot(), 'sqm'), now = Date.now() } = {}) {
19
+ const target = observationPath(scope, root);
20
+ if (!target || !['cloud', 'cache_revalidated', 'unavailable'].includes(result?.source)) return;
21
+ const bundle = result.bundle;
22
+ const verified = bundle?.organization_id === scope.organizationId && bundle?.system_scope?.organization_id === scope.organizationId;
23
+ const repository = safeText(scope.repository, 200);
24
+ if (!repository || !/^[^/\s]+\/[^/\s]+$/.test(repository)) return;
25
+ const value = { version: 1, state: verified ? 'verified' : 'unavailable', repository,
26
+ checkedAt: new Date(now).toISOString(), expiresAt: verified ? bundle.expires_at : null };
27
+ const temp = `${target}.${randomUUID()}.tmp`;
28
+ try { await mkdir(join(root, 'connection-observations'), { recursive: true, mode: 0o700 });
29
+ await writeFile(temp, JSON.stringify(value), { mode: 0o600 }); await rename(temp, target);
30
+ } finally { await rm(temp, { force: true }); }
31
+ }
32
+
33
+ export async function readSqmConnectionObservation(scope, { root = process.env.SIMY_SQM_CACHE_DIR || join(defaultSessionRoot(), 'sqm'), now = Date.now() } = {}) {
34
+ const target = observationPath(scope, root);
35
+ if (!target) return { state: 'unknown' };
36
+ try {
37
+ const raw = await readFile(target, 'utf8'); if (raw.length > 4096) return { state: 'unknown' };
38
+ const v = JSON.parse(raw), at = Date.parse(v.checkedAt);
39
+ if (v.version !== 1 || !['verified', 'unavailable'].includes(v.state) || !Number.isFinite(at) || at > now ||
40
+ typeof v.repository !== 'string' || !/^[^/\s]+\/[^/\s]+$/.test(v.repository) || v.repository.length > 200) return { state: 'unknown' };
41
+ const current = now - at <= 5 * 60 * 1000 && (v.state !== 'verified' || Date.parse(v.expiresAt) > now);
42
+ return { state: current ? v.state : 'previous', repository: safeText(v.repository, 200), checkedAt: v.checkedAt };
43
+ } catch { return { state: 'unknown' }; }
44
+ }
45
+
46
+ export function connectionEnvironment(origin) {
47
+ try { const url = new URL(origin); if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') return { kind: 'unknown', origin: null };
48
+ return { kind: url.origin === 'https://app.simy.one' ? 'production' : url.origin === 'https://app-dev.simy.one' ? 'development' : 'custom', origin: url.origin };
49
+ } catch { return { kind: 'unknown', origin: null }; }
50
+ }
51
+
52
+ export function connectionOverview({ webOrigin, session, access, signedIn, switching, sqm, deviceName = hostname() }) {
53
+ const scopeReady = signedIn && !switching && session?.organization_id && access?.organizationId === session.organization_id && access?.state === 'ready';
54
+ return { device: { name: safeText(deviceName) || null }, environment: connectionEnvironment(webOrigin),
55
+ organization: { name: signedIn && access?.organizationId === session?.organization_id ? safeText(access?.organizationName) : null,
56
+ state: switching ? 'switching' : scopeReady ? 'ready' : 'unknown' },
57
+ sqm: scopeReady ? (sqm || { state: 'unknown' }) : { state: 'unknown' } };
58
+ }
@@ -67,6 +67,7 @@ export function desktopAuthStatus(session, { now = Date.now(), webOrigin = null
67
67
  state: valid ? "signed_in" : exists ? "expired" : "signed_out",
68
68
  expiresAt: typeof session?.expires_at === "string" ? session.expires_at : null,
69
69
  userLabel: sessionUserLabel(session),
70
+ userEmail: valid ? sessionUserEmail(session) : null,
70
71
  userId: valid ? (session.account_id ?? session.auth_user_id ?? null) : null,
71
72
  };
72
73
  }
@@ -323,6 +324,14 @@ function reportPresentation(result, allowSchemaLess = false) {
323
324
  } catch { return null; }
324
325
  }
325
326
 
327
+ function sessionUserEmail(session) {
328
+ for (const key of ["email", "user_label"]) {
329
+ const value = session?.[key];
330
+ if (typeof value === "string" && value.trim().length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())) return value.trim();
331
+ }
332
+ return null;
333
+ }
334
+
326
335
  function sessionUserLabel(session) {
327
336
  if (!session) return null;
328
337
  for (const key of ["user_label", "display_name", "email", "account_id", "auth_user_id"]) {
@@ -1,3 +1,4 @@
1
+ import { recordSqmConnectionObservation } from '../connection-overview.js';
1
2
  import { ADVISORY_GUIDANCE_CAPABILITY, isAdvisoryGuidance } from './advisory-guidance.js';
2
3
  import { safeSqmDiagnostic } from "./diagnostics.js";
3
4
  import { createHash, createPublicKey } from "node:crypto";
@@ -25,13 +26,40 @@ export class SqmIncompatibleCliError extends Error {
25
26
  }
26
27
 
27
28
  export async function loadKnowledgeBundle({
28
- repository,
29
- cliVersion,
30
29
  endpoint = process.env.SIMY_SQM_BUNDLE_URL,
31
30
  token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN,
32
31
  accountId = process.env.SIMY_SQM_ACCOUNT_ID || (token ? `token:${createHash("sha256").update(token).digest("hex")}` : "anonymous"),
33
32
  organizationId = process.env.SIMY_SQM_ORGANIZATION_ID,
34
33
  cacheRoot = process.env.SIMY_SQM_CACHE_DIR || path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm"),
34
+ ...options
35
+ }) {
36
+ const context = { ...options, endpoint, token, accountId, organizationId, cacheRoot };
37
+ const observe = async result => {
38
+ if (options.offline || !endpoint || !accountId || !organizationId) return;
39
+ try { await recordSqmConnectionObservation(context, result, { root: cacheRoot, now: options.now }); }
40
+ catch { /* Display metadata must not change SQM behavior. */ }
41
+ };
42
+ try {
43
+ const result = await loadKnowledgeBundleImpl(context);
44
+ // An online fallback retains usable knowledge, but does not confirm the
45
+ // current connection. Offline/cache-only reads never refresh this record.
46
+ await observe(result.source === 'stale_cache' ? { source: 'unavailable' } : result);
47
+ return result;
48
+ } catch (error) {
49
+ // Cancellation is an intentional stop, not a failed connection attempt.
50
+ if (!options.signal?.aborted) await observe({ source: 'unavailable' });
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ async function loadKnowledgeBundleImpl({
56
+ repository,
57
+ cliVersion,
58
+ endpoint,
59
+ token,
60
+ accountId,
61
+ organizationId,
62
+ cacheRoot,
35
63
  ttlMs = numberEnv("SIMY_SQM_CACHE_TTL_MS", DEFAULT_TTL_MS),
36
64
  offline = false,
37
65
  refresh = false,
@@ -1,9 +1,22 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { resolveBackendExecutable } from '../backend-executable.js';
4
5
  import { runProviderCommand } from './provider-command.js';
5
6
  import { CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
6
7
 
8
+ // The managed marketplace selects the inventory scope, never installation or
9
+ // enablement evidence. Those still come from the provider's actual response.
10
+ async function managedCodexMarketplace({ home, signal }) {
11
+ try {
12
+ const bytes = await readFile(join(home, '.agents', 'plugins', 'marketplace.json'), { encoding: 'utf8', signal });
13
+ if (Buffer.byteLength(bytes) > 64 * 1024) return null;
14
+ const manifest = JSON.parse(bytes);
15
+ return /^[A-Za-z0-9_-]+$/.test(manifest?.name || '') && Array.isArray(manifest.plugins) &&
16
+ manifest.plugins.some(entry => entry?.name === 'simy') ? manifest.name : null;
17
+ } catch { return null; }
18
+ }
19
+
7
20
  export const unavailablePluginStatus = () => ({ state: 'unknown', installed: null, enabled: null });
8
21
 
9
22
  // Provider inventories describe the actual installation and enablement. Managed
@@ -15,7 +28,7 @@ export async function inspectProviderPlugins({ home = homedir(),
15
28
  claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
16
29
  resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
17
30
  timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
18
- onDiagnostic = () => {},
31
+ onDiagnostic = () => {}, readCodexMarketplace = managedCodexMarketplace,
19
32
  } = {}) {
20
33
  if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
21
34
  const inspect = async provider => {
@@ -30,7 +43,12 @@ export async function inspectProviderPlugins({ home = homedir(),
30
43
  const executable = await resolveExecutable(provider, { signal, timeoutMs });
31
44
  if (signal?.aborted) return unavailable('cancelled');
32
45
  if (!executable) return unavailable('cli_missing');
33
- const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
46
+ const marketplace = provider === 'codex' ? await readCodexMarketplace({ home, signal }) : null;
47
+ if (signal?.aborted) return unavailable('cancelled');
48
+ // A global inventory may contact unrelated remote marketplaces. Query the
49
+ // configured SIMY source so their network delays cannot hide local state.
50
+ const args = ['plugin', 'list', '--json', ...(marketplace ? ['--marketplace', marketplace] : [])];
51
+ const { stdout } = await run(executable, args, {
34
52
  timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
35
53
  env: { ...process.env, HOME: home,
36
54
  ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) },
@@ -39,7 +57,7 @@ export async function inspectProviderPlugins({ home = homedir(),
39
57
  const entries = provider === 'codex' ? payload?.installed : payload;
40
58
  if (!Array.isArray(entries)) return unavailable('invalid_inventory');
41
59
  const matches = entries.filter(entry => provider === 'codex'
42
- ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
60
+ ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId) && (!marketplace || entry.pluginId === `simy@${marketplace}`)
43
61
  : entry?.id === CLAUDE_PLUGIN_SELECTOR);
44
62
  // Prefer the account installation; report a project-only install explicitly
45
63
  // rather than silently treating it as an installation for every project.
package/src/web-origin.js CHANGED
@@ -44,3 +44,10 @@ export function isRequestOriginAllowed(requestOrigin, webOrigin) {
44
44
  return false;
45
45
  }
46
46
  }
47
+
48
+ // Explicitly retain the resolved host when a command is copied to a fresh shell.
49
+ export function daemonRestartCommand(webOrigin) {
50
+ const origin = resolveWebOrigin(webOrigin);
51
+ const quotedOrigin = '"' + origin.replace(/["\\$`]/g, "\\$&") + '"';
52
+ return `simy --daemon --host ${quotedOrigin}`;
53
+ }