@awak-app/simy-cli 0.7.20 → 0.7.21
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 +57 -12
- package/package.json +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +9 -1
- package/src/codex-work-pattern-sync.js +243 -43
- package/src/console/sessions.js +6 -6
- package/src/delivery-hooks/command.js +8 -6
- package/src/delivery-hooks/lifecycle-recovery.js +82 -0
- package/src/delivery-hooks/local-supervisor.js +25 -2
- package/src/delivery-hooks/supervisor-activation.js +25 -0
- package/src/delivery-hooks/supervisor-service.js +2 -2
- package/src/delivery-hooks/supervisor-watchdog.js +11 -6
- package/src/desktop-host.js +19 -5
- package/src/index.js +1 -1
- package/src/session-supervision/claude-sessions.js +7 -7
- package/src/session-supervision/codex-app-server.js +19 -9
- package/src/session-supervision/codex-peer-opinion.js +128 -0
- package/src/session-supervision/index.js +21 -12
- package/src/session-supervision/remote-control.js +27 -0
- package/src/session-supervision/remote-errors.js +22 -0
- package/src/session-supervision/remote-hosts.js +17 -13
- package/src/session-supervision/remote-session-list.js +56 -0
- package/src/session-supervision/session-list.js +83 -23
|
@@ -14,7 +14,7 @@ import { loadPurposeRuleSet } from './purpose-instruction.js';
|
|
|
14
14
|
import { originalSessionTarget } from './original-session-target.js';
|
|
15
15
|
import { readCodexChildSessions } from './codex-children.js';
|
|
16
16
|
import { readCodexTitles, sessionTitle } from './session-titles.js';
|
|
17
|
-
import {
|
|
17
|
+
import { recentSessionWindow, retainedSession } from './discovery-window.js';
|
|
18
18
|
import { requireProviderLink } from '../provider-links.js';
|
|
19
19
|
|
|
20
20
|
// Only product-authored eligibility messages may pass through the desktop
|
|
@@ -29,7 +29,8 @@ export class AutoContinueUnavailableError extends Error {
|
|
|
29
29
|
// Opt-in is independent of a busy provider turn. Dispatch still requires a
|
|
30
30
|
// settled context and the original provider's execution boundary.
|
|
31
31
|
function autoContinueAvailability(run, channel) {
|
|
32
|
-
const issue = !channel.ready ? channel : run
|
|
32
|
+
const issue = !channel.ready ? channel : run?.state === 'stopped'
|
|
33
|
+
? { code: 'task_stopped', reason: 'This task was stopped. Review the original conversation and explicitly request resumption before continuing.' } : run && !run.managed && !canUpgradeDelivery(run)
|
|
33
34
|
? { code: 'existing_loop', reason: 'This conversation already has a Delivery Loop. Use its existing controls.' }
|
|
34
35
|
: TERMINAL_RUNS.has(run?.state)
|
|
35
36
|
? { code: 'task_settled', reason: 'This supervised task has settled. Review its existing status before continuing.' } : null;
|
|
@@ -139,7 +140,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
139
140
|
await Promise.all([
|
|
140
141
|
(async () => {
|
|
141
142
|
try { if (connected.has('codex')) discovered = await withClient(openClient, async client => {
|
|
142
|
-
const active = await listCodexSessions({ client,
|
|
143
|
+
const active = await listCodexSessions({ client, all: true, signal, inspectState: true, codexHome,
|
|
143
144
|
prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) });
|
|
144
145
|
if (includeArchived) {
|
|
145
146
|
const observedArchives = new Map();
|
|
@@ -153,7 +154,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
153
154
|
}
|
|
154
155
|
return response;
|
|
155
156
|
} };
|
|
156
|
-
try { await listCodexSessions({ client: archiveClient,
|
|
157
|
+
try { await listCodexSessions({ client: archiveClient, all: true, signal, archived: true }); }
|
|
157
158
|
catch { /* Keep positive archive pages even if a later page fails. */ }
|
|
158
159
|
archivedCodex = [...observedArchives.values()];
|
|
159
160
|
}
|
|
@@ -168,7 +169,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
168
169
|
}
|
|
169
170
|
})(),
|
|
170
171
|
(async () => {
|
|
171
|
-
try { if (connected.has('claude')) claude = await listClaude({ signal,
|
|
172
|
+
try { if (connected.has('claude')) claude = await listClaude({ signal, all: true,
|
|
172
173
|
prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
|
|
173
174
|
catch { claudeError = 'Claude conversation discovery unavailable.'; }
|
|
174
175
|
})(),
|
|
@@ -237,16 +238,17 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
237
238
|
availability: 'unknown', model: record.admission?.model || null,
|
|
238
239
|
});
|
|
239
240
|
}
|
|
240
|
-
//
|
|
241
|
-
// visible even during a truncated discovery or provider outage.
|
|
241
|
+
// Catalog visibility is complete; only live process probes use a recent window.
|
|
242
242
|
const catalog = [...byKey.values()].map(item => {
|
|
243
243
|
const record = recordsByKey.get(`${item.provider}:${item.session}`);
|
|
244
244
|
const parentSession = navigationParent(item, record);
|
|
245
245
|
return { ...item, localHidden: record?.listHidden === true, ...(parentSession ? { parentSession } : {}) };
|
|
246
246
|
});
|
|
247
|
-
const
|
|
247
|
+
const liveItems = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
|
|
248
248
|
priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
|
|
249
249
|
}), ...recentSessionWindow(catalog.filter(item => item.archived === true || item.localHidden), { priority: item => item.localHidden })];
|
|
250
|
+
const liveKeys = new Set(liveItems.map(item => `${item.provider}:${item.session}`));
|
|
251
|
+
const items = [...catalog];
|
|
250
252
|
// Include archived ancestors of active rows even when archive visibility is off.
|
|
251
253
|
const catalogByKey = new Map(catalog.map(item => [`${item.provider}:${item.session}`, item]));
|
|
252
254
|
const includedKeys = new Set(items.map(item => `${item.provider}:${item.session}`));
|
|
@@ -285,7 +287,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
285
287
|
const supported = item.provider === 'codex' && codexChannel.ready;
|
|
286
288
|
const claudeChannel = item.provider === 'claude' ? claudeError
|
|
287
289
|
? { ready: false, code: 'session_metadata_unavailable', reason: claudeError }
|
|
288
|
-
: await liveClaudeChannel(record, item, inspectProcess) : null;
|
|
290
|
+
: await liveClaudeChannel(record, item, liveKeys.has(`${item.provider}:${item.session}`) ? inspectProcess : async () => null) : null;
|
|
289
291
|
const liveHook = claudeChannel?.ready === true;
|
|
290
292
|
let continuationAdmission;
|
|
291
293
|
if (supported || liveHook) {
|
|
@@ -308,7 +310,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
308
310
|
// A hook newer than the persisted provider activity may describe a
|
|
309
311
|
// new turn. Require its same live process; older hooks cannot override
|
|
310
312
|
// a more recent completed provider turn.
|
|
311
|
-
if (record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
313
|
+
if (liveKeys.has(`${item.provider}:${item.session}`) && record.runtime.isRunning && await inspectProcess(record.runtime.process.pid) === record.runtime.process.identity) { isRunning = true; observation = { ...item.observation, observedAt: record.runtime.observedAt }; }
|
|
312
314
|
else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
|
|
313
315
|
} catch { /* Unverified process leaves runtime status unknown. */ }
|
|
314
316
|
}
|
|
@@ -326,6 +328,12 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
|
|
|
326
328
|
autoContinue: !archived && record?.autoContinue === true,
|
|
327
329
|
autoContinueEpoch: record?.autoContinue === true ? record.autoContinueEpoch : null,
|
|
328
330
|
autoContinueAvailability: availability,
|
|
331
|
+
// A finished ordinary report needs an explicit current goal, not an
|
|
332
|
+
// implicit reset through the auto-continue preference.
|
|
333
|
+
followup: !archived && supported && item.provider === 'codex' &&
|
|
334
|
+
run?.state === 'reported_done' && !run.managed && run.id && !record.automaticStartBlocked &&
|
|
335
|
+
!record.associationTaskId && !(record.autoContinue === false && record.autoContinueEpoch)
|
|
336
|
+
? { previousRunId: run.id } : null,
|
|
329
337
|
...autoContinueState(record, isRunning, availability),
|
|
330
338
|
lastActivityAt: item.lastActivityAt, createdAt: item.createdAt ?? null, observation, availability: item.availability,
|
|
331
339
|
taskId: run?.managed?.taskId || record?.associationTaskId || null,
|
|
@@ -369,6 +377,7 @@ function hasAutoContinueAdmission(record, provider, session) {
|
|
|
369
377
|
(provider !== 'codex' || typeof admission.model === 'string' && admission.model.trim());
|
|
370
378
|
}
|
|
371
379
|
function requireAutoContinueTask(record) {
|
|
380
|
+
if (record?.run?.state === 'stopped') throw new AutoContinueUnavailableError({ reasonCode: 'task_stopped', reason: 'This task was stopped. Review the original conversation and explicitly request resumption before continuing.' });
|
|
372
381
|
if (record?.run && !record.run.managed && !canUpgradeDelivery(record.run)) throw Object.assign(new Error('This conversation already has a Delivery Loop. Use its existing controls.'), { code: 'existing_loop' });
|
|
373
382
|
if (TERMINAL_RUNS.has(record?.run?.state)) throw new AutoContinueUnavailableError({ reason: 'This supervised task has settled. Review its existing status before continuing.', reasonCode: 'task_settled' });
|
|
374
383
|
}
|
|
@@ -513,7 +522,7 @@ export async function monitorSession({ root, provider, session, monitor = true,
|
|
|
513
522
|
|
|
514
523
|
export async function enrollSession(options) {
|
|
515
524
|
const { root, provider, session, objective, acceptanceCriteria, contextBootstrap = false, autonomyPolicy,
|
|
516
|
-
maxMinutes, maxContinuations, maxTokens, purposeDriven, purposeVersion, maxFailures, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, previousRunId, reconcileRunId, expectedProviderEpoch, openClient = openCodexAppServer,
|
|
525
|
+
maxMinutes, maxContinuations, maxTokens, purposeDriven, purposeVersion, maxFailures, maxNoProgressMinutes, continuationPolicy, autoContinueEpoch, executionCwd, upgradeRunId, previousRunId, reconcileRunId, followupAutoContinue, expectedProviderEpoch, openClient = openCodexAppServer,
|
|
517
526
|
readClaude = readClaudeSession, inspectProcess = processIdentity, now = Date.now() } = sessionLoopOptions(options);
|
|
518
527
|
if (!['codex', 'claude'].includes(provider)) throw new Error('This provider does not have a verified exact-session enrollment channel.');
|
|
519
528
|
const link = await requireProviderLink({ root, provider, expectedEpoch: expectedProviderEpoch });
|
|
@@ -541,7 +550,7 @@ export async function enrollSession(options) {
|
|
|
541
550
|
const liveRecord = provider === 'claude' ? await readRecord(root, provider, session) : null;
|
|
542
551
|
await requireProviderLink({ root, provider, expectedEpoch: link.epoch });
|
|
543
552
|
const rules = purposeDriven ? await loadPurposeRuleSet() : null;
|
|
544
|
-
const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, previousRunId, reconcileRunId, providerLinkEpoch: link.epoch, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
553
|
+
const run = await startDelivery({ root, provider, session, cwd, executionCwd, upgradeRunId, previousRunId, reconcileRunId, followupAutoContinue, providerLinkEpoch: link.epoch, objective: objective.trim(), nextStep: 'Inspect current evidence and propose the next assessed action.',
|
|
545
554
|
maxMinutes, maxContinuations, maxFailures, continuationPolicy, now, admission, inspectProcess, autoContinueEpoch,
|
|
546
555
|
liveHookAdmission: liveRecord ? { hookStartedAt: liveRecord.hookStartedAt, pid: liveRecord.process.pid, identity: liveRecord.process.identity } : undefined,
|
|
547
556
|
managed: { ...(rules ? { purpose: { enabled: true, version: purposeVersion, ...(purposeVersion === 2 ? { decisionPolicy: 'llm-first-hc102' } : {}), failureBudgetMode: maxFailures === undefined ? 'strategy' : 'hard', ruleDigest: rules.digest, ruleIds: rules.rules.map(rule => rule.id), recoveries: [] } } : {}), maxTokens, acceptanceCriteria, contextBootstrap, autonomyPolicy, maxNoProgressMinutes, approvalScopes: ['task'], approvalPolicy: 'wait',
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Only host-discovered admission may reach the native mutation API.
|
|
2
|
+
export function createControl({ authorize, select, set, followup, save, revoke, root }) {
|
|
3
|
+
return async command => {
|
|
4
|
+
const isFollowup = command?.action === 'session_followup';
|
|
5
|
+
if (!['session_set_auto_continue', 'session_followup'].includes(command?.action) || command.provider !== 'codex' ||
|
|
6
|
+
typeof command.session !== 'string' || !/^[a-f0-9-]{36}$/.test(command.session) ||
|
|
7
|
+
(!isFollowup && typeof command.enabled !== 'boolean') ||
|
|
8
|
+
(isFollowup && (typeof command.objective !== 'string' || !command.objective.trim() || command.objective.length > 4000)) ||
|
|
9
|
+
command.continuationPolicy !== undefined && !['bounded','progress'].includes(command.continuationPolicy)) throw new Error('Unsupported remote command');
|
|
10
|
+
const scope = await authorize();
|
|
11
|
+
const candidate = await select(command);
|
|
12
|
+
if (!candidate || candidate.provider !== 'codex' || candidate.session !== command.session) throw new Error('Select a known conversation');
|
|
13
|
+
if (isFollowup) {
|
|
14
|
+
if (typeof followup !== 'function' || !candidate.followup?.previousRunId || command.previousRunId !== candidate.followup.previousRunId) throw new Error('Refresh the finished conversation before requesting follow-up.');
|
|
15
|
+
await authorize(scope);
|
|
16
|
+
await followup({root,provider:'codex',session:command.session,previousRunId:command.previousRunId,objective:command.objective,followupAutoContinue:true,expectedProviderEpoch:scope.providerEpoch});
|
|
17
|
+
await save({...command,enabled:true},scope);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (command.enabled && (candidate.autoContinueAvailability?.canEnable ?? candidate.capabilities?.canAutoContinue) !== true) throw Object.assign(new Error(candidate.autoContinueAvailability?.reason || 'Continuation unavailable'), {code:candidate.autoContinueAvailability?.reasonCode});
|
|
21
|
+
await authorize(scope);
|
|
22
|
+
await set({root, provider:'codex',session:command.session,enabled:command.enabled,
|
|
23
|
+
continuationPolicy:command.continuationPolicy, admission:candidate.continuationAdmission,expectedProviderEpoch:scope.providerEpoch});
|
|
24
|
+
if (!command.enabled) revoke('codex',command.session);
|
|
25
|
+
await save(command,scope);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Remote diagnostics are selected from product-owned text. Never display
|
|
2
|
+
// arbitrary SSH stderr, provider exceptions, paths or remote frame messages.
|
|
3
|
+
const messages = {
|
|
4
|
+
process_conflict: 'Another SIMY service owns this remote host. Reconcile its existing service before connecting; do not stop unrelated work.',
|
|
5
|
+
desktop_access_required: 'Sign in to the same SIMY account and organization on the remote host and verify its access.',
|
|
6
|
+
provider_link_disconnected: 'Reconnect this AI provider to SIMY on the remote host.',
|
|
7
|
+
provider_link_changed: 'The remote provider connection changed. Refresh and select the conversation again.',
|
|
8
|
+
session_metadata_unavailable: 'Refresh the remote conversation list; its exact session metadata is unavailable.',
|
|
9
|
+
session_model_unavailable: 'Select a model in the original remote conversation, then refresh.',
|
|
10
|
+
model_metadata_unavailable: 'Select a model in the original remote conversation, then refresh.',
|
|
11
|
+
session_workspace_changed: 'The remote workspace changed. Refresh and select the conversation again.',
|
|
12
|
+
session_archived: 'Restore the original remote conversation before enabling Auto-continue.',
|
|
13
|
+
existing_loop: 'Use this remote conversation’s existing task controls.',
|
|
14
|
+
task_stopped: 'This remote task was stopped. Review the original conversation and explicitly request resumption.',
|
|
15
|
+
task_settled: 'The remote task has finished. Refresh and select its explicit follow-up controls.',
|
|
16
|
+
cancelled: 'The remote operation was cancelled.',
|
|
17
|
+
};
|
|
18
|
+
export function remoteOperationFailure(code) {
|
|
19
|
+
return typeof code === 'string' && Object.hasOwn(messages, code)
|
|
20
|
+
? { code, message: messages[code] }
|
|
21
|
+
: { message: 'Remote operation could not finish. Check the original conversation and refresh before retrying.' };
|
|
22
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { remoteOperationFailure } from './remote-errors.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { spawn } from 'node:child_process';
|
|
3
4
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
@@ -14,7 +15,7 @@ const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
|
14
15
|
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);';
|
|
15
16
|
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)"`)}`;
|
|
16
17
|
const allowedActions = new Set(['refresh', 'session_set_pinned', 'session_statistics', 'session_archive', 'session_restore', 'session_track', 'session_untrack',
|
|
17
|
-
'session_monitor', 'session_unmonitor', 'session_enroll', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
|
|
18
|
+
'session_monitor', 'session_unmonitor', 'session_enroll', 'session_followup', 'session_set_auto_continue', 'session_analyze', 'session_continue', 'session_answer',
|
|
18
19
|
'session_deadline_decide', 'session_action_refresh', 'session_action_select', 'session_action_unlink',
|
|
19
20
|
'delivery_pause', 'delivery_stop', 'delivery_reject', 'delivery_resume', 'delivery_replan', 'delivery_approve',
|
|
20
21
|
'delivery_dependency_ready', 'delivery_set_policy', 'delivery_continuation_policy', 'delivery_verify']);
|
|
@@ -25,7 +26,7 @@ function normalizedHost(value) {
|
|
|
25
26
|
if (label.length > 80 || /[\x00-\x1f\x7f]/.test(label)) throw error('Choose a shorter host name.', 'remote_host_invalid');
|
|
26
27
|
return { alias: value.alias, label: label || value.alias, id: remoteHostId(value.alias) };
|
|
27
28
|
}
|
|
28
|
-
async function readHosts(root) {
|
|
29
|
+
export async function readHosts(root) {
|
|
29
30
|
let value;
|
|
30
31
|
try { value = JSON.parse(await readFile(join(root, 'remote-hosts.json'), 'utf8')); }
|
|
31
32
|
catch (e) { if (e.code === 'ENOENT') return []; throw error('Remote host settings could not be read.', 'remote_host_settings'); }
|
|
@@ -42,7 +43,7 @@ function stamp(value, host, depth = 0) {
|
|
|
42
43
|
if (Array.isArray(value)) return value.map(item => stamp(item, host, depth + 1));
|
|
43
44
|
if (!value || typeof value !== 'object') return value;
|
|
44
45
|
const result = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, stamp(item, host, depth + 1)]));
|
|
45
|
-
if (result.provider
|
|
46
|
+
if (['codex', 'claude'].includes(result.provider) && typeof result.session === 'string') result.hostId = host.id;
|
|
46
47
|
return result;
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -113,13 +114,13 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
113
114
|
}
|
|
114
115
|
changed();
|
|
115
116
|
} else if (frame.type === 'error') {
|
|
116
|
-
disconnect(connection,
|
|
117
|
+
disconnect(connection, remoteOperationFailure(frame.code).message); changed(); return;
|
|
117
118
|
} else if (frame.type === 'result') {
|
|
118
119
|
const pending = connection.pending.get(frame.id);
|
|
119
120
|
if (!pending) continue;
|
|
120
121
|
connection.pending.delete(frame.id); clearTimeout(pending.timer);
|
|
121
122
|
pending.resolve({ type: 'result', id: pending.id, ok: frame.ok === true,
|
|
122
|
-
...(frame.ok !== true ?
|
|
123
|
+
...(frame.ok !== true ? remoteOperationFailure(frame.code) : {}) });
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
});
|
|
@@ -167,10 +168,11 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
167
168
|
}
|
|
168
169
|
async function forward(command, status) {
|
|
169
170
|
if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
|
|
171
|
+
if (status.providerLinks?.[command.provider]?.connected === false) throw error('Provider is disconnected.', 'remote_host_access');
|
|
170
172
|
const c = connections.get(command.hostId);
|
|
171
173
|
if (!c?.ready || !c.child || now() - c.receivedAt > 45_000) throw error('The remote host is disconnected. Reconnect before using this conversation.');
|
|
172
|
-
if (!allowedActions.has(command.action) || command.provider
|
|
173
|
-
!c.status?.sessionSupervision?.sessions?.some(s => s?.provider ===
|
|
174
|
+
if (!allowedActions.has(command.action) || !['codex', 'claude'].includes(command.provider) || typeof command.session !== 'string' ||
|
|
175
|
+
!c.status?.sessionSupervision?.sessions?.some(s => s?.provider === command.provider && s.session === command.session)) throw error('Select an available remote conversation.', 'remote_host_invalid');
|
|
174
176
|
const id = randomUUID();
|
|
175
177
|
const { hostId, ...payload } = command;
|
|
176
178
|
return new Promise((resolve, reject) => {
|
|
@@ -181,7 +183,7 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
181
183
|
});
|
|
182
184
|
});
|
|
183
185
|
}
|
|
184
|
-
function project(status) {
|
|
186
|
+
function project(status, { providerLinks = status.providerLinks } = {}) {
|
|
185
187
|
if (!usable(status) || scopeOf(status) !== scope) return status;
|
|
186
188
|
const hosts = [...connections.values()];
|
|
187
189
|
const sessions = [], runs = [];
|
|
@@ -189,14 +191,16 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
189
191
|
const ready = c.ready && now() - c.receivedAt <= 45_000;
|
|
190
192
|
const unavailableReason = c.reason || 'Remote status is stale.';
|
|
191
193
|
for (const row of c.status?.sessionSupervision?.sessions || []) {
|
|
192
|
-
if (!row || row.provider
|
|
194
|
+
if (!row || !['codex', 'claude'].includes(row.provider) || typeof row.session !== 'string' ||
|
|
195
|
+
providerLinks?.[row.provider]?.connected === false) continue;
|
|
193
196
|
const item = stamp(row, c);
|
|
194
197
|
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.' };
|
|
195
198
|
// Codex's host-specific deep link contract is unverified. Preserve exact
|
|
196
199
|
// navigation context instead of silently opening a cloned local UUID.
|
|
197
|
-
item.originalSessionTarget = { kind: 'manual', url: null, label: 'Find in remote
|
|
198
|
-
reason: `Select ${c.label} in Codex, then find conversation ${row.session}.` };
|
|
200
|
+
item.originalSessionTarget = { kind: 'manual', url: null, label: 'Find in remote provider', verifiedOnPlatform: false,
|
|
201
|
+
reason: `Select ${c.label} in ${row.provider === 'claude' ? 'Claude Code' : 'Codex'}, then find conversation ${row.session}.` };
|
|
199
202
|
if (!ready) {
|
|
203
|
+
item.followup = null;
|
|
200
204
|
item.capabilities = Object.fromEntries(Object.keys(item.capabilities || {}).map(key => [key, false]));
|
|
201
205
|
item.autoContinueAvailability = { canEnable: false, reasonCode: 'remote_host_unavailable', reason: unavailableReason };
|
|
202
206
|
item.autoContinueStatus = 'unavailable'; item.autoContinueReason = unavailableReason;
|
|
@@ -206,13 +210,13 @@ export function createRemoteHostManager({ root, spawnImpl = spawn, onChange = ()
|
|
|
206
210
|
}
|
|
207
211
|
sessions.push(item);
|
|
208
212
|
}
|
|
209
|
-
if (ready) for (const run of c.status?.deliveryHooks?.runs || []) if (run?.provider
|
|
213
|
+
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}.` } });
|
|
210
214
|
}
|
|
211
215
|
return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, state: c.state, reason: c.reason })) },
|
|
212
216
|
sessionSupervision: { ...status.sessionSupervision, sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
|
|
213
217
|
deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
|
|
214
218
|
}
|
|
215
219
|
return { update, save, forward, project,
|
|
216
|
-
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => s?.provider
|
|
220
|
+
hasSession: session => [...connections.values()].some(c => c.status?.sessionSupervision?.sessions?.some(s => ['codex', 'claude'].includes(s?.provider) && s.session === session)),
|
|
217
221
|
async close() { closed = true; for (const c of connections.values()) disconnect(c); connections.clear(); await writes.catch(() => {}); } };
|
|
218
222
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readHosts } from './remote-hosts.js';
|
|
5
|
+
|
|
6
|
+
// This read-only command never boots a remote desktop host or supervisor.
|
|
7
|
+
export async function readRemoteSessionLists({ root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy'),
|
|
8
|
+
providers, includeArchived = false, signal, spawnImpl = spawn, timeoutMs = 30_000 } = {}) {
|
|
9
|
+
const hosts = await readHosts(root);
|
|
10
|
+
return Promise.all(hosts.map(async host => {
|
|
11
|
+
try {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const provider = providers.length === 2 ? 'all' : providers[0];
|
|
14
|
+
if (!['all', 'codex', 'claude'].includes(provider)) throw new Error('No connected provider.');
|
|
15
|
+
const command = `exec sh -lc 'exec simy sessions --json --local-only --provider ${provider}${includeArchived ? ' --include-archived' : ''}'`;
|
|
16
|
+
const catalog = await new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawnImpl('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
|
|
18
|
+
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=10', '-o', 'ServerAliveCountMax=2',
|
|
19
|
+
'--', host.alias, command], { stdio: ['ignore', 'pipe', 'pipe'], shell: false });
|
|
20
|
+
let chunks = [], size = 0, done = false;
|
|
21
|
+
const finish = (error, value) => {
|
|
22
|
+
if (done) return; done = true;
|
|
23
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
24
|
+
if (error) { child.kill('SIGTERM'); reject(error); } else resolve(value);
|
|
25
|
+
};
|
|
26
|
+
const abort = () => finish(new Error('Remote listing cancelled.'));
|
|
27
|
+
const timer = setTimeout(() => finish(new Error('Remote listing timed out.')), timeoutMs);
|
|
28
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
29
|
+
if (signal?.aborted) abort();
|
|
30
|
+
child.stderr.resume();
|
|
31
|
+
child.on('error', () => finish(new Error('Could not start SSH.')));
|
|
32
|
+
child.stdout.on('data', chunk => {
|
|
33
|
+
if (done) return;
|
|
34
|
+
size += chunk.length;
|
|
35
|
+
if (size > 16 * 1024 * 1024) return finish(new Error('Remote catalog exceeds the supported response size.'));
|
|
36
|
+
chunks.push(chunk);
|
|
37
|
+
});
|
|
38
|
+
child.on('close', code => {
|
|
39
|
+
if (done) return;
|
|
40
|
+
if (code !== 0) return finish(new Error('Remote listing failed. Check SSH and update SIMY on this host.'));
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); chunks = [];
|
|
43
|
+
if (!Array.isArray(value.sessions) || value.sessions.some(row => !row ||
|
|
44
|
+
!providers.includes(row.provider) || typeof row.session !== 'string' || !row.session ||
|
|
45
|
+
typeof row.cwd !== 'string' || row.hostId && row.hostId !== 'local')) throw new Error();
|
|
46
|
+
finish(null, value);
|
|
47
|
+
} catch { finish(new Error('Remote SIMY returned an invalid conversation catalog.')); }
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
return { host, catalog };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
signal?.throwIfAborted();
|
|
53
|
+
return { host, error: error.message };
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
@@ -1,57 +1,112 @@
|
|
|
1
|
+
import { readRemoteSessionLists } from './remote-session-list.js';
|
|
1
2
|
import { hostname } from 'node:os';
|
|
2
3
|
import { stripVTControlCharacters } from 'node:util';
|
|
3
4
|
import { readProviderLinks } from '../provider-links.js';
|
|
4
5
|
import { openCodexAppServer, listCodexSessions } from './codex-app-server.js';
|
|
6
|
+
import { listClaudeSessions } from './claude-sessions.js';
|
|
5
7
|
import { readCodexTitles } from './session-titles.js';
|
|
6
8
|
import { sessionWorkState } from './work-state.js';
|
|
7
9
|
|
|
8
10
|
export const terminalText = value => stripVTControlCharacters(String(value ?? '')).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
|
|
9
11
|
export function filterSessionList(sessions, query = '') {
|
|
10
12
|
const needle = query.trim().toLocaleLowerCase();
|
|
11
|
-
return sessions.filter(row => !needle || [row.title, row.session, row.cwd].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
|
|
13
|
+
return sessions.filter(row => !needle || [row.title, row.session, row.cwd, row.provider, row.hostLabel].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
// List directly from the installed local provider. No agent, supervisor,
|
|
15
17
|
// enrollment, archive reconciliation or provider turn is started by this view.
|
|
16
|
-
export async function readLocalSessionList({ root, signal, includeArchived = false,
|
|
17
|
-
openClient = openCodexAppServer,
|
|
18
|
+
export async function readLocalSessionList({ root, signal, includeArchived = false, provider = 'all',
|
|
19
|
+
openClient = openCodexAppServer, listClaude = listClaudeSessions,
|
|
20
|
+
readLinks = readProviderLinks, readTitles = readCodexTitles } = {}) {
|
|
18
21
|
const links = await readLinks(root);
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
|
|
23
|
+
if (!providers.length) throw Object.assign(new Error('Codex and Claude Code are disconnected from SIMY. Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
|
|
24
|
+
const results = await Promise.allSettled(providers.map(async provider => {
|
|
25
|
+
signal?.throwIfAborted();
|
|
26
|
+
if (provider === 'claude') return (await listClaude({ all: true, signal })).filter(row => includeArchived || !row.archived);
|
|
27
|
+
const client = await openClient({ signal, cancelOnAbort: true });
|
|
28
|
+
let sessions;
|
|
29
|
+
try {
|
|
30
|
+
sessions = await listCodexSessions({ client, all: true, signal, inspectState: true });
|
|
31
|
+
if (includeArchived) sessions.push(...(await listCodexSessions({ client, all: true, signal, archived: true })).map(row => ({ ...row, archived: true })));
|
|
32
|
+
} finally { await client.close(); }
|
|
33
|
+
const titles = await readTitles();
|
|
34
|
+
return sessions.map(row => ({ ...row, title: row.title || titles.get(row.session) }));
|
|
35
|
+
}));
|
|
27
36
|
signal?.throwIfAborted();
|
|
28
37
|
const currentLinks = await readLinks(root);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
for (const provider of providers) {
|
|
39
|
+
if (!currentLinks[provider]?.connected || currentLinks[provider].epoch !== links[provider].epoch)
|
|
40
|
+
throw Object.assign(new Error('Provider connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
|
|
41
|
+
}
|
|
42
|
+
if (results.every(result => result.status === 'rejected')) throw results[0].reason;
|
|
43
|
+
const warnings = results.flatMap((result, index) => result.status === 'rejected'
|
|
44
|
+
? [`${providers[index]}: ${terminalText(result.reason?.message || 'Conversation discovery unavailable')}`] : []);
|
|
45
|
+
const sessions = results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
46
|
+
return { host: hostname(), observedAt: new Date().toISOString(), complete: warnings.length === 0, warnings,
|
|
47
|
+
sessions: [...new Map(sessions.map(row => [`${row.provider}:${row.session}`, row])).values()]
|
|
48
|
+
.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.provider.localeCompare(b.provider) || a.session.localeCompare(b.session))
|
|
49
|
+
.map(row => ({
|
|
50
|
+
provider: row.provider, session: row.session, title: terminalText(row.title || row.session),
|
|
51
|
+
cwd: terminalText(row.cwd), archived: row.archived === true,
|
|
52
|
+
workState: sessionWorkState(row),
|
|
53
|
+
})) };
|
|
35
54
|
}
|
|
55
|
+
// Aggregate only explicitly registered hosts, preserving the host/provider/ID tuple.
|
|
56
|
+
export async function readSessionList({ root, signal, provider = 'all', localOnly = false, includeArchived = false,
|
|
57
|
+
readLinks = readProviderLinks, loadLocal = readLocalSessionList, loadRemote = readRemoteSessionLists } = {}) {
|
|
58
|
+
const links = await readLinks(root);
|
|
59
|
+
const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
|
|
60
|
+
if (!providers.length) throw Object.assign(new Error('Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
|
|
61
|
+
const [local, remote] = await Promise.allSettled([
|
|
62
|
+
loadLocal({ root, signal, provider, includeArchived, readLinks }),
|
|
63
|
+
localOnly ? [] : loadRemote({ root, signal, providers, includeArchived }),
|
|
64
|
+
]);
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
const current = await readLinks(root);
|
|
67
|
+
for (const name of providers) if (!current[name]?.connected || current[name].epoch !== links[name].epoch)
|
|
68
|
+
throw Object.assign(new Error('Provider connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
|
|
69
|
+
const warnings = [], sessions = [], hosts = [];
|
|
70
|
+
if (local.status === 'fulfilled') {
|
|
71
|
+
sessions.push(...local.value.sessions.map(row => ({ ...row, hostId: 'local', hostLabel: local.value.host })));
|
|
72
|
+
warnings.push(...(local.value.warnings || []));
|
|
73
|
+
} else warnings.push(`local: ${terminalText(local.reason?.message)}`);
|
|
74
|
+
if (remote.status === 'rejected') warnings.push(`remote: ${terminalText(remote.reason?.message)}`);
|
|
75
|
+
else for (const result of remote.value) {
|
|
76
|
+
hosts.push({ id: result.host.id, label: result.host.label, state: result.error ? 'unavailable' : 'ready' });
|
|
77
|
+
if (result.error) { warnings.push(`${result.host.label}: ${terminalText(result.error)}`); continue; }
|
|
78
|
+
warnings.push(...(result.catalog.warnings || []).map(warning => `${result.host.label}: ${terminalText(warning)}`));
|
|
79
|
+
if (result.catalog.complete === false && !result.catalog.warnings?.length) warnings.push(`${result.host.label}: incomplete catalog`);
|
|
80
|
+
sessions.push(...result.catalog.sessions.map(row => ({
|
|
81
|
+
provider: row.provider, session: row.session, title: terminalText(row.title || row.session), cwd: terminalText(row.cwd),
|
|
82
|
+
archived: row.archived === true, hostId: result.host.id, hostLabel: result.host.label,
|
|
83
|
+
workState: { label: terminalText(row.workState?.label || '状態未確認') },
|
|
84
|
+
})));
|
|
85
|
+
}
|
|
86
|
+
return { host: hostname(), observedAt: new Date().toISOString(), complete: warnings.length === 0, warnings, hosts,
|
|
87
|
+
sessions: [...new Map(sessions.map(row => [`${row.hostId}:${row.provider}:${row.session}`, row])).values()] };
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
export function formatSessionList(catalog, query = '') {
|
|
37
91
|
const rows = filterSessionList(catalog.sessions, query);
|
|
38
|
-
return [`SIMY · Codex会話一覧 · ${terminalText(catalog.host)}`,
|
|
92
|
+
return [`SIMY · Codex / Claude Code 会話一覧 · ${terminalText(catalog.host)}`,
|
|
39
93
|
`表示 ${rows.length} / ${catalog.sessions.length} 件${query ? ` · 検索: ${terminalText(query)}` : ''}`,
|
|
94
|
+
...(catalog.warnings || []).map(warning => `取得未完了: ${terminalText(warning)}`),
|
|
40
95
|
...rows.flatMap((row, index) => [
|
|
41
96
|
`${index + 1}. ${terminalText(row.title)}`,
|
|
42
|
-
` ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
|
|
97
|
+
` ${terminalText(row.hostLabel || catalog.host)} · ${row.provider === 'claude' ? 'Claude Code' : 'Codex'} · ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
|
|
43
98
|
` ${terminalText(row.cwd)}`,
|
|
44
99
|
]), ...(rows.length ? [] : ['一致する会話はありません。'])].join('\n') + '\n';
|
|
45
100
|
}
|
|
46
101
|
|
|
47
102
|
export async function runSessionListCommand(argv, { output = process.stdout, errors = process.stderr, input = process.stdin,
|
|
48
|
-
load =
|
|
49
|
-
let query = '', json = false, interactive = false, includeArchived = false;
|
|
103
|
+
load = readSessionList, renderInteractive } = {}) {
|
|
104
|
+
let query = '', json = false, interactive = false, includeArchived = false, localOnly = false, provider = 'all';
|
|
50
105
|
try {
|
|
51
106
|
for (let i = 0; i < argv.length; i++) {
|
|
52
107
|
const arg = argv[i];
|
|
53
108
|
if (arg === '--help' || arg === '-h') {
|
|
54
|
-
output.write('simy sessions [--query TEXT] [--json | --interactive] [--include-archived]\n\nShow
|
|
109
|
+
output.write('simy sessions [--query TEXT] [--json | --interactive] [--include-archived] [--local-only] [--provider all|codex|claude]\n\nShow available Codex and Claude Code conversations on this host and registered SSH hosts. No task is started or resumed.\n'); return 0;
|
|
55
110
|
}
|
|
56
111
|
if (arg === '--query') {
|
|
57
112
|
if (typeof argv[i + 1] !== 'string' || argv[i + 1].startsWith('--')) throw new Error('--query requires search text.');
|
|
@@ -59,6 +114,11 @@ export async function runSessionListCommand(argv, { output = process.stdout, err
|
|
|
59
114
|
} else if (arg === '--json') json = true;
|
|
60
115
|
else if (arg === '--interactive') interactive = true;
|
|
61
116
|
else if (arg === '--include-archived') includeArchived = true;
|
|
117
|
+
else if (arg === '--local-only') localOnly = true;
|
|
118
|
+
else if (arg === '--provider') {
|
|
119
|
+
provider = argv[++i];
|
|
120
|
+
if (!['all', 'codex', 'claude'].includes(provider)) throw new Error('--provider requires all, codex or claude.');
|
|
121
|
+
}
|
|
62
122
|
else throw new Error(`Unknown sessions option: ${terminalText(arg)}`);
|
|
63
123
|
}
|
|
64
124
|
if (json && interactive) throw new Error('Choose --json or --interactive.');
|
|
@@ -67,7 +127,7 @@ export async function runSessionListCommand(argv, { output = process.stdout, err
|
|
|
67
127
|
const cancel = () => controller.abort();
|
|
68
128
|
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
69
129
|
try {
|
|
70
|
-
const refresh = () => load({ includeArchived, signal: controller.signal });
|
|
130
|
+
const refresh = () => load({ includeArchived, localOnly, provider, signal: controller.signal });
|
|
71
131
|
const catalog = await refresh();
|
|
72
132
|
if (interactive) {
|
|
73
133
|
const start = renderInteractive || (await import('../console/sessions.js')).runSessionListConsole;
|