@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.
- package/README.md +88 -3
- package/package.json +4 -4
- package/src/agent.js +1 -0
- package/src/cli-contract.js +1 -1
- package/src/cli-observability.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +5 -3
- package/src/codex-plugins/simy/hooks/acceptance-policy.mjs +7 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +570 -65
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +6 -3
- package/src/codex-plugins/simy/hooks/purpose-search.mjs +23 -4
- package/src/codex-plugins/simy/hooks/scene-reasoning.mjs +2 -0
- package/src/codex-plugins/simy/hooks/update-dispatch-gate.mjs +126 -4
- package/src/codex-plugins/simy/skills/continue/SKILL.md +69 -0
- package/src/codex-plugins/simy/skills/continue/agents/openai.yaml +6 -0
- package/src/delivery-hooks/claude-workflow.md +3 -3
- package/src/delivery-hooks/command.js +12 -0
- package/src/delivery-hooks/desktop.js +31 -9
- package/src/delivery-hooks/supervisor.js +3 -2
- package/src/desktop-access.js +4 -1
- package/src/desktop-executor.js +6 -0
- package/src/desktop-host.js +494 -72
- package/src/native-development-report-completion.js +26 -0
- package/src/native-development-reports.js +15 -5
- package/src/orchestrator/budget.js +24 -0
- package/src/orchestrator/charter-repair.js +1 -1
- package/src/orchestrator/completion-contract.js +8 -2
- package/src/orchestrator/contract.js +3 -1
- package/src/orchestrator/independent-audit.js +13 -6
- package/src/orchestrator/instruction.js +1 -1
- package/src/orchestrator/loop.js +34 -4
- package/src/provider-stream.js +9 -2
- package/src/runner.js +44 -21
- package/src/session-supervision/action-deadline.js +18 -1
- package/src/session-supervision/autonomous-upgrade.js +4 -2
- package/src/session-supervision/claude-desktop-archive.js +57 -0
- package/src/session-supervision/claude-desktop-native.js +50 -0
- package/src/session-supervision/claude-desktop-native.jxa.js +195 -0
- package/src/session-supervision/claude-desktop-target.js +87 -0
- package/src/session-supervision/claude-outcome-review.js +132 -0
- package/src/session-supervision/claude-owner-renewal.js +197 -0
- package/src/session-supervision/claude-review-definition.js +5 -0
- package/src/session-supervision/claude-sessions.js +210 -40
- package/src/session-supervision/codex-app-server.js +189 -36
- package/src/session-supervision/continuation-analysis.js +7 -7
- package/src/session-supervision/continuation-command.js +45 -0
- package/src/session-supervision/controller-store.js +48 -0
- package/src/session-supervision/conversation-presentation.js +241 -0
- package/src/session-supervision/decision-presentation.js +96 -35
- package/src/session-supervision/execution-context.js +39 -0
- package/src/session-supervision/index.js +38 -9
- package/src/session-supervision/manual-acceptance-command.js +30 -0
- package/src/session-supervision/notification-authority.js +9 -0
- package/src/session-supervision/notification-journal.js +40 -0
- package/src/session-supervision/operation-journal.js +42 -0
- package/src/session-supervision/owner-transport.js +146 -0
- package/src/session-supervision/purpose-recovery.js +10 -2
- package/src/session-supervision/purpose-verification.js +103 -9
- package/src/session-supervision/remote-control.js +1 -1
- package/src/session-supervision/remote-entry.js +2 -0
- package/src/session-supervision/remote-errors.js +15 -0
- package/src/session-supervision/remote-hosts.js +344 -36
- package/src/session-supervision/remote-metadata.js +103 -0
- package/src/session-supervision/remote-session-list.js +2 -4
- package/src/session-supervision/session-archive-command.js +45 -0
- package/src/session-supervision/session-list.js +40 -4
- package/src/session-supervision/sqm-presentation.js +46 -3
- package/src/session-supervision/stop-notifications.js +42 -11
- package/src/session-supervision/work-state.js +106 -6
- package/src/sqm/agent-skill.js +3 -3
- package/src/sqm/claude-plugin.js +7 -2
- package/src/sqm/history-store.js +6 -0
- package/src/team-threads.js +1 -1
- package/src/update-detachment/coordinator.js +7 -1
- package/src/update-detachment/history-recovery.js +18 -0
- package/src/update-detachment/ledger-adapter.js +1 -0
- package/src/update-detachment/retained-paused.js +290 -0
- 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
|
-
//
|
|
52
|
-
//
|
|
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
|
|
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',
|
|
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 = '
|
|
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.
|
|
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.
|
|
124
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
c.pending.set(id, { id: command.id,
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
}
|