@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
@@ -8,6 +8,7 @@ import { Box, Spacer, Text, useApp, useInput, useStdout } from "ink";
8
8
  import { consoleHelpLines, parseConsoleCommand } from "./commands.js";
9
9
  import { summarizeCodingLoopEvent } from "../orchestrator/presentation.js";
10
10
  import { isLocalRepositoryApprovalPending } from "../runner.js";
11
+ import { connectionStartupHint, connectionGuideLines } from '../remote-connection-guide.js';
11
12
 
12
13
  const TERMINAL_STATES = new Set([
13
14
  "merge_ready",
@@ -19,7 +20,7 @@ const TERMINAL_STATES = new Set([
19
20
  ]);
20
21
  const NEW_TASK = "__new_task__";
21
22
 
22
- export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
23
+ export function AgenticLoopConsole({ agent, onQuit = () => {}, remoteConnections = { hosts: [], warnings: [] } }) {
23
24
  const { exit } = useApp();
24
25
  const { stdout } = useStdout();
25
26
  const [terminal, setTerminal] = useState(() => terminalSize(stdout));
@@ -29,7 +30,10 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
29
30
  const [inputMode, setInputMode] = useState(() => runs.length === 0);
30
31
  const [input, setInput] = useState("");
31
32
  const [busy, setBusy] = useState(false);
32
- const [notice, setNotice] = useState("");
33
+ const [notice, setNotice] = useState(() => connectionStartupHint(remoteConnections));
34
+ const [connectionsOpen, setConnectionsOpen] = useState(false);
35
+ const [connectionIndex, setConnectionIndex] = useState(0);
36
+ const [connectionScroll, setConnectionScroll] = useState(0);
33
37
  const [showHelp, setShowHelp] = useState(false);
34
38
  const [confirmStop, setConfirmStop] = useState(false);
35
39
  const [detailOpen, setDetailOpen] = useState(false);
@@ -71,12 +75,16 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
71
75
  const hasNextAction = Boolean(
72
76
  selectedRun && nextActionForRun(selectedRun, isRepositoryMissing(selectedRun)),
73
77
  );
78
+ const effectiveNotice = connectionsOpen ? '' : notice;
74
79
  const actionRows =
75
80
  1 +
76
81
  (hasNextAction ? 1 : 0) +
77
- (notice || busy ? 1 : 0) +
82
+ (effectiveNotice || busy ? wrapTerminalText(`STATUS ${effectiveNotice || 'Please wait…'}`, terminal.columns).length : 0) +
78
83
  (visibleUpdateNotice(cliUpdate) ? 1 : 0);
79
84
  const mainHeight = Math.max(8, terminal.rows - 5 - actionRows);
85
+ const connectionRows = remoteConnectionRows(remoteConnections, connectionIndex, terminal.columns);
86
+ const connectionPageSize = Math.max(1, mainHeight - 4);
87
+ const connectionMaxScroll = Math.max(0, connectionRows.length - connectionPageSize);
80
88
  const detailInnerWidth = Math.max(20, terminal.columns - 4);
81
89
  const detailRows = useMemo(
82
90
  () => buildRunDetailRows(selectedRun, detailInnerWidth),
@@ -117,6 +125,12 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
117
125
  setShowHelp(true);
118
126
  return;
119
127
  }
128
+ if (command.type === 'connections') {
129
+ setConnectionsOpen(true);
130
+ setConnectionScroll(0);
131
+ setShowHelp(false);
132
+ return;
133
+ }
120
134
  if (command.type === "new") {
121
135
  setSelectedId(NEW_TASK);
122
136
  setDetailOpen(false);
@@ -336,6 +350,17 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
336
350
 
337
351
  const inputHandlerRef = useRef(() => {});
338
352
  inputHandlerRef.current = (character, key) => {
353
+ if (connectionsOpen) {
354
+ if (key.escape || (key.ctrl && character === 'c')) {
355
+ setConnectionsOpen(false);
356
+ if (!selectedRun) setInputMode(true);
357
+ } else if (key.upArrow || key.downArrow) {
358
+ setConnectionIndex(current => Math.max(0, Math.min(Math.max(0, remoteConnections.hosts.length - 1), current + (key.upArrow ? -1 : 1))));
359
+ setConnectionScroll(0);
360
+ } else if (key.pageDown) setConnectionScroll(current => Math.min(connectionMaxScroll, current + connectionPageSize));
361
+ else if (key.pageUp) setConnectionScroll(current => Math.max(0, current - connectionPageSize));
362
+ return;
363
+ }
339
364
  if (scanPrompt) {
340
365
  if (key.escape || String(character).toLowerCase() === "n") {
341
366
  setScanPrompt(null);
@@ -505,7 +530,9 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
505
530
  jsx(Box, {
506
531
  marginTop: 1,
507
532
  height: mainHeight,
508
- children: showHelp
533
+ children: connectionsOpen
534
+ ? jsx(RemoteConnectionsGuide, { rows: connectionRows, scroll: Math.min(connectionScroll, connectionMaxScroll), pageSize: connectionPageSize, width: terminal.columns })
535
+ : showHelp
509
536
  ? jsx(Help, { width: terminal.columns })
510
537
  : scanPrompt
511
538
  ? jsx(RepositoryScanConsent, {
@@ -548,10 +575,10 @@ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
548
575
  inputMode,
549
576
  input,
550
577
  busy,
551
- notice,
578
+ notice: effectiveNotice,
552
579
  confirmStop,
553
580
  repositoryMissing: isRepositoryMissing(selectedRun),
554
- modalOpen: Boolean(scanPrompt || repositoryPicker),
581
+ modalOpen: Boolean(scanPrompt || repositoryPicker || connectionsOpen),
555
582
  cliUpdate,
556
583
  width: terminal.columns,
557
584
  }),
@@ -830,6 +857,7 @@ function Help({ width }) {
830
857
  const groups = [
831
858
  ["TASK", consoleHelpLines().slice(0, 8)],
832
859
  ["RUN", consoleHelpLines().slice(8, 15)],
860
+ ["CONNECTION", consoleHelpLines().filter(line => line.startsWith('/connections'))],
833
861
  ];
834
862
  return jsxs(Box, {
835
863
  width,
@@ -848,6 +876,32 @@ function Help({ width }) {
848
876
  });
849
877
  }
850
878
 
879
+ function remoteConnectionRows(discovery, selected, width) {
880
+ const host = discovery.hosts[selected];
881
+ const start = Math.max(0, selected - 2);
882
+ return [
883
+ '保存されているパソコン(まだ接続できるかは未確認)',
884
+ 'SIMYがつながらないときは、別のターミナルで simy connections recover を実行してください。',
885
+ ...discovery.hosts.slice(start, start + 5).map((item, index) =>
886
+ `${start + index === selected ? '›' : ' '} ${item.label} (${item.alias})${item.enabled ? '' : ' · 接続解除済み'}`),
887
+ ...discovery.warnings,
888
+ '',
889
+ ...(host ? connectionGuideLines(host.alias) : [
890
+ '保存されているパソコンがありません。接続設定をした人に接続先名を確認してください。',
891
+ '一覧: simy connections',
892
+ 'このパソコンで simy connections を実行すると、使い方が表示されます。',
893
+ ]),
894
+ ].flatMap(line => wrapTerminalText(line, width - 4));
895
+ }
896
+
897
+ function RemoteConnectionsGuide({ rows, scroll, pageSize, width }) {
898
+ return jsxs(Box, { width, borderStyle: 'single', borderColor: 'cyan', paddingX: 1, flexDirection: 'column', children: [
899
+ jsx(Text, { bold: true, color: 'cyan', children: '別のパソコンのSIMYにつなぐ' }),
900
+ ...rows.slice(scroll, scroll + pageSize).map((line, index) => jsx(Text, { children: line }, `guide-${index}`)),
901
+ jsx(Text, { dimColor: true, children: '↑/↓でパソコンを選択 · PgUp/PgDnで前後を表示 · Escで戻る' }),
902
+ ] });
903
+ }
904
+
851
905
  function ActionBar({
852
906
  run,
853
907
  detailOpen,
@@ -16,6 +16,7 @@ const HELP_LINES = [
16
16
  "/resume Resume a paused executor process",
17
17
  "/stop Stop the selected run",
18
18
  "/update Open the available installer download",
19
+ "/connections 別のパソコンへの接続方法を見る",
19
20
  ];
20
21
 
21
22
  export function parseConsoleCommand(value) {
@@ -29,6 +30,8 @@ export function parseConsoleCommand(value) {
29
30
  switch (command) {
30
31
  case "/new":
31
32
  return { type: "new" };
33
+ case "/connections":
34
+ return { type: "connections" };
32
35
  case "/repos":
33
36
  return { type: "repositories" };
34
37
  case "/scan":
@@ -8,6 +8,7 @@ export async function runAgenticLoopConsole(agent, options = {}) {
8
8
  jsx(AgenticLoopConsole, {
9
9
  agent,
10
10
  onQuit: options.onQuit,
11
+ remoteConnections: options.remoteConnections,
11
12
  }),
12
13
  {
13
14
  stdin: options.stdin ?? process.stdin,
@@ -63,6 +63,29 @@ export function pinnedCliEntry(root) {
63
63
  return loader + "\nif (!await selectRuntime()) { process.stderr.write('SIMY selected runtime unavailable.\\n'); process.exitCode = 1; }\n";
64
64
  }
65
65
 
66
+ // Older SSH clients resolve the remote entry beside the real CLI executable.
67
+ // A shell launcher lives in bin rather than src, so expose a compatible entry
68
+ // there while retaining the same verified pin and normal remote startup.
69
+ export function pinnedRemoteEntry(root) {
70
+ const loader = pinnedCliEntry(root).replace('src/index.js', 'src/session-supervision/remote-entry.js');
71
+ // CommonJS-compatible .js works on Node 20 without a surrounding package.json.
72
+ const url = 'data:text/javascript,' + encodeURIComponent(loader);
73
+ return `// SIMY managed remote runtime route.\nvoid import(${JSON.stringify(url)}).catch(() => { process.stderr.write('SIMY remote runtime unavailable.\\n'); process.exitCode = 1; });\n`;
74
+ }
75
+
76
+ export async function installPinnedRemoteEntry(root) {
77
+ // Like the service entry, this only writes a loader. The loader validates
78
+ // the pin and full source fingerprint every time it is actually executed.
79
+ const directory = join(root, 'bin/session-supervision');
80
+ await mkdir(directory, { recursive: true, mode: 0o700 });
81
+ const entry = join(directory, 'remote-entry.js'), temporary = `${entry}.${randomUUID()}.tmp`;
82
+ try {
83
+ await writeFile(temporary, pinnedRemoteEntry(resolve(root)), { mode: 0o600 });
84
+ await rename(temporary, entry);
85
+ } finally { await rm(temporary, { force: true }); }
86
+ return entry;
87
+ }
88
+
66
89
  export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
67
90
  await pinnedWorkflowSource({ root });
68
91
  const folder = join(codexHome, 'plugins/cache/personal/simy');
@@ -93,6 +116,7 @@ export async function routeLegacyHooks({ root, codexHome = process.env.CODEX_HOM
93
116
  // Also used during service installation after a handover performed by an older
94
117
  // watcher which did not know this launcher. Atomic replacement keeps startup safe.
95
118
  export async function installPinnedCliEntry(root) {
119
+ await installPinnedRemoteEntry(root);
96
120
  const entry = join(root, 'bin/simy-runtime.mjs');
97
121
  await mkdir(join(root, 'bin'), { recursive: true, mode: 0o700 });
98
122
  const temporary = `${entry}.${randomUUID()}.tmp`;
@@ -1,5 +1,5 @@
1
1
  import { captureDispatchProvenance } from '../update-detachment/provenance.js';
2
- import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, confirmDispatch, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
2
+ import { captureDispatchOwner, assertTaskAdmission, dispatchWithFence, queueDispatchReceipt, confirmDispatch, flushDispatchIntake, rootFromDelivery, observeDispatchEvent } from '../update-detachment/dispatch-gate.js';
3
3
  import { bindCompletionArtifacts } from '../session-supervision/completion-artifacts.js';
4
4
  import { recoveryWorkProposal, admitRecoveryWork } from '../session-supervision/recovery-frontier.js';
5
5
  import { safeCheckpointDiagnostic } from './checkpoint-diagnostic.js';
@@ -11,9 +11,7 @@ import { workspaceIdentity, reviewWorkspaceIdentity } from '../session-supervisi
11
11
  import { searchEnabled, failureLimitReached } from '../codex-plugins/simy/hooks/purpose-search.mjs';
12
12
  import { reconcilePurposeTasks } from '../session-supervision/purpose-recovery.js';
13
13
  import { recordRecoveryIncident, recordRecoveryStage, settleRecoveryIncidents, resolveRecoveryReceipt } from '../codex-plugins/simy/hooks/recovery-incident.mjs';
14
- import { runConsistencyMonitor } from './consistency-monitor.js';
15
- import { desktopRuntimeStatus } from './desktop-runtime.js';
16
- import { auditConsistencyTask, recoverOutcomeAuditLease } from '../session-supervision/consistency-auditor.js';
14
+ import { recoverOutcomeAuditLease } from '../session-supervision/consistency-auditor.js';
17
15
  import { minimalSufficientChangeInstruction } from '../codex-plugins/simy/hooks/minimal-sufficient.mjs';
18
16
  import { requireProviderLink } from '../provider-links.js';
19
17
  import { spawn } from 'node:child_process';
@@ -28,7 +26,9 @@ import { createTaskPool } from '../codex-plugins/simy/hooks/loop-kernel.mjs';
28
26
 
29
27
  const MAX_OUTPUT = 64 * 1024;
30
28
  const SAFE_PROVIDER_CODES = new Set(['authentication_required', 'identity', 'permission_narrowed', 'permission_restore', 'turn_failed', 'checkpoint',
31
- 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval', 'provider_question', 'usage_unavailable']);
29
+ 'closed', 'timeout', 'unsupported', 'unavailable', 'manual_provider_action', 'checkpoint_rejected', 'provider_approval', 'provider_question', 'usage_unavailable',
30
+ 'state_busy', 'dispatch_detached', 'owner_fenced', 'task_admission_blocked', 'task_admission_identity_required',
31
+ 'task_identity_conflict', 'attempt_already_recorded', 'identity_unavailable']);
32
32
  function safeProviderCode(code) { return SAFE_PROVIDER_CODES.has(code) ? code : 'provider_error'; }
33
33
  function providerFailureReason(code) {
34
34
  if (code === 'permission_restore') return 'Codex sandbox restoration could not be confirmed; inspect the original conversation before resuming.';
@@ -36,6 +36,8 @@ function providerFailureReason(code) {
36
36
  if (code === 'identity') return 'Codex session identity, workspace, or model changed; inspect before resuming.';
37
37
  if (code === 'checkpoint' || code === 'checkpoint_rejected') return 'Codex returned an invalid managed checkpoint; inspect its plan before resuming.';
38
38
  if (code === 'turn_failed') return 'The Codex turn did not complete; inspect the original conversation.';
39
+ if (code === 'state_busy') return 'The local dispatch state is busy; reconcile its lock before another attempt.';
40
+ if (['dispatch_detached', 'owner_fenced', 'task_admission_blocked', 'task_admission_identity_required', 'task_identity_conflict', 'identity_unavailable'].includes(code)) return `Local dispatch admission requires reconciliation (${code}); preserve the existing task and provider authority.`;
39
41
  return 'Codex recovery process failed; inspect its checkpoint and original session before the next bounded recovery.';
40
42
  }
41
43
  // One fresh lease read at a time; stopping the watch also fences late reads so
@@ -642,19 +644,17 @@ export async function tickManagedTasks({ root, webOrigin, now = Date.now(), exec
642
644
  }
643
645
 
644
646
  export function createManagedSupervisor(options) {
645
- let running = null, auditing = null, closed = false;
647
+ let running = null, closed = false;
646
648
  const controller = new AbortController();
647
649
  const workers = createTaskPool({ concurrency: options.concurrency ?? 4 });
648
650
  const readCandidates = options.readRecords || (() => listRecords(options.root));
649
651
  return {
650
652
  tick() {
651
653
  if (closed) return Promise.resolve();
652
- if (!auditing) auditing = (async () => {
653
- const runtime = await desktopRuntimeStatus({ root: dirname(options.root) }).catch(() => ({ state: 'runtime_observation_failed' }));
654
- await runConsistencyMonitor({ root: options.root, runtime, readRecords: options.readRecords || (() => listRecords(options.root)),
655
- onAudit: async (record, findings, request) => closed || await options.shouldYield?.() ? undefined : auditConsistencyTask(record, findings, request, {
656
- root: options.root, signal: controller.signal, authorize: options.authorizeDispatch || (async () => {}) }) });
657
- })().catch(() => {}).finally(() => { auditing = null; });
654
+ try { flushDispatchIntake(rootFromDelivery(options.root)); }
655
+ catch (error) { if (error.code !== 'state_busy') return Promise.reject(error); }
656
+ // Continue actual task work and its result checks. Starting an unrelated
657
+ // periodic audit here could acquire a lease that prevents that work.
658
658
  if (!running) running = (async () => {
659
659
  for (const record of await readCandidates()) {
660
660
  if (closed || await options.shouldYield?.()) break;
@@ -680,8 +680,8 @@ export function createManagedSupervisor(options) {
680
680
  },
681
681
  status() { return workers.snapshot(); },
682
682
  async idle() { await running; await workers.idle(); },
683
- async drain() { closed = true; await Promise.allSettled([running, auditing, workers.stop()]); },
684
- async close() { closed = true; controller.abort(); await Promise.allSettled([running, auditing, workers.stop()]); },
683
+ async drain() { closed = true; await Promise.allSettled([running, workers.stop()]); },
684
+ async close() { closed = true; controller.abort(); await Promise.allSettled([running, workers.stop()]); },
685
685
  };
686
686
  }
687
687
 
@@ -457,7 +457,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
457
457
  if (!sessionRefresh && (force || Date.now() - sessionRefreshedAt >= 15_000)) {
458
458
  const requestedScope = accessScope(currentAccess);
459
459
  const controller = discoveryController = new AbortController();
460
- sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, includeArchived: true, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
460
+ const attempt = { id: randomUUID(), requestedAt: Date.now(), state: 'pending' };
461
+ sessionStatus = { ...sessionStatus, discoveryAttempt: attempt };
462
+ sessionRefresh = Promise.resolve().then(() => { if (controller.signal.aborted) return null; return (dependencies.discoverSessions || discoverSessions)({ root: deliveryRoot, signal: controller.signal, includeArchived: true, includeSelectionCandidates: true, connectedProviders: PROVIDER_LINKS.filter(provider => providerLinks[provider].connected) }); })
461
463
  .then(async value => {
462
464
  if (closed || relay || controller.signal.aborted) return;
463
465
  const latestAccess = await accessSnapshot();
@@ -474,8 +476,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
474
476
  }
475
477
  if (coordinator.acceptsNewWork()) await analysisManager.update(analysisSessions(sessions));
476
478
  if (controller.signal.aborted) return;
477
- sessionStatus = { ...value, sessions, initialLoading: false }; sessionRefreshedAt = Date.now(); })
478
- .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false, providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
479
+ sessionStatus = { ...value, sessions, initialLoading: false,
480
+ discoveryAttempt: { ...attempt, state: 'succeeded', completedAt: Date.now() } }; sessionRefreshedAt = Date.now(); })
481
+ .catch(() => { if (closed || relay || controller.signal.aborted) return; sessionStatus = { ...sessionStatus, initialLoading: false,
482
+ discoveryAttempt: { ...attempt, state: 'failed', completedAt: Date.now() },
483
+ providers: { codex: { discovery: "unavailable", reason: "Conversation discovery unavailable." }, claude: { discovery: "unavailable", reason: "Conversation discovery unavailable." } } }; sessionRefreshedAt = Date.now(); })
479
484
  .finally(() => { if (discoveryController === controller) discoveryController = null; sessionRefresh = null; if (sessionRefreshAgain && !closed && !relay) { sessionRefreshAgain = false; refreshSessions(true); }
480
485
  if (!closed) setImmediate(() => { void publish().catch(() => {}); }); });
481
486
  }
@@ -771,7 +776,9 @@ export async function startDesktopHost({ input = process.stdin, output = process
771
776
  for (const session of sessions) {
772
777
  const key = `${session.provider}:${session.session}`;
773
778
  session.statistics = activeStatisticsRead?.key === key && !activeStatisticsRead.controller.signal.aborted
774
- ? { state: 'loading', hours: [], activity: [] }
779
+ ? statisticsCache.has(key)
780
+ ? { ...statisticsCache.get(key), refreshing: true }
781
+ : { state: 'loading', hours: [], activity: [] }
775
782
  : statisticsCache.get(key) || unavailableStatistics();
776
783
  session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
777
784
  session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
@@ -1202,11 +1209,13 @@ export async function startDesktopHost({ input = process.stdin, output = process
1202
1209
  if (!command.hostId && command.session && remoteHosts?.hasSession(command.session)) {
1203
1210
  throw Object.assign(new Error('Select the host for this conversation before continuing.'), { code: 'remote_host_invalid' });
1204
1211
  }
1205
- if (action === 'remote_host_save' || action === 'remote_host_remove') {
1212
+ if (['remote_host_save', 'remote_host_edit', 'remote_host_connect', 'remote_host_disconnect', 'remote_host_remove'].includes(action)) {
1206
1213
  if (!remoteHosts) throw Object.assign(new Error('Nested remote connections are unavailable.'), { code: 'remote_host_invalid' });
1207
- if (action === 'remote_host_save') await authorizeProvider('codex', commandScope);
1214
+ if (action === 'remote_host_save' || action === 'remote_host_connect' || action === 'remote_host_edit' && command.previousAlias !== command.alias) await authorizeProvider(PROVIDER_LINKS.find(provider => providerLinks[provider]?.connected) || 'codex', commandScope);
1208
1215
  else await authorizeWork(commandScope);
1209
- await remoteHosts.save({ alias: command.alias, label: command.label }, action === 'remote_host_remove');
1216
+ if (action === 'remote_host_edit') await remoteHosts.edit({ previousAlias: command.previousAlias, alias: command.alias, label: command.label });
1217
+ else if (action === 'remote_host_connect' || action === 'remote_host_disconnect') await remoteHosts.setEnabled(command.alias, action === 'remote_host_connect');
1218
+ else await remoteHosts.save({ alias: command.alias, label: command.label }, action === 'remote_host_remove');
1210
1219
  emit({ type: 'result', id: command.id, ok: true }); await publish(); return;
1211
1220
  }
1212
1221
  if (action === 'provider_repair') {
@@ -1284,8 +1293,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
1284
1293
  await authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate));
1285
1294
  signal.throwIfAborted();
1286
1295
  if (activeStatisticsRead !== request) throw Object.assign(new Error('Conversation activity request cancelled.'), { code: 'cancelled' });
1296
+ const previous = statisticsCache.get(request.key);
1297
+ if (statistics.state === 'unavailable' && ['ready', 'partial'].includes(previous?.state)) statistics = { ...previous, refreshFailed: true };
1287
1298
  statisticsCache.clear(); // Only the selected conversation needs chart data.
1288
1299
  statisticsCache.set(request.key, statistics);
1300
+ }).catch(error => {
1301
+ const previous = statisticsCache.get(request.key);
1302
+ if (activeStatisticsRead === request && !signal.aborted && ['ready', 'partial'].includes(previous?.state)) {
1303
+ statisticsCache.set(request.key, { ...previous, refreshFailed: true });
1304
+ }
1305
+ throw error;
1289
1306
  }).finally(() => {
1290
1307
  if (activeStatisticsRead === request) activeStatisticsRead = null;
1291
1308
  if (!closed) void publish().catch(() => {});
@@ -1311,6 +1328,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
1311
1328
  }
1312
1329
  if (action === 'desktop_access_refresh') {
1313
1330
  await agent.refreshDesktopAccess?.();
1331
+ } else if (action === 'desktop_sqm_settings') {
1332
+ const verified = await agent.refreshDesktopAccess?.();
1333
+ await authorizeWork(commandScope);
1334
+ if (!desktopAccessReady(verified) || verified.organizationId !== access.organizationId ||
1335
+ !validDesktopAccessPath(verified.settingsPath, verified.organizationSlug)) throw new Error('Organization settings are unavailable; refresh access first.');
1336
+ const url = new URL(`/org/${encodeURIComponent(verified.organizationSlug)}/quality-monitor`, webOrigin);
1337
+ const result = await (dependencies.openLogin || openAuthorizationUrl)(url.toString(), { logger: silentLogger });
1338
+ if (!result.opened) throw new Error('Could not open SQM settings.');
1314
1339
  } else if (action === 'desktop_access_settings' || action === 'desktop_access_members') {
1315
1340
  const verified = await agent.refreshDesktopAccess?.();
1316
1341
  const path = verified?.[action === 'desktop_access_settings' ? 'settingsPath' : 'membersPath'];
@@ -1340,7 +1365,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
1340
1365
  else if (action === "login" || action === "organization_select" || action === "authorization_open") {
1341
1366
  if (action === "organization_select" && (typeof command.organizationId !== "string" || !command.organizationId)) throw new Error("Select an organization first.");
1342
1367
  const url = action === "authorization_open" ? (agent.getPendingAuthorizationUrl?.() ?? agent.retryAuthorization?.())
1343
- : await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null);
1368
+ : await agent.requestDesktopLogin(action === "organization_select" ? command.organizationId : null,
1369
+ { reauthorize: action === 'login' && command.reauthorize === true });
1344
1370
  if (action === 'authorization_open' && !url) throw new Error('No pending authorization.');
1345
1371
  if (url) {
1346
1372
  if (new URL(url).origin !== webOrigin) throw new Error("Invalid login origin.");
@@ -1593,6 +1619,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
1593
1619
  await (dependencies.trackSession || trackSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
1594
1620
  session: command.session, tracked: action === "session_track", expectedProviderEpoch: selectedLinkEpochs.get(candidate) });
1595
1621
  await refreshSessions(true);
1622
+ } else if (action === "session_add") {
1623
+ if (command.provider !== 'codex' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(command.session || '')) throw new Error('Choose a Codex conversation or enter its exact conversation ID.');
1624
+ await authorizeWork(commandScope);
1625
+ await (dependencies.monitorSession || monitorSession)({ root: deliveryRoot, provider: command.provider,
1626
+ session: command.session, monitor: true, expectedProviderEpoch: commandProviderLink?.epoch,
1627
+ openClient: dependencies.openCodexAppServer || openCodexAppServer });
1628
+ await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1629
+ await updateRecord(deliveryRoot, command.provider, command.session, previous => ({ ...previous, listHidden: false }));
1630
+ await refreshSessions(true);
1596
1631
  } else if (action === "session_monitor" || action === "session_unmonitor") {
1597
1632
  await (dependencies.monitorSession || monitorSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
1598
1633
  session: command.session, monitor: action === "session_monitor", expectedProviderEpoch: commandProviderLink?.epoch });
@@ -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"]) {
@@ -108,7 +108,7 @@ export async function startDirectTask(
108
108
  task.error = "Worker launch outcome could not be confirmed. Reconcile saved evidence before retrying.";
109
109
  return task;
110
110
  }
111
- if (task.stop_requested) await owner.stop(task.worker_handle);
111
+ if (task.stop_requested) await stopDirectWorker(task);
112
112
  return await reconcileDirectWorker(task);
113
113
  }
114
114
 
@@ -229,6 +229,12 @@ export async function reconcileDirectWorker(task, { worker = task.worker || crea
229
229
  }
230
230
  }
231
231
 
232
+ // Launch acknowledgement and a user stop may settle together. Both paths
233
+ // share one owned stop RPC, including when it closes the owner's IPC socket.
234
+ function stopDirectWorker(task) {
235
+ return task.workerStopPromise ||= task.worker.stop(task.worker_handle);
236
+ }
237
+
232
238
  // Only the child captured by this task may be signalled, including its owned tools.
233
239
  export function stopDirectTask(task) {
234
240
  if (!task) return Promise.resolve(task);
@@ -241,7 +247,7 @@ async function stopOwnedDirectTask(task) {
241
247
  if (task.worker_handle) {
242
248
  // Launch uncertainty does not prevent stopping a separately confirmed owner.
243
249
  await task.worker_start?.catch(() => {});
244
- await task.worker.stop(task.worker_handle);
250
+ await stopDirectWorker(task);
245
251
  return reconcileDirectWorker(task);
246
252
  }
247
253
  const child = task.child;
package/src/doctor.js CHANGED
@@ -46,6 +46,7 @@ export async function collectDiagnostics({ webOrigin, cliVersion, root = default
46
46
  providers, failures, host_exit: await readHostExitDiagnostics(root),
47
47
  purpose_verification: await purposeVerificationCapability(purposeSigningKeyPath(root)).then(value => ({ state: value ? "available" : "not_initialized", key_id: value?.key_id || null, execution: "explicit_frozen_recipes_only" })).catch(() => ({ state: "unavailable", key_id: null, execution: "explicit_frozen_recipes_only" })),
48
48
  actions: [
49
+ { id: "connection_recovery", command: "simy connections recover simy", instruction: "つながらない原因を確認し、表示される対処方法を選んで、もう一度確認します。" },
49
50
  ...Object.entries(providers).filter(([, provider]) => provider.connection === "disconnected")
50
51
  .map(([provider]) => ({ id: `connect_${provider}`, action: "provider_connect", provider,
51
52
  instruction: `Open SIMY settings and connect ${provider}. Provider login and SIMY connection are separate; existing tasks remain stopped.` })),
package/src/index.js CHANGED
@@ -38,7 +38,7 @@ const commandArgv = argv.filter((value) => value !== "--no-auto-update");
38
38
  // Delivery receives the resolved origin separately; global --host is not one
39
39
  // of its command-specific options, regardless of its position on the CLI.
40
40
  const deliveryArgv = commandArgv.filter((value, index) => value !== "--host" && commandArgv[index - 1] !== "--host");
41
- const dispatchArgv = ["delivery", "sessions"].includes(deliveryArgv[0]) ? deliveryArgv : commandArgv;
41
+ const dispatchArgv = ["delivery", "sessions", "connections"].includes(deliveryArgv[0]) ? deliveryArgv : commandArgv;
42
42
  const entryPath = fileURLToPath(import.meta.url);
43
43
  const packageInfo = await readCliPackageInfo();
44
44
  const updatesDisabled = args.has("--no-auto-update") || process.env.SIMY_AUTO_UPDATE === "0";
@@ -65,6 +65,11 @@ if (!configuredWebOrigin) {
65
65
  } else if (dispatchArgv[0] === "sessions") {
66
66
  const { runSessionListCommand } = await import("./session-supervision/session-list.js");
67
67
  process.exitCode = await runSessionListCommand(dispatchArgv.slice(1));
68
+ } else if (dispatchArgv[0] === "connections") {
69
+ try {
70
+ const { runRemoteConnectionsCommand } = await import("./remote-connection-guide.js");
71
+ process.exitCode = await runRemoteConnectionsCommand(dispatchArgv.slice(1), { webOrigin: configuredWebOrigin });
72
+ } catch (error) { console.error(error.message); process.exitCode = 2; }
68
73
  } else if (dispatchArgv[0] === "doctor") {
69
74
  const { runDoctor } = await import("./doctor.js");
70
75
  process.exitCode = await runDoctor({ webOrigin: configuredWebOrigin, cliVersion: packageInfo.version });
@@ -140,6 +145,7 @@ async function runCommand(installMode) {
140
145
  console.log(`Usage:
141
146
  simy Start the local SIMY agent
142
147
  simy --version Print the installed version without starting or updating
148
+ simy connections 別のパソコンへの接続や、つながらないときの手順を表示
143
149
  simy doctor Read-only version, connection, provider and failure diagnostics
144
150
  simy --daemon Start it in the background
145
151
  simy --no-tui Start the foreground HTTP agent without the interactive console
@@ -177,6 +183,10 @@ async function runLocalAgent() {
177
183
  const port = readPort(argv);
178
184
  const webOrigin = configuredWebOrigin;
179
185
  const sessionRoot = process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
186
+ const { discoverRemoteConnections, connectionStartupHint } = await import("./remote-connection-guide.js");
187
+ const remoteConnections = !daemon ? await discoverRemoteConnections({ root: sessionRoot }) : { hosts: [], warnings: [] };
188
+ const remoteHint = connectionStartupHint(remoteConnections);
189
+ if (remoteHint) console.log(remoteHint);
180
190
 
181
191
  try {
182
192
  const existing = await findReusableConnection({ webOrigin, sessionRoot, cliVersion: packageInfo.version,
@@ -277,7 +287,7 @@ async function runLocalAgent() {
277
287
  if (interactive) {
278
288
  const { runAgenticLoopConsole } = await import("./console/index.js");
279
289
  try {
280
- await runAgenticLoopConsole(agent);
290
+ await runAgenticLoopConsole(agent, { remoteConnections });
281
291
  } finally {
282
292
  await shutdownAgent();
283
293
  }