@awak-app/simy-cli 0.7.23 → 0.7.26

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 (46) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +16 -1
  4. package/src/auto-update.js +3 -2
  5. package/src/browser-handoff.js +14 -3
  6. package/src/cli-contract.js +1 -1
  7. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
  9. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  10. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  11. package/src/connection-health.js +253 -0
  12. package/src/connection-overview.js +58 -0
  13. package/src/connection-presentation.js +85 -0
  14. package/src/connection-recovery.js +102 -0
  15. package/src/console/app.js +60 -6
  16. package/src/console/commands.js +3 -0
  17. package/src/console/index.js +1 -0
  18. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  19. package/src/delivery-hooks/supervisor.js +14 -14
  20. package/src/desktop-host.js +43 -8
  21. package/src/desktop-status.js +9 -0
  22. package/src/direct-executor.js +8 -2
  23. package/src/doctor.js +1 -0
  24. package/src/index.js +12 -2
  25. package/src/remote-connection-guide.js +222 -0
  26. package/src/session-supervision/analysis-manager.js +24 -4
  27. package/src/session-supervision/automatic-continuation.js +69 -18
  28. package/src/session-supervision/codex-app-server.js +52 -7
  29. package/src/session-supervision/codex-peer-opinion.js +27 -5
  30. package/src/session-supervision/consistency-auditor.js +1 -1
  31. package/src/session-supervision/continuation-analysis.js +7 -3
  32. package/src/session-supervision/decision-presentation.js +1 -1
  33. package/src/session-supervision/index.js +21 -13
  34. package/src/session-supervision/outcome-plan.js +76 -0
  35. package/src/session-supervision/purpose-instruction.js +28 -0
  36. package/src/session-supervision/recovery-frontier.js +14 -4
  37. package/src/session-supervision/remote-command-queue.js +15 -0
  38. package/src/session-supervision/remote-control.js +3 -0
  39. package/src/session-supervision/remote-hosts.js +70 -19
  40. package/src/session-supervision/remote-managed-delivery.js +94 -0
  41. package/src/session-supervision/remote-session-list.js +1 -1
  42. package/src/session-supervision/statistics.js +10 -3
  43. package/src/session-supervision/work-state.js +5 -0
  44. package/src/sqm/bundle-store.js +30 -2
  45. package/src/sqm/provider-plugin-status.js +21 -3
  46. package/src/web-origin.js +7 -0
@@ -0,0 +1,94 @@
1
+ import { mkdir, readFile, writeFile, rename, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { isDeepStrictEqual } from 'node:util';
5
+
6
+ const sessionId = value => typeof value === 'string' && /^[a-f0-9-]{36}$/.test(value);
7
+ const actions = new Set(['delivery_resume', 'delivery_pause', 'delivery_stop']);
8
+
9
+ // A host-local adapter, not an execution controller. Supply the installed
10
+ // runtime's resumeSession/controlDelivery, record reader and status projection.
11
+ // The existing managed supervisor remains the only owner of claims/dispatches.
12
+ export async function createRemoteManagedDelivery({ root, stateRoot, scope, authorize, select,
13
+ readRecord, listRecords, resumeSession, controlDelivery, deliveryHookStatus }) {
14
+ const automaticRoot = join(stateRoot, 'targets');
15
+ const manualRoot = join(stateRoot, 'manual-targets');
16
+ await mkdir(automaticRoot, { recursive: true, mode: 0o700 });
17
+ await mkdir(manualRoot, { recursive: true, mode: 0o700 });
18
+ async function target(directory, session) {
19
+ if (!sessionId(session)) throw new Error('Select a known conversation');
20
+ try { return JSON.parse(await readFile(join(directory, session + '.json'), 'utf8')); }
21
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
22
+ }
23
+ const read = (provider, session) => readRecord(root, provider, session);
24
+ const matches = (binding, record) => binding && record?.run?.id === binding.runId &&
25
+ record.run.managed?.taskId === binding.taskId;
26
+ async function guard(candidate) {
27
+ if (candidate?.provider !== 'codex' || !sessionId(candidate.session)) throw new Error('Select a known conversation');
28
+ const automatic = await target(automaticRoot, candidate.session);
29
+ const record = await read('codex', candidate.session);
30
+ if (automatic) {
31
+ await authorize(automatic.scope);
32
+ if (!record?.autoContinue || record.autoContinueEpoch !== automatic.epoch) throw new Error('Continuation consent changed');
33
+ return automatic;
34
+ }
35
+ const manual = await target(manualRoot, candidate.session);
36
+ if (!matches(manual, record)) throw new Error('Conversation not selected');
37
+ await authorize(manual.scope);
38
+ return manual;
39
+ }
40
+ async function records() {
41
+ const selectedScope = scope();
42
+ if (!selectedScope) return [];
43
+ const rows = new Map();
44
+ for (const [directory, automatic] of [[automaticRoot, true], [manualRoot, false]]) {
45
+ for (const file of await readdir(directory)) {
46
+ if (!file.endsWith('.json') || !sessionId(file.slice(0, -5))) continue;
47
+ const session = file.slice(0, -5), binding = await target(directory, session);
48
+ if (!binding || !isDeepStrictEqual(binding.scope, selectedScope)) continue;
49
+ const record = await read('codex', session);
50
+ if (automatic ? record?.autoContinue && record.autoContinueEpoch === binding.epoch : matches(binding, record))
51
+ rows.set(session, record);
52
+ }
53
+ }
54
+ return [...rows.values()];
55
+ }
56
+ async function control(command) {
57
+ if (!actions.has(command?.action) || command.provider !== 'codex' || !sessionId(command.session) ||
58
+ typeof command.taskId !== 'string' || !command.taskId) throw new Error('Unsupported remote command');
59
+ const selectedScope = await authorize();
60
+ const candidate = await select(command);
61
+ if (candidate?.provider !== command.provider || candidate.session !== command.session) throw new Error('Select a known conversation');
62
+ const automatic = await target(automaticRoot, command.session);
63
+ if (automatic || command.action !== 'delivery_resume') await guard(command);
64
+ const record = await read(command.provider, command.session);
65
+ if (record?.run?.managed?.taskId !== command.taskId ||
66
+ command.expectedRunId !== undefined && record.run.id !== command.expectedRunId)
67
+ throw new Error('Task changed; refresh before operating');
68
+ await authorize(selectedScope);
69
+ // Forward only the native control contract. A command cannot provide an
70
+ // admission, resume observation, permission override, or dispatch receipt.
71
+ await (command.action === 'delivery_resume' ? resumeSession : controlDelivery)({
72
+ root, provider: command.provider, session: command.session, taskId: command.taskId,
73
+ expectedRunId: command.expectedRunId ?? record.run.id, expectedRevision: command.expectedRevision,
74
+ action: command.action.slice('delivery_'.length),
75
+ });
76
+ if (!automatic && command.action === 'delivery_resume') {
77
+ await authorize(selectedScope);
78
+ if (!matches({ runId: record.run.id, taskId: command.taskId }, await read(command.provider, command.session)))
79
+ throw new Error('Task changed; refresh before operating');
80
+ const file = join(manualRoot, command.session + '.json');
81
+ const temporary = file + '.' + randomUUID() + '.tmp';
82
+ await writeFile(temporary, JSON.stringify({ scope: selectedScope, runId: record.run.id, taskId: command.taskId }) + '\n', { mode: 0o600 });
83
+ await rename(temporary, file);
84
+ }
85
+ }
86
+ async function status() {
87
+ const selectedScope = await authorize();
88
+ // Supplying records is essential: display must not reconcile/mutate runs.
89
+ const result = await deliveryHookStatus({ root, records: await listRecords(root) });
90
+ await authorize(selectedScope);
91
+ return result;
92
+ }
93
+ return { control, guard, records, status, readCurrentRecord: read };
94
+ }
@@ -6,7 +6,7 @@ import { readHosts } from './remote-hosts.js';
6
6
  // This read-only command never boots a remote desktop host or supervisor.
7
7
  export async function readRemoteSessionLists({ root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
8
8
  providers, includeArchived = false, signal, spawnImpl = spawn, timeoutMs = 30_000 } = {}) {
9
- const hosts = await readHosts(root);
9
+ const hosts = (await readHosts(root)).filter(host => host.enabled !== false);
10
10
  return Promise.all(hosts.map(async host => {
11
11
  try {
12
12
  signal?.throwIfAborted();
@@ -61,7 +61,13 @@ class StatisticsAccumulator {
61
61
  if (!row || typeof row !== 'object' || Array.isArray(row)) { this.missing = true; return; }
62
62
  if (this.provider === 'codex' && row.type === 'session_meta') {
63
63
  if (row.payload?.id !== this.session) this.wrongSession = true;
64
- else this.matched = true;
64
+ else {
65
+ this.matched = true;
66
+ // Creation can precede the first usage/activity record. Forked history
67
+ // can also contain older events belonging to the parent conversation.
68
+ const created = Date.parse(row.payload?.timestamp ?? row.timestamp);
69
+ if (Number.isFinite(created)) this.startedAt = created;
70
+ }
65
71
  }
66
72
  if (this.provider === 'claude') {
67
73
  if (row.sessionId !== this.session || row.isSidechain === true) return;
@@ -104,9 +110,10 @@ class StatisticsAccumulator {
104
110
  }
105
111
  }
106
112
  *finishSteps(now, incomplete = false, coverage = {}) {
107
- if (!this.matched || this.wrongSession || this.earliest > now) return unavailableStatistics();
113
+ const first = this.startedAt ?? this.earliest;
114
+ if (!this.matched || this.wrongSession || first > now) return unavailableStatistics();
108
115
  const missing = this.missing || incomplete;
109
- const start = Math.max(this.earliest, now - WINDOW_MS), end = now;
116
+ const start = Math.max(first, now - WINDOW_MS), end = now;
110
117
  const hours = new Map();
111
118
  for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
112
119
  let batch = 0;
@@ -28,7 +28,12 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
28
28
  return result('approval', '承認待ち', candidate.decisionRequest?.context || '実行する操作の確認が必要です。', 'review_approval', true);
29
29
  if (question || phase === 'needs_clarification' || candidate.analysis?.state === 'needs_user' || candidate.decisionRequest?.kind === 'question')
30
30
  return result('question', '回答待ち', candidate.decisionRequest?.question || '続けるために回答が必要です。', 'answer', true);
31
+ if (candidate.autoContinue === true && ['starting', 'recovering'].includes(candidate.autoContinueStatus))
32
+ return result(candidate.autoContinueStatus, candidate.autoContinueStatus === 'starting' ? '自動継続を準備中' : '自動復旧中',
33
+ candidate.autoContinueReason || '自動継続はオンで保存されています。');
31
34
  if ((run?.autoRecovery || run?.managed?.autoRecovery) && ['recovery_required', 'auto_resuming'].includes(run.state)) return result('checking', 'AIが状況確認中', '自動復旧の結果を待っています。');
35
+ if (candidate.autoContinue === true && candidate.autoContinueStatus === 'supervising' && run?.state === 'verification_pending')
36
+ return result('checking', '結果を確認中', '作業の結果を自動で確認しています。');
32
37
  if (['failed', 'recovery_required', 'budget_exhausted'].includes(run?.state) || candidate.analysis?.state === 'failed' || candidate.autoContinueStatus === 'error')
33
38
  return result('error', '対応が必要', run?.reason || '作業が中断されています。', run?.canResume || run?.canReplan ? 'resume' : 'open_original', true);
34
39
  if (run?.dependencyWait || run?.managed?.dependencyWait) return result('waiting', '依存条件待ち', '条件が変わるまで待機します。');
@@ -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
+ }