@awak-app/simy-cli 0.7.25 → 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 (38) hide show
  1. package/README.md +6 -0
  2. package/package.json +2 -2
  3. package/src/cli-contract.js +1 -1
  4. package/src/codex-plugins/simy/hooks/consistency-handoff.mjs +45 -0
  5. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +43 -7
  6. package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +7 -5
  7. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +39 -1
  8. package/src/connection-health.js +253 -0
  9. package/src/connection-presentation.js +85 -0
  10. package/src/connection-recovery.js +102 -0
  11. package/src/console/app.js +60 -6
  12. package/src/console/commands.js +3 -0
  13. package/src/console/index.js +1 -0
  14. package/src/delivery-hooks/legacy-hook-routing.js +24 -0
  15. package/src/delivery-hooks/supervisor.js +14 -14
  16. package/src/desktop-host.js +43 -8
  17. package/src/direct-executor.js +8 -2
  18. package/src/doctor.js +1 -0
  19. package/src/index.js +12 -2
  20. package/src/remote-connection-guide.js +222 -0
  21. package/src/session-supervision/analysis-manager.js +24 -4
  22. package/src/session-supervision/automatic-continuation.js +69 -18
  23. package/src/session-supervision/codex-app-server.js +52 -7
  24. package/src/session-supervision/codex-peer-opinion.js +27 -5
  25. package/src/session-supervision/consistency-auditor.js +1 -1
  26. package/src/session-supervision/continuation-analysis.js +7 -3
  27. package/src/session-supervision/decision-presentation.js +1 -1
  28. package/src/session-supervision/index.js +21 -13
  29. package/src/session-supervision/outcome-plan.js +76 -0
  30. package/src/session-supervision/purpose-instruction.js +28 -0
  31. package/src/session-supervision/recovery-frontier.js +14 -4
  32. package/src/session-supervision/remote-command-queue.js +15 -0
  33. package/src/session-supervision/remote-control.js +3 -0
  34. package/src/session-supervision/remote-hosts.js +70 -19
  35. package/src/session-supervision/remote-managed-delivery.js +94 -0
  36. package/src/session-supervision/remote-session-list.js +1 -1
  37. package/src/session-supervision/statistics.js +10 -3
  38. package/src/session-supervision/work-state.js +5 -0
@@ -0,0 +1,85 @@
1
+ // Everyday guidance stays separate from the technical diagnostic record.
2
+ // JSON and the explicit details view retain the original codes for support.
3
+ const networkSteps = [
4
+ 'このパソコンで、いつも見るWebサイトが開くか確認してください。開けない場合はWi-Fiやケーブルを確認してください。',
5
+ '接続先が別のパソコンなら、そちらの電源を入れ、スリープを解除してください。',
6
+ '会社専用の接続アプリ(VPN)を普段使う場合は、そのアプリで接続してください。終わったら「もう一度確認する」を選びます。',
7
+ ];
8
+ const adminStep = '自分で設定した覚えがない場合は、接続先を用意した人や会社の担当者に「詳しい情報」を見せてください。';
9
+ const advice = {
10
+ dns: ['接続先の場所が見つかりませんでした。', ['入力した接続先の名前やWebアドレスに間違いがないか確認してください。', ...networkSteps]],
11
+ proxy_dns: ['会社などの中継サーバーが見つかりませんでした。', [networkSteps[0], networkSteps[2], adminStep]],
12
+ proxy_auth: ['会社などの中継サーバーでログインが必要です。', ['会社指定の接続アプリやログイン画面で、ログインし直してください。', adminStep]],
13
+ proxy_config: ['会社などの中継サーバーの設定を読み取れませんでした。', [adminStep]],
14
+ proxy_connect: ['会社などの中継サーバーを通って接続できませんでした。', [networkSteps[2], adminStep]],
15
+ refused: ['接続先は、今この接続を受け付けていません。', ['相手のパソコンやサービスが起動しているか確認してください。', '相手がMacの場合は「システム設定」で「リモートログイン」を検索し、オンになっているか確認してください。', adminStep]],
16
+ route: ['接続先にたどり着けませんでした。', networkSteps],
17
+ timeout: ['しばらく待ちましたが、接続先から返事がありませんでした。', networkSteps],
18
+ reset: ['途中で接続が切れました。', [networkSteps[0], '少し待ってから「もう一度確認する」を選んでください。何度も切れる場合は、接続先の担当者に確認してください。']],
19
+ tls_expired: ['接続先の安全確認に使う証明書の期限を確認できませんでした。', ['このパソコンの日付と時刻が正しいか確認してください。', '時刻が正しければ、接続先の担当者に証明書の期限を確認してもらってください。']],
20
+ tls_name: ['開こうとしている接続先と、安全確認の情報が一致しません。', ['Webアドレスが正しいか確認してください。', '正しい場合は、接続先の担当者に確認してください。安全確認を無効にして接続しないでください。']],
21
+ tls_trust: ['接続先が安全か確認できませんでした。', ['接続先の担当者に、安全確認に使う証明書の設定を確認してもらってください。', '会社独自の設定が必要な場合もあります。安全確認を無効にせず、担当者の手順に従ってください。']],
22
+ tls_client: ['このパソコンの接続許可を示す証明書が使えません。', [adminStep]],
23
+ tls: ['安全な通信を始められませんでした。', ['Webアドレスが正しいか確認してください。', adminStep]],
24
+ key_changed: ['相手のパソコンの身元を確認する情報が以前と変わっています。', ['接続先を管理する人に、パソコンの交換や設定変更があったか確認してください。', '同じ名前の別のパソコンの可能性もあります。確認できるまで、以前の情報を削除したり、安全確認を無効にしたりしないでください。']],
25
+ key_unknown: ['このパソコンから接続してよい相手か、まだ確認できていません。', ['接続先を用意した人に、相手の身元確認と最初の接続設定を手伝ってもらってください。', '知らない相手をそのまま許可せず、設定が終わってから「もう一度確認する」を選んでください。']],
26
+ key_permissions: ['接続に使う鍵のファイルを、安全に使える設定にする必要があります。', ['このパソコンの接続設定をした人に、鍵のファイルを自分だけが使える設定にしてもらってください。', '鍵のファイルの中身を送ったり、貼り付けたりする必要はありません。']],
27
+ key_missing: ['接続に使う鍵のファイルが見つからないか、読み取れません。', [adminStep, '鍵のファイルの中身は共有しないでください。']],
28
+ ssh_certificate: ['このパソコンの接続許可を更新する必要があります。', ['このパソコンの日付と時刻を確認してください。', '接続先を管理する人に、接続許可の更新方法を確認してください。']],
29
+ ssh_auth: ['相手のパソコンへのログインができませんでした。', ['「相手のパソコンにログインし直す」を選んでください。', 'パスワードなどを求められたら、このターミナルの案内に従って入力してください。入力中に文字が表示されないことがあります。', '分からない場合は、接続先を用意した人に確認してください。']],
30
+ ssh_agent: ['接続用の鍵がロックされているか、使用できません。', ['普段使う鍵の管理アプリや、USBなどで接続する認証機器を確認してください。', '「相手のパソコンにログインし直す」を選び、表示される案内に従ってください。']],
31
+ ssh_config: ['保存されている接続設定を読み取れませんでした。', [adminStep]],
32
+ ssh_forward: ['相手のパソコンが、一時的な接続経路の作成を許可していません。', ['接続先の担当者に、一時的な接続経路を使ってよいか確認してください。担当者向けの情報は「詳しい情報」で確認できます。']],
33
+ ssh_missing: ['別のパソコンに接続するためのソフトが見つかりません。', ['このパソコンにOpenSSHという接続用ソフトが必要です。設定を手伝う人にインストールを依頼してください。']],
34
+ cli_missing: ['相手のパソコンでSIMYを見つけられませんでした。', ['相手のパソコンでSIMYが使えるか確認してください。', '相手側で使えるのにこの表示が出る場合は、接続設定をした人に「詳しい情報」を見せてください。']],
35
+ auth: ['ログインが必要か、ログインの有効期限が切れています。', ['下のログインの操作を選び、開いた画面の案内に従ってください。', '外部サービス用の接続キーを使っている場合は、そのサービスの担当者に更新方法を確認してください。']],
36
+ permission: ['接続先から、利用を許可されませんでした。', ['正しいアカウントでログインしているか確認してください。', '正しければ、接続先の担当者に利用許可を確認してください。接続を制限する設定が原因の場合もあります。']],
37
+ rate_limit: ['接続先の利用回数などの制限に達しています。', ['サービスの利用状況や、再び使える時刻を確認してください。時間を置いてから「もう一度確認する」を選んでください。']],
38
+ service: ['接続先のサービスで問題が起きています。', ['サービスのお知らせや障害情報を確認してください。復旧してから「もう一度確認する」を選んでください。']],
39
+ endpoint: ['指定したWebアドレスが見つかりませんでした。', ['接続先のWebアドレスが正しいか、サービスの担当者に確認してください。']],
40
+ method: ['このサービスは、今回の確認方法には対応していません。', ['これだけでは故障とは判断できません。サービスの担当者に、接続確認用のWebアドレスを教えてもらってください。']],
41
+ redirect: ['接続先から、別のページへ移動するよう案内されました。', ['ログイン画面や、移転したページかもしれません。サービスの正しいWebアドレスとログイン方法を確認してください。']],
42
+ curl_missing: ['Webサービスを確認するためのソフトが見つかりません。', ['このパソコンにcurlという確認用ソフトが必要です。設定を手伝う人にインストールを依頼してください。']],
43
+ unknown: ['今回の確認では、接続できるか判断できませんでした。', [networkSteps[0], adminStep]],
44
+ provider_unavailable: ['AIサービス用のソフトを使えるか確認できませんでした。', ['このパソコンで、対象のAIサービス用ソフトが使えるか確認してください。', '未インストール・更新待ち・ログイン状態の不明などが考えられます。公式の案内に従って設定してください。']],
45
+ provider_disconnected: ['SIMYとAIサービスの連携が無効か、設定を読み取れません。', ['「SIMYとの連携を有効にする」が表示されていれば、選んでください。', '表示されない場合は設定を読み取れない可能性があります。設定を手伝う人に「詳しい情報」を見せてください。']],
46
+ };
47
+ const observations = {
48
+ dns_resolved: '接続先の場所が見つかりました。',
49
+ ssh_config_resolved: '保存されている接続設定を読み取れました。',
50
+ ssh_proxy_dns: '別のサーバーを通る設定です。実際の接続で確認します。',
51
+ ssh_ready: '相手のパソコンにつながり、SIMYが見つかりました。',
52
+ cli_auth_unverified: '相手側のSIMYへのログインは、開いてから確認してください。',
53
+ provider_ready: 'AIサービス用のソフトと、保存されたログイン情報を確認できました。',
54
+ provider_linked: 'SIMYとの連携は有効です。',
55
+ provider_api_unverified: 'AIサービスで実際に作業できるかは、まだ確認していません。',
56
+ http_reached: 'Webサービスから返事がありました。',
57
+ web_ready: 'このWebアドレスにつながりました。',
58
+ session_current: '保存されているログイン情報の期限は有効です。サービス側で取り消されていないかは未確認です。',
59
+ };
60
+ export function friendlyCheck(item) {
61
+ const [message, steps] = advice[item.code] || [];
62
+ return { message: message || observations[item.code] || advice.unknown[0], steps: steps || [] };
63
+ }
64
+ export const targetName = target => target.display === 'provider:codex' ? 'Codex(AIサービス)'
65
+ : target.display === 'provider:claude' ? 'Claude(AIサービス)' : target.display;
66
+
67
+ export function friendlyAction(action, target) {
68
+ const name = targetName(target);
69
+ const known = {
70
+ retry: ['もう一度確認する', ['今と同じ接続先を確認します。設定を直したら、この操作を選んでください。']],
71
+ open_cli: [`${name} のSIMYを開く`, ['このパソコンから、相手のパソコンのSIMYを開きます。', '相手側のSIMY画面が表示されたら接続完了です。相手側のSIMYを終了すると、このパソコンに戻ります。']],
72
+ ssh_login: ['相手のパソコンにログインし直す', ['このターミナルで相手のパソコンへのログインを試します。表示される案内に従ってください。', 'パスワードの入力中に文字が表示されなくても、そのまま入力してEnterキーを押してください。', '終わったら、同じ接続先をもう一度確認します。']],
73
+ provider_login: [`${name} にログインし直す`, ['このパソコンでログイン操作を始めます。表示される案内や、開いたブラウザの指示に従ってください。', 'ログインが完了したら、このターミナルに戻ってください。']],
74
+ provider_link: ['SIMYとの連携を有効にする', ['このパソコンのSIMYで、対象のAIサービスとの連携を有効にします。', '作業の実行許可は別途選ぶ必要があります。停止中の作業は再開しません。']],
75
+ web_login: ['サービスのWebページを開く', ['このパソコンのブラウザで、サービスのトップページを開きます。そこからログインしてください。', '外部サービス用の接続キーを使う場合は、ブラウザのログインだけでは直らないことがあります。キーの更新方法はサービスの担当者に確認してください。', '手続きが終わったら、このターミナルに戻ってください。']],
76
+ simy_login: ['SIMYにログインし直す手順を見る', ['このパソコンで、ターミナルのウィンドウをもう1つ開いてください。', '下の1行をコピーして貼り付け、Enterキーを押してください。', '表示されたログイン用のページをブラウザで開き、ログインを完了してください。終わったら、このターミナルに戻ってください。']],
77
+ reverse: ['相手のパソコンからつなぐ方法を見る', ['今の向きでつながらないときは、相手のパソコンからこのパソコンへつなぐ方法があります。相手からこちらへ接続できる設定が必要です。', '相手のパソコンでターミナルを開き、simy connections を実行してください。', '一覧から「今使っているこのパソコン」の接続先名を確認し、下の「接続先名」を置き換えて実行してください。', '接続経路ができると次のコマンドが表示されます。そのコマンドをこのパソコンの別のターミナルで実行してください。相手側のターミナルは接続中ずっと開いておきます。', '一覧にない・どちらの名前か分からない場合は、接続設定をした人に確認してください。']],
78
+ network_settings: ['ネット接続や会社の接続アプリを確認する', networkSteps],
79
+ provider_setup: ['AIサービス用ソフトの準備方法を見る', advice.provider_unavailable[1]],
80
+ };
81
+ const remedy = action.id.startsWith('resolve_') ? advice[action.id.slice(8)] : null;
82
+ const [title, steps] = known[action.id] || (remedy ? ['対処方法を読む: ' + remedy[0], remedy[1]] : ['設定を確認する', [adminStep]]);
83
+ return { title, steps, command: action.id === 'simy_login' ? action.command
84
+ : action.id === 'reverse' ? 'simy connections tunnel 接続先名' : undefined };
85
+ }
@@ -0,0 +1,102 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { diagnoseConnection, parseConnectionTarget } from './connection-health.js';
4
+ import { writeProviderLink } from './provider-links.js';
5
+ import { openExternalUrl } from './browser.js';
6
+ import { friendlyAction, friendlyCheck, targetName } from './connection-presentation.js';
7
+
8
+ export function connectionReportLines(report, { details = false } = {}) {
9
+ const status = { reachable: '今回の接続確認はできました', needs_action: '確認・操作が必要です', unverified: 'まだ接続を確認できていません' };
10
+ const lines = [
11
+ `接続先: ${targetName(report.target)}`,
12
+ `結果: ${status[report.status] || status.unverified}`,
13
+ ...report.checks.flatMap(item => {
14
+ const plain = friendlyCheck(item);
15
+ return [`${item.state === 'passed' ? '✓' : item.state === 'failed' ? '!' : '?'} ${plain.message}`,
16
+ ...plain.steps.map((step, i) => ` ${i + 1}. ${step}`)];
17
+ }),
18
+ '使いたい機能のすべてを確認したわけではありません。まだ確認できていない項目は上に表示しています。',
19
+ ];
20
+ if (details) lines.push('', '詳しい情報(設定を手伝う人向け)',
21
+ ...report.checks.map(c => `${c.code}: ${c.message}${c.instruction ? `\n ${c.instruction}` : ''}`),
22
+ `プロキシ設定: ${report.context.proxyConfigured ? 'あり' : 'なし'} / 独自CA設定: ${report.context.customCaConfigured ? 'あり' : 'なし'}`);
23
+ return lines;
24
+ }
25
+
26
+ function interactiveProcess(file, args) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(file, args, { stdio: 'inherit', shell: false });
29
+ child.once('error', () => reject(new Error('操作を起動できませんでした。インストールと設定を確認してください。')));
30
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error('操作が完了しませんでした。再診断で状態を確認してください。')));
31
+ });
32
+ }
33
+
34
+ export async function performConnectionAction(action, value, options = {}) {
35
+ const target = parseConnectionTarget(value, options.webOrigin);
36
+ const run = options.runProcess || interactiveProcess;
37
+ if (action.id === 'ssh_login' && target.kind === 'ssh') {
38
+ await run('ssh', ['-T', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=8', '--', target.alias, 'true']);
39
+ } else if (action.id === 'open_cli' && target.kind === 'ssh') {
40
+ const { runRemoteConnectionsCommand } = await import('./remote-connection-guide.js');
41
+ const code = await runRemoteConnectionsCommand(['connect', target.alias]);
42
+ if (code !== 0) throw new Error('相手のSIMYを開けませんでした。');
43
+ } else if (action.id === 'provider_login' && target.kind === 'provider') {
44
+ await run(target.provider, target.provider === 'codex' ? ['login'] : ['auth', 'login']);
45
+ } else if (action.id === 'provider_link' && target.kind === 'provider') {
46
+ await writeProviderLink(options.root, target.provider, true);
47
+ } else if (action.id === 'web_login' && target.kind === 'web') {
48
+ await openExternalUrl(target.url.origin);
49
+ }
50
+ // Other actions are manual remedies. The caller displays the exact instructions
51
+ // and waits for the user to finish before making another read-only probe.
52
+ }
53
+
54
+ export async function runConnectionRecovery(argv, options = {}) {
55
+ const { output = console.log, diagnose = diagnoseConnection, perform = performConnectionAction,
56
+ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY), ask } = options;
57
+ const [mode, ...args] = argv;
58
+ let value = 'simy', hasTarget = false, json = false, details = false, tokenEnv;
59
+ for (let i = 0; i < args.length; i++) {
60
+ if (args[i] === '--json') json = true;
61
+ else if (args[i] === '--details') details = true;
62
+ else if (args[i] === '--token-env' && args[i + 1] && !tokenEnv) tokenEnv = args[++i];
63
+ else if (!args[i].startsWith('-') && !hasTarget) { value = args[i]; hasTarget = true; }
64
+ else throw new Error('入力内容を確認してください。SIMYを確認するには simy connections recover を実行します。使い方は simy connections --help で確認できます。');
65
+ }
66
+ const settings = { ...options, tokenEnv };
67
+ let reader;
68
+ const question = ask || (prompt => { reader ||= createInterface({ input: process.stdin, output: process.stdout }); return reader.question(prompt); });
69
+ try {
70
+ while (true) {
71
+ const report = await diagnose(value, settings);
72
+ if (json) output(JSON.stringify(report, null, 2));
73
+ else {
74
+ connectionReportLines(report, { details }).forEach(output);
75
+ output('');
76
+ output('次にできること:');
77
+ report.actions.forEach((a, i) => output(`${i + 1}. ${friendlyAction(a, report.target).title}`));
78
+ if (mode !== 'recover' || !interactive) {
79
+ output('操作しながら確認するには、同じ接続先で diagnose を recover に変えて実行してください。recover は文字を入力できるターミナルで使います。');
80
+ if (!details) output('詳しい情報は、実行したコマンドの末尾に --details を付けると表示できます。');
81
+ }
82
+ }
83
+ if (mode !== 'recover' || json || !interactive) return report.status === 'reachable' ? 0 : 1;
84
+ const choice = (await question('次にすることの番号を入力してEnter(d: 詳しい情報 / 0: 終了): ')).trim();
85
+ if (choice.toLowerCase() === 'd') { details = !details; continue; }
86
+ if (choice === '0' || choice === '') return report.status === 'reachable' ? 0 : 1;
87
+ const action = /^\d+$/.test(choice) ? report.actions[Number(choice) - 1] : null;
88
+ if (!action) { output('表示された操作番号を選んでください。'); continue; }
89
+ if (action.id !== 'retry') {
90
+ const guidance = friendlyAction(action, report.target);
91
+ output(guidance.title);
92
+ guidance.steps.forEach((step, i) => output(`${i + 1}. ${step}`));
93
+ if (guidance.command) output(guidance.command);
94
+ // Release stdin while the selected login program owns the terminal.
95
+ reader?.close(); reader = undefined;
96
+ try { await perform(action, value, settings); }
97
+ catch { output('操作が完了しませんでした。画面の案内を確認し、分からない場合は設定を手伝う人に相談してください。'); }
98
+ await question('操作が終わったらEnterキーを押してください。同じ接続先をもう一度確認します: ');
99
+ }
100
+ }
101
+ } finally { reader?.close(); }
102
+ }
@@ -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 });