@awak-app/simy-cli 0.7.28 → 0.7.31

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 (77) hide show
  1. package/README.md +88 -3
  2. package/package.json +4 -4
  3. package/src/agent.js +1 -0
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
  7. package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +570 -65
  9. package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
  10. package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
  11. package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
  12. package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
  13. package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
  14. package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
  15. package/src/delivery-hooks/claude-workflow.md +3 -3
  16. package/src/delivery-hooks/command.js +12 -0
  17. package/src/delivery-hooks/desktop.js +31 -9
  18. package/src/delivery-hooks/supervisor.js +3 -2
  19. package/src/desktop-access.js +4 -1
  20. package/src/desktop-executor.js +6 -0
  21. package/src/desktop-host.js +494 -72
  22. package/src/native-development-report-completion.js +26 -0
  23. package/src/native-development-reports.js +15 -5
  24. package/src/orchestrator/budget.js +24 -0
  25. package/src/orchestrator/charter-repair.js +1 -1
  26. package/src/orchestrator/completion-contract.js +8 -2
  27. package/src/orchestrator/contract.js +3 -1
  28. package/src/orchestrator/independent-audit.js +13 -6
  29. package/src/orchestrator/instruction.js +1 -1
  30. package/src/orchestrator/loop.js +34 -4
  31. package/src/provider-stream.js +9 -2
  32. package/src/runner.js +44 -21
  33. package/src/session-supervision/action-deadline.js +18 -1
  34. package/src/session-supervision/autonomous-upgrade.js +4 -2
  35. package/src/session-supervision/claude-desktop-archive.js +57 -0
  36. package/src/session-supervision/claude-desktop-native.js +50 -0
  37. package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
  38. package/src/session-supervision/claude-desktop-target.js +87 -0
  39. package/src/session-supervision/claude-outcome-review.js +132 -0
  40. package/src/session-supervision/claude-owner-renewal.js +197 -0
  41. package/src/session-supervision/claude-review-definition.js +5 -0
  42. package/src/session-supervision/claude-sessions.js +210 -40
  43. package/src/session-supervision/codex-app-server.js +189 -36
  44. package/src/session-supervision/continuation-analysis.js +7 -7
  45. package/src/session-supervision/continuation-command.js +45 -0
  46. package/src/session-supervision/controller-store.js +48 -0
  47. package/src/session-supervision/conversation-presentation.js +241 -0
  48. package/src/session-supervision/decision-presentation.js +96 -35
  49. package/src/session-supervision/execution-context.js +39 -0
  50. package/src/session-supervision/index.js +38 -9
  51. package/src/session-supervision/manual-acceptance-command.js +30 -0
  52. package/src/session-supervision/notification-authority.js +9 -0
  53. package/src/session-supervision/notification-journal.js +40 -0
  54. package/src/session-supervision/operation-journal.js +42 -0
  55. package/src/session-supervision/owner-transport.js +146 -0
  56. package/src/session-supervision/purpose-recovery.js +10 -2
  57. package/src/session-supervision/purpose-verification.js +103 -9
  58. package/src/session-supervision/remote-control.js +1 -1
  59. package/src/session-supervision/remote-entry.js +2 -0
  60. package/src/session-supervision/remote-errors.js +15 -0
  61. package/src/session-supervision/remote-hosts.js +344 -36
  62. package/src/session-supervision/remote-metadata.js +103 -0
  63. package/src/session-supervision/remote-session-list.js +2 -4
  64. package/src/session-supervision/session-archive-command.js +45 -0
  65. package/src/session-supervision/session-list.js +40 -4
  66. package/src/session-supervision/sqm-presentation.js +46 -3
  67. package/src/session-supervision/stop-notifications.js +42 -11
  68. package/src/session-supervision/work-state.js +106 -6
  69. package/src/sqm/agent-skill.js +3 -3
  70. package/src/sqm/claude-plugin.js +7 -2
  71. package/src/sqm/history-store.js +6 -0
  72. package/src/team-threads.js +1 -1
  73. package/src/update-detachment/coordinator.js +7 -1
  74. package/src/update-detachment/history-recovery.js +18 -0
  75. package/src/update-detachment/ledger-adapter.js +1 -0
  76. package/src/update-detachment/retained-paused.js +290 -0
  77. package/src/update-detachment/runtime.js +78 -15
@@ -1,6 +1,7 @@
1
1
  // SSH-only entry point. Keep the normal signed/runtime-pinned desktop startup,
2
2
  // account/organization checks, provider consent and native execution boundaries.
3
3
  // No device credential is relayed from the Mac and hosts cannot recurse.
4
+ import { relayToOwner } from './owner-transport.js';
4
5
  import { homedir } from 'node:os';
5
6
  import { join } from 'node:path';
6
7
  import { pathToFileURL } from 'node:url';
@@ -9,6 +10,7 @@ process.env.SIMY_REMOTE_CHILD = '1';
9
10
  process.env.SIMY_AUTO_UPDATE = '0';
10
11
  const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
11
12
  try {
13
+ if (await relayToOwner({ root })) process.exit(0);
12
14
  await adoptInstalledRuntime({ root });
13
15
  const selected = await pinnedWorkflowSource({ root });
14
16
  const host = await import(selected ? pathToFileURL(join(selected, 'src/desktop-host.js')).href : '../desktop-host.js');
@@ -1,6 +1,21 @@
1
1
  // Remote diagnostics are selected from product-owned text. Never display
2
2
  // arbitrary SSH stderr, provider exceptions, paths or remote frame messages.
3
3
  const messages = {
4
+ claude_desktop_platform_unsupported: 'Claude Desktop archive control requires a macOS conversation host.',
5
+ claude_desktop_not_installed: 'Install Claude Desktop on the conversation host and sign in to its Code tab.',
6
+ claude_desktop_accessibility_required: 'Enable macOS Accessibility for the app running SIMY on the conversation host. Use simy sessions provider-setup --provider claude --host ALIAS --session UUID to open that host’s setting.',
7
+ claude_desktop_ui_unavailable: 'Open the exact Claude Desktop Code conversation on the selected host and retry.',
8
+ claude_desktop_ui_changed: 'The Claude Desktop window changed or is covered. Retry when the selected conversation is visible.',
9
+ claude_desktop_running: 'Stop this conversation and turn off automatic continuation before organizing it.',
10
+ claude_desktop_unsaved_input: 'Claude Desktop contains unsent input. Preserve it on the conversation host before retrying.',
11
+ claude_desktop_identity_unverified: 'Claude Desktop conversation identity could not be verified. Refresh the exact conversation.',
12
+ claude_desktop_identity_changed: 'Claude Desktop account or conversation changed. Refresh before retrying.',
13
+ claude_desktop_state_unconfirmed: 'Claude Desktop has not confirmed the archive state. Refresh before retrying.',
14
+ claude_desktop_native_unconfirmed: 'The native Claude result is unknown. Refresh its archive state before retrying.',
15
+
16
+ remote_stop_pending: 'The stop request is awaiting confirmation. New work will remain paused until it is reconciled.',
17
+ remote_operation_unknown: 'The remote operation was received but its result is not yet known. Refresh its state before starting another operation.',
18
+ remote_operation_unsupported: 'Update SIMY on the selected host to use this operation.',
4
19
  process_conflict: 'Another SIMY service owns this remote host. Reconcile its existing service before connecting; do not stop unrelated work.',
5
20
  desktop_access_required: 'Sign in to the same SIMY account and organization on the remote host and verify its access.',
6
21
  provider_link_disconnected: 'Reconnect this AI provider to SIMY on the remote host.',
@@ -1,24 +1,46 @@
1
+ import { operationDigest } from './operation-journal.js';
2
+ import { createControllerStore, controllerScope } from './controller-store.js';
1
3
  import { remoteOperationFailure } from './remote-errors.js';
2
4
  import { randomUUID } from 'node:crypto';
3
5
  import { spawn } from 'node:child_process';
4
6
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
7
  import { join } from 'node:path';
6
8
  import { compareSessionWork } from './work-state.js';
9
+ import { manualAcceptanceCommand, matchesManualAcceptanceControls, manualAcceptanceContinuation } from './manual-acceptance-command.js';
7
10
 
8
11
  const MAX_FRAME = 8 * 1024 * 1024;
9
12
  const MAX_HOSTS = 8;
10
13
  const aliasPattern = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
14
+ // Resolve the saved alias on every connection. A user's multiplexed SSH master
15
+ // can belong to the previous network and bypass this connection's keepalives.
16
+ export function remoteSshArgs(alias, command) {
17
+ return ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
18
+ '-o', 'ConnectTimeout=5', '-o', 'ConnectionAttempts=1',
19
+ '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2',
20
+ '-o', 'ControlMaster=no', '-o', 'ControlPath=none', '--', alias, command];
21
+ }
11
22
  export const remoteHostId = alias => `remote-ssh-discovered:${alias}`;
12
23
  // Resolve the installed SIMY executable on the remote login PATH. No local
13
24
  // credential, command payload, or user-supplied shell text enters this script.
14
25
  const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
15
26
  const bootstrap = 'import { realpathSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; const entry = join(dirname(realpathSync(process.argv[1])), "session-supervision", "remote-entry.js"); await import(pathToFileURL(entry).href);';
16
27
  export const REMOTE_HOST_COMMAND = `exec sh -lc ${shellQuote(`exec env SIMY_REMOTE_CHILD=1 SIMY_AUTO_UPDATE=0 node --input-type=module -e ${shellQuote(bootstrap)} "$(command -v simy)"`)}`;
17
- const allowedActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
28
+ export const allowedActions = new Set(['session_provider_setup', 'session_sync_context', 'refresh', 'task_detail', 'task_stop', 'controller_events', 'controller_pair', 'controller_provider_connect', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
29
+ 'session_monitor', 'session_unmonitor', 'session_enroll', 'session_followup', 'session_set_auto_continue', 'session_get_status', 'session_analyze', 'session_continue', 'session_answer',
30
+ 'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
31
+ 'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
32
+ 'delivery_dependency_ready', 'delivery_set_policy', 'delivery_continuation_policy', 'delivery_verify', 'session_read_context', 'delivery_acceptance_decide']);
33
+ // Protocol v1 had no advertised capabilities; retain its known released actions.
34
+ const legacyActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
18
35
  'session_monitor', 'session_unmonitor', 'session_enroll', 'session_followup', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
19
36
  'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
20
37
  'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
21
- 'delivery_dependency_ready', 'delivery_set_policy', 'delivery_continuation_policy', 'delivery_verify']);
38
+ 'delivery_dependency_ready', 'delivery_set_policy', 'delivery_continuation_policy', 'delivery_verify', 'delivery_acceptance_decide']);
39
+ const startsWork = command => ['session_followup', 'session_enroll', 'session_continue', 'delivery_resume', 'delivery_replan', 'session_track', 'session_monitor'].includes(command.action) ||
40
+ command.action === 'session_set_auto_continue' && command.enabled === true;
41
+ const stopsWork = command => ['delivery_stop', 'delivery_pause', 'session_untrack', 'session_unmonitor'].includes(command.action) ||
42
+ command.action === 'session_set_auto_continue' && command.enabled === false;
43
+ export const controllerPolicyActions = new Set(['session_sync_context','session_set_auto_continue','session_track','session_untrack','session_monitor','session_unmonitor','delivery_set_policy','delivery_continuation_policy','delivery_stop','delivery_pause']);
22
44
  const error = (message, code = 'remote_host_unavailable') => Object.assign(new Error(message), { code });
23
45
  function normalizedHost(value) {
24
46
  if (!value || typeof value.alias !== 'string' || !aliasPattern.test(value.alias)) throw error('Enter an SSH host alias, for example nvidia-workstation.', 'remote_host_invalid');
@@ -44,22 +66,28 @@ function stamp(value, host, depth = 0) {
44
66
  if (Array.isArray(value)) return value.map(item => stamp(item, host, depth + 1));
45
67
  if (!value || typeof value !== 'object') return value;
46
68
  const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, stamp(item, host, depth + 1)]));
69
+ // Includes nested conversationPresentation.target: the verified connection,
70
+ // never a remote frame's self-reported alias, owns this projection's host.
47
71
  if (['codex', 'claude'].includes(result.provider) && typeof result.session === 'string') result.hostId = host.id;
72
+ if (typeof result.notificationId === 'string' && result.notificationId) result.notificationId = `${host.id}:${result.notificationId}`;
48
73
  return result;
49
74
  }
50
75
 
51
- // Each remote host owns its records, controller, provider calls and workspace.
52
- // The Mac only projects status and forwards explicit, host-scoped commands.
53
- export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = () => {}, now = Date.now,
76
+ // The controller owns preferences and notifications. Remote hosts execute
77
+ // provider/workspace operations and return scoped results.
78
+ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChange = () => {}, onNotify = () => {}, now = Date.now,
54
79
  retryMs = 15_000, requestTimeoutMs = 45_000, handshakeTimeoutMs = 15_000 } = {}) {
55
80
  const connections = new Map();
81
+ const metadata = new Map();
82
+ const preferences = store || createControllerStore(root);
83
+ let preferenceScope = null;
56
84
  let scope = null, local = null, closed = false, settingsError = null, writes = Promise.resolve(), generation = 0;
57
85
  const changed = () => { if (!closed) onChange(); };
58
86
  function disconnect(connection, reason = 'Remote connection closed.') {
59
87
  const child = connection.child;
60
88
  connection.child = null; connection.ready = false; connection.reason = reason; connection.state = 'unavailable'; connection.retryAt = now() + retryMs;
61
89
  clearTimeout(connection.handshake);
62
- for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error('Remote connection lost. Check the original conversation before retrying this operation.')); }
90
+ for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error('Remote operation result is unknown. Check the original conversation before retrying.', 'remote_operation_unknown')); }
63
91
  connection.pending.clear();
64
92
  if (child) {
65
93
  child.stdin.end(); child.kill('SIGTERM');
@@ -67,14 +95,58 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
67
95
  force.unref?.(); child.once('close', () => clearTimeout(force));
68
96
  }
69
97
  }
98
+ function receiveNotification(connection, frame) {
99
+ if (!connection.ready || typeof frame?.id !== 'string' || !['finished','failed','paused','needs_decision'].includes(frame.kind)) return;
100
+ if (!['codex','claude'].includes(frame.provider) || typeof frame.session !== 'string') return;
101
+ const expectedScope = preferenceScope;
102
+ if (frame.scope !== undefined && frame.scope !== expectedScope) return;
103
+ // Journal scope is account/organization identity. Native UI binds its own
104
+ // current origin/access scope after this authenticated transport check.
105
+ const { scope: sourceScope, ...event } = frame;
106
+ const id = `${connection.id}:${frame.id}`;
107
+ return preferences.change(expectedScope, state => {
108
+ if (state.events[id] || closed || preferenceScope !== expectedScope || !connection.ready) return;
109
+ onNotify({ ...stamp(event, connection), hostId: connection.id, id });
110
+ state.events[id] = now();
111
+ });
112
+ }
113
+ async function pullEvents(connection) {
114
+ if (connection.eventPull || closed || !connection.ready) return;
115
+ connection.eventPull = true;
116
+ const expectedScope = preferenceScope;
117
+ try {
118
+ const state = await preferences.load(expectedScope);
119
+ const after = state.eventCursors?.[connection.id] || 0;
120
+ const id = randomUUID();
121
+ const result = await new Promise((resolve, reject) => {
122
+ const timer = setTimeout(() => { connection.pending.delete(id); reject(new Error('Event replay timed out.')); }, requestTimeoutMs);
123
+ connection.pending.set(id, { id, resolve, reject, timer });
124
+ connection.child.stdin.write(JSON.stringify({ id, action: 'controller_events', after }) + '\n');
125
+ });
126
+ if (!result.ok || !Array.isArray(result.data?.events)) return;
127
+ let cursor = after;
128
+ for (const event of result.data.events) {
129
+ if (closed || preferenceScope !== expectedScope || !connection.ready) return;
130
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= cursor) throw new Error('Invalid event sequence.');
131
+ if (typeof event.id !== 'string' || !event.id || !['finished','failed','paused','needs_decision'].includes(event.kind) ||
132
+ !['codex','claude'].includes(event.provider) || typeof event.session !== 'string' || !event.session) throw new Error('Invalid replay event.');
133
+ await receiveNotification(connection, event);
134
+ cursor = event.sequence;
135
+ }
136
+ if (closed || preferenceScope !== expectedScope) return;
137
+ await preferences.change(expectedScope, state => { state.eventCursors ||= {}; state.eventCursors[connection.id] = cursor; });
138
+ } finally { connection.eventPull = false; }
139
+ }
70
140
  function connect(connection) {
71
141
  if (closed || connection.enabled === false || connection.child || now() < connection.retryAt) return;
72
142
  connection.state = 'connecting'; connection.reason = null;
73
143
  let child;
74
- try { child = spawnImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5',
75
- '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2', '--', connection.alias, REMOTE_HOST_COMMAND], { stdio: ['pipe', 'pipe', 'pipe'], shell: false }); }
144
+ try { child = spawnImpl('ssh', remoteSshArgs(connection.alias, REMOTE_HOST_COMMAND), { stdio: ['pipe', 'pipe', 'pipe'], shell: false }); }
76
145
  catch { disconnect(connection, 'Could not start SSH.'); return; }
77
146
  connection.child = child;
147
+ // This transport identity belongs to the local manager's fenced SSH child,
148
+ // never the remote payload. Reconnects may be shorter than a UI heartbeat.
149
+ connection.transportId = randomUUID();
78
150
  connection.handshake = setTimeout(() => {
79
151
  if (connection.child === child) { disconnect(connection, 'Remote SIMY did not respond. Check SSH and update SIMY on the remote host.'); changed(); }
80
152
  }, handshakeTimeoutMs);
@@ -100,28 +172,58 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
100
172
  if (!frame || typeof frame !== 'object') continue;
101
173
  if (frame.type === 'status') {
102
174
  const status = frame.status;
175
+ connection.capabilities = Array.isArray(status?.remoteControl?.actions)
176
+ ? status.remoteControl.actions.filter(action => typeof action === 'string' && allowedActions.has(action))
177
+ : status?.remoteControl === undefined && status?.remoteSessionProtocol === 1 ? [...legacyActions] : [];
103
178
  if (status?.remoteSessionProtocol !== 1) { disconnect(connection, 'Update SIMY on the remote host to use remote sessions.'); changed(); return; }
104
179
  if (!usable(status) || status.auth.userId !== local?.auth?.userId || status.access.organizationId !== local?.access?.organizationId) {
105
180
  clearTimeout(connection.handshake);
106
- connection.ready = false; connection.state = 'access_required'; connection.reason = 'Sign in to the same SIMY account and organization on the remote host.';
181
+ connection.ready = false; connection.state = 'access_required'; connection.reason = 'Use Remote connections in this CLI to sign in to this host.';
107
182
  connection.status = null;
108
- for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error(connection.reason, 'remote_host_access')); }
109
- connection.pending.clear();
183
+ for (const [id, job] of connection.pending) if (!job.setup) { clearTimeout(job.timer); job.reject(error(connection.reason, 'remote_operation_unknown')); connection.pending.delete(id); }
110
184
  } else if (!Array.isArray(status.sessionSupervision?.sessions) || !Array.isArray(status.deliveryHooks?.runs)) {
111
185
  connection.ready = false; connection.state = 'connecting'; connection.reason = 'Waiting for remote conversation status.';
112
186
  } else {
113
187
  clearTimeout(connection.handshake);
114
188
  connection.status = status; connection.ready = true; connection.state = 'ready'; connection.reason = null; connection.receivedAt = now();
189
+ if (status.controllerEventsVersion === 1) void pullEvents(connection).catch(() => {});
190
+ else for (const event of status.controllerEvents || []) void receiveNotification(connection, event)?.catch(() => {});
191
+ const expectedScope = preferenceScope;
192
+ void preferences.change(expectedScope, state => {
193
+ for (const row of status.sessionSupervision.sessions) {
194
+ const key = JSON.stringify([connection.id, row.provider, row.session]);
195
+ const saved = state.conversations[key] ||= {};
196
+ if (['ready','partial'].includes(row.statistics?.state)) saved.statistics = { ...row.statistics, observedAt: row.statistics.observedAt || now() };
197
+ if (!saved.importedRemoteDisplay) {
198
+ saved.pinned ??= row.listPinned === true;
199
+ saved.pinScopes ??= row.listPinScopes || (row.listPinned ? ['legacy'] : []);
200
+ saved.hidden ??= row.listHidden === true;
201
+ saved.importedRemoteDisplay = true;
202
+ }
203
+ }
204
+ state.hosts ||= {};
205
+ state.hosts[connection.id] = { sessionSupervision: status.sessionSupervision, deliveryHooks: status.deliveryHooks,
206
+ controllerCompletions: [...new Map([...(state.hosts[connection.id]?.controllerCompletions || []), ...(status.controllerCompletions || [])].map(item => [JSON.stringify([item.provider,item.provider_session_id,item.task_id]),item])).values()], tasks: status.tasks, sqmTasks: status.sqmTasks };
207
+ }).catch(() => {});
115
208
  }
116
209
  changed();
210
+ } else if (frame.type === 'notify' && connection.ready && local && typeof frame.id === 'string' && ['finished','failed','paused','needs_decision'].includes(frame.kind)) {
211
+ void receiveNotification(connection, frame)?.catch(() => {});
117
212
  } else if (frame.type === 'error') {
118
213
  disconnect(connection, remoteOperationFailure(frame.code).message); changed(); return;
214
+ } else if (frame.type === 'task_detail') {
215
+ const pending = connection.pending.get(frame.id);
216
+ pending?.onDetail?.({ ...frame, id: pending.id, taskKey: pending.taskKey });
119
217
  } else if (frame.type === 'result') {
120
218
  const pending = connection.pending.get(frame.id);
121
219
  if (!pending) continue;
122
220
  connection.pending.delete(frame.id); clearTimeout(pending.timer);
123
- pending.resolve({ type: 'result', id: pending.id, ok: frame.ok === true,
124
- ...(frame.ok !== true ? remoteOperationFailure(frame.code) : {}) });
221
+ const continuation = pending.action === 'delivery_acceptance_decide' ? manualAcceptanceContinuation(frame.continuation) : null;
222
+ const ok = frame.ok === true && (pending.action !== 'delivery_acceptance_decide' || continuation !== null);
223
+ pending.resolve({ type: 'result', id: pending.id, ok,
224
+ ...(ok && continuation ? { continuation } : {}),
225
+ ...(ok && pending.action !== 'delivery_acceptance_decide' && frame.data !== undefined ? { data: frame.data } : {}),
226
+ ...(ok ? {} : remoteOperationFailure(frame.code)) });
125
227
  }
126
228
  }
127
229
  });
@@ -132,10 +234,12 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
132
234
  if (scope !== nextScope) {
133
235
  generation++;
134
236
  for (const c of connections.values()) disconnect(c);
135
- connections.clear(); scope = nextScope;
237
+ connections.clear(); metadata.clear(); scope = nextScope;
136
238
  }
137
- local = { auth: status?.auth, access: status?.access };
239
+ local = { auth: status?.auth, access: status?.access, providerLinks: status?.providerLinks };
240
+ preferenceScope = scope ? controllerScope(status) : null;
138
241
  if (!scope) return;
242
+ await preferences.load(preferenceScope);
139
243
  const observedGeneration = generation;
140
244
  let hosts;
141
245
  try { hosts = await readHosts(root); settingsError = null; }
@@ -144,7 +248,7 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
144
248
  for (const [id, c] of connections) if (!hosts.some(h => h.id === id)) { disconnect(c); connections.delete(id); }
145
249
  for (const host of hosts) {
146
250
  let c = connections.get(host.id);
147
- if (!c) { c = { ...host, pending: new Map(), retryAt: 0, ready: false, status: null }; connections.set(host.id, c); }
251
+ if (!c) { c = { ...host, pending: new Map(), retryAt: 0, ready: false, status: (await preferences.load(preferenceScope)).hosts?.[host.id] || null }; connections.set(host.id, c); }
148
252
  c.label = host.label; c.enabled = host.enabled !== false;
149
253
  if (!c.enabled) {
150
254
  if (c.child) disconnect(c);
@@ -154,6 +258,7 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
154
258
  if (c.state === 'disconnected') c.retryAt = 0;
155
259
  if (c.ready && now() - c.receivedAt > 45_000) disconnect(c, 'Remote status is stale. Reconnecting.');
156
260
  connect(c);
261
+ if (c.ready) void flushPolicies(c).catch(() => {});
157
262
  }
158
263
  }
159
264
  async function persistHosts(change, apply) {
@@ -168,6 +273,39 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
168
273
  });
169
274
  writes = work; await work;
170
275
  }
276
+ function remoteTask(key) {
277
+ let target; try { target = JSON.parse(key); } catch { return null; }
278
+ if (!Array.isArray(target) || target.length !== 3 || target[0] !== 'remote-task') return null;
279
+ return { connection: connections.get(target[1]), sourceKey: target[2] };
280
+ }
281
+ async function forwardTask(command, status, onDetail) {
282
+ if (closed || !usable(status) || scopeOf(status) !== scope) throw error('Controller account changed.', 'remote_host_access');
283
+ const target = remoteTask(command.taskKey), c = target?.connection;
284
+ if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('Remote task connection is unavailable.');
285
+ const task = [...(c.status.tasks?.items || []), ...(c.status.sqmTasks?.items || [])].find(item => (item.key || item.id) === target.sourceKey);
286
+ if (!task || !['task_detail','task_stop'].includes(command.action) || command.action === 'task_stop' && (command.force !== true || task.canStop !== true)) throw error('Select an available task.', 'remote_host_invalid');
287
+ if (!c.capabilities?.includes(command.action)) throw error('Update the remote host.', 'remote_operation_unsupported');
288
+ const id = randomUUID();
289
+ return new Promise((resolve,reject) => {
290
+ const timer = setTimeout(() => { c.pending.delete(id); reject(error('Remote task result is unknown.', 'remote_operation_unknown')); }, requestTimeoutMs);
291
+ c.pending.set(id, { id: command.id, resolve, reject, timer, taskKey: command.taskKey, onDetail });
292
+ c.child.stdin.write(JSON.stringify({ id, action: command.action, taskKey: target.sourceKey, force: command.force }) + '\n');
293
+ });
294
+ }
295
+ async function configure(command, status) {
296
+ if (!usable(status) || scopeOf(status) !== scope) throw error('Controller sign-in changed.', 'remote_host_access');
297
+ const connection = connections.get(remoteHostId(command.alias));
298
+ if (!connection?.child || !['ready','access_required'].includes(connection.state)) throw error('Save this host and wait for its connection before configuring it.', 'remote_host_unavailable');
299
+ const action = command.action === 'remote_host_login' ? 'controller_pair' : 'controller_provider_connect';
300
+ if (!connection.capabilities?.includes(action)) throw error('Update SIMY on this host to configure it from this CLI.', 'remote_operation_unsupported');
301
+ if (action === 'controller_provider_connect' && (!connection.ready || !['codex','claude'].includes(command.provider))) throw error('Sign in to this host before connecting its provider.', 'remote_host_access');
302
+ const id = randomUUID();
303
+ return new Promise((resolve, reject) => {
304
+ const timer = setTimeout(() => { connection.pending.delete(id); reject(error('Remote setup timed out.')); }, requestTimeoutMs);
305
+ connection.pending.set(id, { id: command.id, resolve, reject, timer, setup: true });
306
+ connection.child.stdin.write(JSON.stringify({ id, action, organizationId: status.access.organizationId, userId: status.auth.userId, ...(command.provider ? { provider: command.provider } : {}) }) + '\n');
307
+ });
308
+ }
171
309
  async function save(value, remove = false) {
172
310
  const host = normalizedHost(value);
173
311
  await persistHosts(hosts => [...hosts.filter(h => h.id !== host.id), ...(remove ? [] : [host])], () => {
@@ -208,32 +346,74 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
208
346
  if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
209
347
  if (status.providerLinks?.[command.provider]?.connected === false) throw error('Provider is disconnected.', 'remote_host_access');
210
348
  const c = connections.get(command.hostId);
349
+ const stops = preferences.get(preferenceScope, command).policy || {};
350
+ if (startsWork(command) && ['delivery_stop','delivery_pause'].some(action => ['pending','unknown'].includes(stops[action]?.state))) throw error('The stop request must be reconciled before starting more work.', 'remote_stop_pending');
211
351
  if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('The remote host is disconnected. Reconnect before using this conversation.');
212
352
  const manualAdd = command.action === 'session_add' && 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 || '');
213
353
  if ((!manualAdd && !allowedActions.has(command.action)) || !['codex', 'claude'].includes(command.provider) || typeof command.session !== 'string' ||
214
354
  (!manualAdd && !c.status?.sessionSupervision?.sessions?.some(s => s?.provider === command.provider && s.session === command.session))) throw error('Select an available remote conversation.', 'remote_host_invalid');
215
- const restoreHidden = manualAdd && c.status?.sessionSupervision?.sessions?.some(row =>
216
- row.provider === command.provider && row.session === command.session && row.localHidden === true);
355
+ const sourceAction = manualAdd ? 'session_monitor' : command.action;
356
+ if (!c.capabilities?.includes(sourceAction)) throw error('Update SIMY on this host to support this operation.', 'remote_operation_unsupported');
217
357
  const id = randomUUID();
218
- const { hostId, ...payload } = command;
358
+ let { hostId, ...payload } = command;
359
+ if (command.action === 'delivery_acceptance_decide') {
360
+ let controls;
361
+ try { controls = manualAcceptanceCommand(command); }
362
+ catch { throw error('Refresh the remote acceptance controls before deciding.', 'remote_host_invalid'); }
363
+ // New controls require a current remote projection. Old runtimes and
364
+ // same-session local clones must never supply an identity fallback.
365
+ const run = c.status.deliveryHooks.runs.find(row => row?.provider === controls.provider && row.session === controls.session && row.taskId === controls.taskId);
366
+ const criterion = run?.manualAcceptance?.criteria?.find(row => row.id === controls.criterionId);
367
+ if (run?.manualAcceptance?.reasonCode !== null || criterion?.[controls.decision === 'accept' ? 'canAccept' : 'canNotYet'] !== true ||
368
+ !matchesManualAcceptanceControls(controls, criterion.controls)) throw error('Refresh the remote acceptance controls before deciding.', 'remote_host_invalid');
369
+ payload = { action: command.action, ...controls };
370
+ }
371
+ if (manualAdd) Object.assign(payload, { action: sourceAction, monitor: true });
372
+ const expectedScope = preferenceScope;
373
+ const digest = operationDigest(command);
374
+ // A new click is a new intent. Only explicit IDs and persisted queue items
375
+ // reuse an identity; equal command bodies are not evidence of a retry.
376
+ const requestId = command.requestId || randomUUID();
377
+ const updateOperation = state => preferences.change(expectedScope, data => {
378
+ const key = JSON.stringify([command.hostId, command.provider, command.session]);
379
+ const row = data.conversations[key] ||= {};
380
+ row.operations ||= {};
381
+ const previous = row.operations[requestId];
382
+ if (previous && previous.digest !== digest) throw error('Request identity conflicts with another operation.', 'remote_host_invalid');
383
+ row.operations[requestId] = { action: command.action, digest, requestId, state };
384
+ });
385
+ if (c.status.remoteControl?.version >= 2) {
386
+ await updateOperation('unknown');
387
+ payload.requestId = requestId;
388
+ } else {
389
+ // Old owners cannot reconcile request identities. Never imply that their
390
+ // unknown outcomes can safely be replayed automatically.
391
+ delete payload.requestId;
392
+ }
393
+ if (preferenceScope !== expectedScope || !c.ready || !c.child) throw error('Remote connection changed before dispatch.');
219
394
  const result = await new Promise((resolve, reject) => {
220
- const timer = setTimeout(() => { c.pending.delete(id); reject(error('Remote operation timed out. Check its result in the original conversation before retrying.')); }, requestTimeoutMs);
221
- c.pending.set(id, { id: command.id, resolve, reject, timer });
222
- c.child.stdin.write(JSON.stringify({ ...payload, ...(manualAdd ? { action: 'session_monitor', monitor: true } : {}), id }) + '\n', e => {
223
- if (e && c.pending.has(id)) { c.pending.delete(id); clearTimeout(timer); reject(error('Remote command could not be delivered.')); }
395
+ const timer = setTimeout(() => { c.pending.delete(id); reject(error('Remote operation timed out; its result is unknown. Check the original conversation before retrying.', 'remote_operation_unknown')); }, requestTimeoutMs);
396
+ c.pending.set(id, { id: command.id, action: command.action, resolve: result => {
397
+ if (c.status.remoteControl?.version >= 2 && result.code !== 'remote_operation_unknown') void updateOperation(result.ok ? 'applied' : 'failed').then(() => resolve(result), reject); else resolve(result);
398
+ }, reject, timer });
399
+ c.child.stdin.write(JSON.stringify({ ...payload, id }) + '\n', e => {
400
+ if (e && c.pending.has(id)) { c.pending.delete(id); clearTimeout(timer); reject(error('Remote command delivery is unknown. Check the original conversation before retrying.', 'remote_operation_unknown')); }
224
401
  });
225
402
  });
226
- // Existing remote runtimes understand these two idempotent navigation
227
- // operations. Monitoring alone does not clear a remote local archive.
228
- // Re-enter the normal forwarding path to recheck scope and connectivity;
229
- // never restore a provider archive or resume the conversation's task.
230
- if (restoreHidden && result.ok === true) return forward({ ...command, action: 'session_restore', scope: 'local' }, status);
403
+ // Visibility belongs to this controller. A successful explicit Add clears
404
+ // its local hiding choice; source/provider archives and tasks stay intact.
405
+ if (manualAdd && result.ok === true) {
406
+ if (preferenceScope !== expectedScope || !c.ready || closed) throw error('Controller account or remote connection changed.', 'remote_host_access');
407
+ await preferences.set(expectedScope, command, { hidden: false });
408
+ if (preferenceScope !== expectedScope) throw error('Controller account changed.', 'remote_host_access');
409
+ changed();
410
+ }
231
411
  return result;
232
412
  }
233
413
  function project(status, { providerLinks = status.providerLinks } = {}) {
234
414
  if (!usable(status) || scopeOf(status) !== scope) return status;
235
415
  const hosts = [...connections.values()];
236
- const sessions = [], runs = [], selectionCandidates = [];
416
+ const sessions = [], runs = [], tasks = [], sqmTasks = [], selectionCandidates = [];
237
417
  for (const c of hosts) {
238
418
  const ready = c.ready && now() - c.receivedAt <= 45_000;
239
419
  const unavailableReason = c.reason || 'Remote status is stale.';
@@ -244,8 +424,23 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
244
424
  for (const row of c.status?.sessionSupervision?.sessions || []) {
245
425
  if (!row || !['codex', 'claude'].includes(row.provider) || typeof row.session !== 'string' ||
246
426
  providerLinks?.[row.provider]?.connected === false) continue;
247
- const item = stamp(row, c);
248
- item.remoteHost = { id: c.id, label: c.label, state: ready ? 'ready' : c.state === 'ready' ? 'unavailable' : c.state, reason: ready ? null : c.reason || 'Remote status is stale.' };
427
+ const item = { ...stamp(row, c), ...metadata.get(JSON.stringify([c.id, row.provider, row.session])),
428
+ sourceActionTaskId: row.actionAssociation?.binding?.taskId || row.taskId || row.binding?.taskId, sourceActionDeadline: row.actionDeadline };
429
+ const saved = preferences.get(preferenceScope, item);
430
+ if (saved.pinned !== undefined) item.listPinned = saved.pinned;
431
+ if (saved.pinScopes !== undefined) item.listPinScopes = saved.pinScopes;
432
+ if (saved.hidden !== undefined) { item.localHidden = item.listHidden = saved.hidden; item.archived = row.providerArchived === true || saved.hidden; }
433
+ if (Array.isArray(c.capabilities)) {
434
+ const supports = action => c.capabilities.includes(action);
435
+ const commands = { canMonitor: 'session_monitor', canPurposeContinue: 'session_continue', canAnalyze: 'session_analyze', canEnroll: 'session_enroll', canContinue: 'session_continue', canAutoContinue: 'session_set_auto_continue' };
436
+ item.capabilities = Object.fromEntries(Object.entries(item.capabilities || {}).map(([key,value]) => [key, value === true && (!commands[key] || supports(commands[key]))]));
437
+ if (item.followup && !supports('session_followup')) item.followup = null;
438
+ if (item.decisionRequest) item.decisionRequest = { ...item.decisionRequest, choices: (item.decisionRequest.choices || []).filter(choice => choice.action === 'open_original' || supports(choice.action)) };
439
+ }
440
+ item.controllerPreferences = saved;
441
+ item.controllerPolicy = saved.policy || null;
442
+ item.controllerCanSetAutoContinue = true;
443
+ item.remoteHost = { id: c.id, label: c.label, state: ready ? 'ready' : c.state === 'ready' ? 'unavailable' : c.state, transportId: ready ? c.transportId : null, reason: ready ? null : c.reason || 'Remote status is stale.' };
249
444
  // Codex's host-specific deep link contract is unverified. Preserve exact
250
445
  // navigation context instead of silently opening a cloned local UUID.
251
446
  item.originalSessionTarget = { kind: 'manual', url: null, label: 'Find in remote provider', verifiedOnPlatform: false,
@@ -257,17 +452,130 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
257
452
  item.autoContinueStatus = 'unavailable'; item.autoContinueReason = unavailableReason;
258
453
  item.isRunning = false; item.availability = 'unknown'; item.reason = unavailableReason; item.decisionRequest = null;
259
454
  item.workState = { state: 'unknown', label: '接続先を確認できません', reason: unavailableReason, stale: true, fetchFailed: true, checkedAt: c.receivedAt, needsAttention: false };
260
- item.statistics = { state: 'unavailable', hours: [], activity: [] };
455
+ item.statistics = saved.statistics ? { ...saved.statistics, stale: true, cached: true } : { state: 'unavailable', hours: [], activity: [] };
261
456
  }
457
+ if (ready && !['ready','partial'].includes(item.statistics?.state) && saved.statistics) item.statistics = { ...saved.statistics, stale: true, cached: true };
458
+ item.executionContextSync = saved.policy?.session_sync_context?.state || null;
459
+ const auto = saved.policy?.session_set_auto_continue;
460
+ if (auto) { item.autoContinueDesired = auto.command.enabled; item.autoContinueSync = auto.state; }
262
461
  sessions.push(item);
263
462
  }
264
- if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (['codex', 'claude'].includes(run?.provider) && providerLinks?.[run.provider]?.connected !== false) runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in ${run.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${run.session}.` } });
463
+ if (ready) {
464
+ for (const task of c.status?.tasks?.items || []) tasks.push({ ...stamp(task, c), key: JSON.stringify(['remote-task',c.id,task.key || task.id]), hostId: c.id });
465
+ for (const task of c.status?.sqmTasks?.items || []) sqmTasks.push({ ...stamp(task, c), key: JSON.stringify(['remote-task',c.id,task.key || task.id]), hostId: c.id });
466
+ }
467
+ if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (['codex', 'claude'].includes(run?.provider) && providerLinks?.[run.provider]?.connected !== false) {
468
+ const projected = { ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in ${run.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${run.session}.` } };
469
+ if (Array.isArray(c.capabilities)) for (const [flag, action] of Object.entries({ canStop:'delivery_stop', canPause:'delivery_pause', canResume:'delivery_resume', canApprove:'delivery_approve', canReject:'delivery_reject', canReplan:'delivery_replan', canAnswer:'session_answer', canResolveDependency:'delivery_dependency_ready', canSetPolicy:'delivery_set_policy' })) {
470
+ if (!c.capabilities.includes(action)) projected[flag] = false;
471
+ }
472
+ runs.push(projected);
473
+ }
474
+ }
475
+ function aggregate(key, rows) {
476
+ const localItems = (status[key]?.items || []).filter(row => !row.hostId || row.hostId === 'local');
477
+ const priorRemote = status[key]?.remoteActiveCount || 0;
478
+ const remoteActiveCount = hosts.filter(c => c.ready).reduce((n,c) => n + (c.status?.[key]?.activeCount || 0), 0);
479
+ return { ...status[key], items: [...localItems, ...rows], localActiveCount: Math.max(0, (status[key]?.activeCount || 0) - priorRemote), activeCount: Math.max(0, (status[key]?.activeCount || 0) - priorRemote) + remoteActiveCount, remoteActiveCount, unknownHosts: hosts.filter(c => !c.ready).map(c => c.id) };
265
480
  }
266
- return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, reason: c.reason })) },
481
+ return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, transportId: c.ready ? c.transportId : null, reason: c.reason })) },
482
+ tasks: aggregate('tasks', tasks), sqmTasks: aggregate('sqmTasks', sqmTasks),
267
483
  sessionSupervision: { ...status.sessionSupervision, selectionCandidates: [...(status.sessionSupervision?.selectionCandidates || []).filter(row => !row.hostId || row.hostId === 'local'), ...selectionCandidates], sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
268
484
  deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
269
485
  }
270
- return { update, save, edit, setEnabled, forward, project,
486
+ async function flushPolicies(connection) {
487
+ if (!connection || connection.flushing || closed || !connection.ready) return;
488
+ connection.flushing = true;
489
+ const expectedScope = preferenceScope;
490
+ try {
491
+ const state = await preferences.load(expectedScope);
492
+ const pending = Object.entries(state.conversations).flatMap(([key, row]) => {
493
+ if (JSON.parse(key)[0] !== connection.id) return [];
494
+ return Object.values(row.policy || {}).filter(item => ['pending', 'unknown'].includes(item.state)).map(item => ({ key, item }));
495
+ });
496
+ // Stops for every conversation take precedence over starts on this host.
497
+ pending.sort((a, b) => Number(stopsWork(b.item.command)) - Number(stopsWork(a.item.command)));
498
+ for (const { key, item } of pending) {
499
+ if (preferenceScope !== expectedScope || !connection.ready || closed) return;
500
+ const latest = preferences.get(expectedScope, item.command).policy?.[item.command.action];
501
+ if (latest?.command.id !== item.command.id) continue;
502
+ if (item.state === 'unknown' && !(connection.status?.remoteControl?.version >= 2)) continue;
503
+ const link = local.providerLinks?.[item.command.provider];
504
+ let result;
505
+ try {
506
+ if (startsWork(item.command) && (link?.connected === false || (item.providerEpoch ?? null) !== (link?.epoch ?? null)))
507
+ throw error('Provider consent changed before this queued operation.', 'remote_host_access');
508
+ result = await forward(item.command, local);
509
+ } catch (failure) {
510
+ // A missing conversation or unsupported action must not starve other
511
+ // conversations. A disconnected transport retains its queued intent.
512
+ const nextState = failure.code === 'remote_operation_unknown' ? 'unknown'
513
+ : failure.code === 'remote_host_unavailable' ? item.state : 'failed';
514
+ result = { ok: false, code: failure.code, nextState };
515
+ }
516
+ await preferences.change(expectedScope, data => {
517
+ const current = data.conversations[key]?.policy?.[item.command.action];
518
+ if (current?.command.id === item.command.id) {
519
+ current.state = result.nextState || (result.ok ? 'applied' : result.code === 'remote_operation_unknown' ? 'unknown' : 'failed');
520
+ current.code = result.code;
521
+ }
522
+ });
523
+ changed();
524
+ if (!connection.ready || result.code === 'remote_host_unavailable') return;
525
+ }
526
+ } finally { connection.flushing = false; }
527
+ }
528
+ async function control(command) {
529
+ if (!controllerPolicyActions.has(command.action) || !candidate(command) || !preferenceScope) throw error('Select a known remote conversation.', 'remote_host_invalid');
530
+ if (command.action === 'session_set_auto_continue' && typeof command.enabled !== 'boolean') throw error('Invalid continuation setting.', 'remote_host_invalid');
531
+ const expectedScope = preferenceScope;
532
+ await preferences.change(expectedScope, state => {
533
+ const key = JSON.stringify([command.hostId, command.provider, command.session]);
534
+ const row = state.conversations[key] ||= {}; row.policy ||= {};
535
+ // Opposite preferences supersede unsent earlier choices.
536
+ const opposite = { session_track: 'session_untrack', session_untrack: 'session_track', session_monitor: 'session_unmonitor', session_unmonitor: 'session_monitor' }[command.action];
537
+ if (opposite) delete row.policy[opposite];
538
+ if (['delivery_stop','delivery_pause'].includes(command.action) && row.policy.session_set_auto_continue?.command.enabled) delete row.policy.session_set_auto_continue;
539
+ row.policy[command.action] = { command: { ...command, id: randomUUID(), requestId: command.requestId || randomUUID() },
540
+ providerEpoch: local.providerLinks?.[command.provider]?.epoch ?? null, state: 'pending' };
541
+ });
542
+ if (preferenceScope !== expectedScope) throw error('Controller account changed.', 'remote_host_access');
543
+ changed(); void flushPolicies(connections.get(command.hostId)).catch(() => {});
544
+ return { type: 'result', id: command.id, ok: true, applied: false };
545
+ }
546
+ function candidate(command) {
547
+ const c = connections.get(command.hostId);
548
+ const row = c?.status?.sessionSupervision?.sessions?.find(row => row.provider === command.provider && row.session === command.session);
549
+ return row ? { ...stamp(row, c), hostId: c.id } : null;
550
+ }
551
+ async function setPreference(command) {
552
+ const target = candidate(command);
553
+ if (!target || !preferenceScope) throw error('Select a known conversation.', 'remote_host_invalid');
554
+ const expectedScope = preferenceScope;
555
+ if (command.action === 'session_set_pinned') {
556
+ if (typeof command.enabled !== 'boolean') throw error('Invalid preference.', 'remote_host_invalid');
557
+ const pinScope = command.pinScope ?? 'legacy';
558
+ if (typeof pinScope !== 'string' || !pinScope || pinScope.length > 512) throw error('Invalid pin scope.', 'remote_host_invalid');
559
+ const current = preferences.get(expectedScope, target);
560
+ const scopes = new Set(current.pinScopes ?? target.listPinScopes ?? (target.listPinned ? ['legacy'] : []));
561
+ if (command.enabled) scopes.add(pinScope); else scopes.delete(pinScope);
562
+ await preferences.set(expectedScope, target, { pinned: scopes.size > 0, pinScopes: [...scopes], metadata: { title: target.title, createdAt: target.createdAt, lastActivityAt: target.lastActivityAt } });
563
+ } else if (['session_archive','session_restore'].includes(command.action) && command.scope === 'local') {
564
+ await preferences.set(expectedScope, target, { hidden: command.action === 'session_archive' });
565
+ } else throw error('Unsupported preference.', 'remote_host_invalid');
566
+ if (preferenceScope !== expectedScope) throw error('Controller account changed.', 'remote_host_access');
567
+ changed(); return { type: 'result', id: command.id, ok: true };
568
+ }
569
+ function supportsReplay(command) {
570
+ const c = connections.get(command?.hostId);
571
+ return Boolean(command?.hostId && command.hostId !== 'local' && c?.ready && c.child && now() - c.receivedAt <= 45_000 &&
572
+ c.status?.remoteControl?.version >= 2 && c.capabilities?.includes(command.action) && usable(local) && scopeOf(local) === scope);
573
+ }
574
+ return { update, save, edit, setEnabled, configure, forward, forwardTask, supportsReplay, isRemoteTask: key => Boolean(remoteTask(key)), project, candidate, setPreference, control,
575
+ setMetadata(rows) { for (const row of rows) metadata.set(JSON.stringify([row.hostId,row.provider,row.session]), { actionAssociation: row.actionAssociation, actionDeadline: row.actionDeadline, actionTaskId: row.actionTaskId }); },
576
+ completions: () => [...connections.values()].flatMap(c => ((preferences.peek(preferenceScope)?.hosts?.[c.id] || c.status)?.controllerCompletions || []).filter(item =>
577
+ ['codex','claude'].includes(item?.provider) && typeof item.provider_session_id === 'string' && typeof item.task_id === 'string'
578
+ ).map(item => ({ ...item, host_id: c.id }))),
271
579
  hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => ['codex', 'claude'].includes(s?.provider) && s.session === session)),
272
- async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); } };
580
+ async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); await preferences.close(); } };
273
581
  }