@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.
@@ -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 { RECENT_SESSION_LIMIT, recentSessionWindow, retainedSession } from './discovery-window.js';
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 && !run.managed && !canUpgradeDelivery(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, inspectState: true, maxSessions: RECENT_SESSION_LIMIT, codexHome,
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, archived: true, maxSessions: RECENT_SESSION_LIMIT }); }
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, limit: RECENT_SESSION_LIMIT,
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
- // Select recent history before live probes, keeping explicit supervision
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 items = [...recentSessionWindow(catalog.filter(item => item.archived !== true && !item.localHidden), {
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 === 'codex' && typeof result.session === 'string') result.hostId = host.id;
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, 'Remote SIMY could not start. Check its installation, login and existing desktop connection.'); changed(); return;
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 ? { message: 'Remote operation could not finish. Check the original conversation and refresh before retrying.' } : {}) });
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 !== 'codex' || typeof command.session !== 'string' ||
173
- !c.status?.sessionSupervision?.sessions?.some(s => s?.provider === 'codex' && s.session === command.session)) throw error('Select an available remote Codex conversation.', 'remote_host_invalid');
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 !== 'codex' || typeof row.session !== 'string') continue;
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 Codex', verifiedOnPlatform: false,
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 === 'codex') runs.push({ ...stamp(run, c), originalSessionTarget: { kind: 'manual', url: null, verifiedOnPlatform: false, reason: `Select ${c.label} in Codex, then find conversation ${run.session}.` } });
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 === 'codex' && s.session === session)),
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, readLinks = readProviderLinks, readTitles = readCodexTitles } = {}) {
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
- if (!links.codex.connected) throw Object.assign(new Error('Codex is disconnected from SIMY. Reconnect it before listing conversations.'), { code: 'provider_link_disconnected' });
20
- const client = await openClient({ signal, cancelOnAbort: true });
21
- let sessions;
22
- try {
23
- sessions = await listCodexSessions({ client, inspectState: true });
24
- if (includeArchived) sessions.push(...(await listCodexSessions({ client, archived: true })).map(row => ({ ...row, archived: true })));
25
- } finally { await client.close(); }
26
- const titles = await readTitles();
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
- if (!currentLinks.codex.connected || currentLinks.codex.epoch !== links.codex.epoch) throw Object.assign(new Error('Codex connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
30
- return { host: hostname(), observedAt: new Date().toISOString(), sessions: [...new Map(sessions.map(row => [row.session, row])).values()].map(row => ({
31
- provider: 'codex', session: row.session, title: terminalText(row.title || titles.get(row.session) || row.session),
32
- cwd: terminalText(row.cwd), archived: row.archived === true,
33
- workState: sessionWorkState(row),
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 = readLocalSessionList, renderInteractive } = {}) {
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 local Codex conversations. No task is started or resumed.\n'); return 0;
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;