@awak-app/simy-cli 0.7.19 → 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.
@@ -7,7 +7,7 @@ export function SessionListConsole({ catalog: initial, query: initialQuery = '',
7
7
  const { exit } = useApp(), { stdout } = useStdout();
8
8
  const [catalog, setCatalog] = useState(initial), [query, setQuery] = useState(initialQuery);
9
9
  const [searching, setSearching] = useState(false), [index, setIndex] = useState(0);
10
- const [busy, setBusy] = useState(false), [notice, setNotice] = useState('');
10
+ const [busy, setBusy] = useState(false), [notice, setNotice] = useState((initial.warnings || []).join('; '));
11
11
  const [height, setHeight] = useState(stdout.rows || 24);
12
12
  useEffect(() => { const resize = () => setHeight(stdout.rows || 24); stdout.on('resize', resize); return () => stdout.off('resize', resize); }, [stdout]);
13
13
  const rows = filterSessionList(catalog.sessions, query), selected = Math.max(0, Math.min(index, Math.max(0, rows.length - 1)));
@@ -26,7 +26,7 @@ export function SessionListConsole({ catalog: initial, query: initialQuery = '',
26
26
  if (key.downArrow) setIndex(Math.max(0, Math.min(rows.length - 1, selected + 1)));
27
27
  if (text === 'r' && !busy) {
28
28
  setBusy(true); setNotice('');
29
- void refresh().then(next => { setCatalog(next); setIndex(0); }, error => {
29
+ void refresh().then(next => { setCatalog(next); setNotice((next.warnings || []).join('; ')); setIndex(0); }, error => {
30
30
  if (['provider_link_disconnected', 'provider_link_changed'].includes(error.code)) {
31
31
  setCatalog(current => ({ ...current, sessions: [] })); setIndex(0);
32
32
  }
@@ -36,15 +36,15 @@ export function SessionListConsole({ catalog: initial, query: initialQuery = '',
36
36
  });
37
37
  const current = rows[selected];
38
38
  return jsxs(Box, { flexDirection: 'column', children: [
39
- jsx(Text, { bold: true, color: 'cyan', children: `SIMY · Codex会話一覧 · ${terminalText(catalog.host)}` }),
40
- jsx(Text, { children: `表示 ${rows.length} / ${catalog.sessions.length} 件 · この端末の実会話` }),
39
+ jsx(Text, { bold: true, color: 'cyan', children: `SIMY · Codex / Claude Code 会話一覧 · ${terminalText(catalog.host)}` }),
40
+ jsx(Text, { children: `表示 ${rows.length} / ${catalog.sessions.length} 件 · 接続先の実会話` }),
41
41
  jsx(Text, { color: searching ? 'yellow' : undefined, children: `検索: ${query}${searching ? '▏' : ''}` }),
42
42
  ...rows.slice(offset, offset + pageSize).map((row, i) => jsx(Text, {
43
43
  color: offset + i === selected ? 'green' : undefined, wrap: 'truncate-end',
44
44
  children: `${offset + i === selected ? '›' : ' '} ${terminalText(row.title)}`,
45
- }, row.session)),
45
+ }, `${row.hostId || 'local'}:${row.provider}:${row.session}`)),
46
46
  ...(!rows.length ? [jsx(Text, { children: '一致する会話はありません。' }, 'empty')] : []),
47
- jsx(Text, { dimColor: true, children: current ? `${terminalText(current.workState?.label)} · ${current.session}` : '' }),
47
+ jsx(Text, { dimColor: true, children: current ? `${terminalText(current.hostLabel || catalog.host)} · ${current.provider === 'claude' ? 'Claude Code' : 'Codex'} · ${terminalText(current.workState?.label)} · ${current.session}` : '' }),
48
48
  jsx(Text, { dimColor: true, wrap: 'truncate-end', children: terminalText(current?.cwd) }),
49
49
  jsx(Text, { color: notice ? 'red' : undefined, children: notice || (busy ? '更新中…' : '↑↓ 選択 / 検索 Esc 解除 r 更新 q 終了') }),
50
50
  ] });
@@ -144,19 +144,20 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
144
144
  output(JSON.stringify(result, null, 2)); return result.status === 'not_requested' ? 2 : 0;
145
145
  }
146
146
  if (['supervisor-enable', 'supervisor-disable', 'supervisor-watch', 'supervisor-status', 'supervisor-watchdog-install'].includes(args[0])) {
147
- const options = parseDeliveryOptions(args.slice(1), new Set(['root']));
147
+ const options = parseDeliveryOptions(args.slice(1), new Set(['root', ...(args[0] === 'supervisor-enable' ? ['provider', 'session'] : [])]));
148
148
  const supervisorRoot = options.root || dirname(root);
149
149
  if (!isAbsolute(supervisorRoot)) throw new Error('Supervisor root must be absolute.');
150
150
  if (args[0] === 'supervisor-watch') {
151
151
  const controller = new AbortController(), interrupt = () => controller.abort('user_interrupt'), terminate = () => controller.abort('service_termination');
152
- process.once('SIGINT', interrupt); process.once('SIGTERM', terminate);
152
+ process.once('SIGINT', interrupt); process.once('SIGTERM', terminate); process.once('SIGHUP', terminate);
153
153
  try { await watchSupervisor({ root: supervisorRoot, signal: controller.signal }); }
154
- finally { process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', terminate); }
154
+ finally { process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', terminate); process.removeListener('SIGHUP', terminate); }
155
155
  return 0;
156
156
  }
157
157
  const result = args[0] === 'supervisor-status' ? await supervisorWatchdogStatus({ root: supervisorRoot })
158
158
  : args[0] === 'supervisor-watchdog-install' ? await installSupervisorService({ root: supervisorRoot })
159
- : await setSupervisorWatchdog({ root: supervisorRoot, enabled: args[0] === 'supervisor-enable', webOrigin });
159
+ : await setSupervisorWatchdog({ root: supervisorRoot, enabled: args[0] === 'supervisor-enable', webOrigin,
160
+ ...((options.provider || options.session) ? { target: { provider: options.provider, session: options.session } } : {}) });
160
161
  output(JSON.stringify(result, null, 2)); return 0;
161
162
  }
162
163
  if (args[0] === 'policy-adopt') {
@@ -187,11 +188,12 @@ export async function runDeliveryCommand(argv, { root = rootDirectory(), cwd = p
187
188
  return 0;
188
189
  }
189
190
  if (action === 'supervise') {
190
- if (args.length > 1 || args.length === 1 && args[0] !== '--once') throw new Error('Use simy delivery supervise [--once].');
191
+ const once = args.includes('--once');
192
+ const options = parseDeliveryOptions(args.filter(arg => arg !== '--once'), new Set(['activation-epoch']));
191
193
  const controller = new AbortController();
192
194
  const stop = () => controller.abort();
193
195
  process.once('SIGINT', stop); process.once('SIGTERM', stop);
194
- try { await superviseLocalDelivery({ root, webOrigin, once: args[0] === '--once', signal: controller.signal }); }
196
+ try { await superviseLocalDelivery({ root, webOrigin, once, activationEpoch: options['activation-epoch'], signal: controller.signal }); }
195
197
  finally { process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); }
196
198
  return 0;
197
199
  }
@@ -0,0 +1,82 @@
1
+ import { automaticContinuationAllowed, hasPendingUserStop, queuePurposeDiagnosis,
2
+ updateRecord, advanceDeliveryControlRevision } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { requireProviderLink } from '../provider-links.js';
4
+ import { openCodexAppServer, readCodexObservation } from '../session-supervision/codex-app-server.js';
5
+
6
+ function eligible(record) {
7
+ const run = record?.run, managed = run?.managed;
8
+ if (record?.provider !== 'codex' || record.autoContinue !== true || !automaticContinuationAllowed(record) ||
9
+ !managed?.purpose?.enabled || managed.resumeChannel !== 'codex_app_server' ||
10
+ !['active', 'recovery_required', 'blocked'].includes(run.state) || run.stopReason?.actor === 'user' ||
11
+ managed.resumeLease || managed.liveWorkspaceLease || managed.outcomeAudit?.lease ||
12
+ managed.nativeWait || managed.purpose.nativeBoundary || managed.providerWarning ||
13
+ managed.userQuestion || managed.understandingQuestion || managed.dependencyWait ||
14
+ managed.authenticationWait || managed.phase === 'understanding' || managed.usageBlocked) return false;
15
+ // A restart is never approval for an effect. Only a controller-generated
16
+ // local context-selection wait can yield to independent read diagnosis.
17
+ const pending = managed.pendingApproval;
18
+ if (pending && (pending.denied || pending.kind !== 'scope_change' || managed.decision?.assessment?.externalEffect !== 'local' ||
19
+ managed.decision.assessment.reversibility !== 'reversible' || managed.decision.assessment.inObjective !== true ||
20
+ managed.decision.assessment.userChoice !== false)) return false;
21
+ return true;
22
+ }
23
+
24
+ // Called once by the owning supervisor after a durable activation is loaded.
25
+ // It preserves the same run, purpose, budgets and effect history, and queues a
26
+ // read. An interrupted write is reconciled before any proposal to repeat it.
27
+ export async function reconcileLifecycleRecovery({ root, records, eventId, authorize = async () => {}, sessionEndedAt }) {
28
+ for (const record of records) {
29
+ if (!eligible(record) || hasPendingUserStop(root, record)) continue;
30
+ try {
31
+ const link = await requireProviderLink({ root, provider: record.provider });
32
+ await authorize(record);
33
+ await updateRecord(root, record.provider, record.session, async current => {
34
+ if (!eligible(current) || hasPendingUserStop(root, current) || current.run.id !== record.run.id ||
35
+ current.run.revision !== record.run.revision || current.run.controlRevision !== record.run.controlRevision ||
36
+ current.autoContinueEpoch !== record.autoContinueEpoch || current.run.managed.lifecycleRecovery?.eventId === eventId ||
37
+ sessionEndedAt !== undefined && !endedSession(current, sessionEndedAt)) return current;
38
+ await requireProviderLink({ root, provider: record.provider, expectedEpoch: link.epoch });
39
+ await authorize(current);
40
+ const run = current.run, managed = run.managed, previousState = run.state;
41
+ const pending = managed.pendingApproval ? structuredClone(managed.pendingApproval) : null;
42
+ const queued = queuePurposeDiagnosis(run,
43
+ 'The provider session ended or the local execution host returned after an interruption. Read the saved task and actual current artifacts first. Keep the original goal, corrections, permission ceiling, budgets and explicit stops. Reconcile unfinished or uncertain effects before proposing more work; do not replay a prior write or treat recovery as approval. Inspect the current execution context and choose useful authorized work.',
44
+ `lifecycle:${eventId}`, Date.now(), 'interrupted_owner');
45
+ if (queued) {
46
+ managed.lifecycleRecovery = { eventId, at: Date.now(), previousState, mode: 'read_before_continue', retainedApproval: pending };
47
+ advanceDeliveryControlRevision(run);
48
+ run.updatedAt = Date.now();
49
+ }
50
+ return current;
51
+ });
52
+ } catch (error) {
53
+ if (['provider_link_disconnected', 'provider_link_changed'].includes(error.code) ||
54
+ /^Delivery state (?:is busy; retry the command\.|lock owner is unavailable; inspect the lock before retrying\.)$/.test(error.message)) continue;
55
+ throw error;
56
+ }
57
+ }
58
+ }
59
+
60
+ function endedSession(record, at = record.hookEndedAt) {
61
+ return record.lastEvent === 'SessionEnd' && Number.isFinite(at) && at > 0 && record.hookEndedAt === at &&
62
+ record.run?.state === 'recovery_required' && record.run.recoveryCause === 'session_end';
63
+ }
64
+
65
+ async function observeEndedSession(record) {
66
+ const client = await openCodexAppServer();
67
+ try { return await readCodexObservation(client, record.session); }
68
+ finally { await client.close(); }
69
+ }
70
+
71
+ // A provider can end while the supervisor remains alive. Reconcile each such
72
+ // event once, after confirming the original conversation has no active turn.
73
+ export async function reconcileEndedSessions({ root, records, eventId, authorize, observe = observeEndedSession }) {
74
+ for (const record of records) {
75
+ if (!endedSession(record) || !eligible(record) || hasPendingUserStop(root, record)) continue;
76
+ let observation;
77
+ try { observation = await observe(record); } catch { continue; }
78
+ if (observation?.isRunning !== false) continue;
79
+ await reconcileLifecycleRecovery({ root, records: [record], authorize,
80
+ eventId: `${eventId}:session-end:${record.session}:${record.hookEndedAt}`, sessionEndedAt: record.hookEndedAt });
81
+ }
82
+ }
@@ -1,4 +1,8 @@
1
- import { dirname } from 'node:path';
1
+ import { dirname, join } from 'node:path';
2
+ import { listRecords } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+ import { readSupervisorState } from './supervisor-owner.js';
4
+ import { supervisorTargetMatches } from './supervisor-activation.js';
5
+ import { reconcileLifecycleRecovery, reconcileEndedSessions } from './lifecycle-recovery.js';
2
6
  import { setTimeout as delay } from 'node:timers/promises';
3
7
  import { assertNoDesktopHostForOrigin } from '../desktop-process-lock.js';
4
8
  import { createManagedSupervisor } from './supervisor.js';
@@ -6,6 +10,8 @@ import { acquireSupervisorOwner } from './supervisor-owner.js';
6
10
  import { pendingSupervisorHandover, acknowledgeSupervisorDrain } from './supervisor-handover.js';
7
11
 
8
12
  export async function superviseLocalDelivery({ root, webOrigin, once = false, signal,
13
+ activationEpoch, readActivation = readSupervisorState, reconcileStartup = reconcileLifecycleRecovery,
14
+ reconcileSessionEnds = reconcileEndedSessions,
9
15
  createSupervisor = createManagedSupervisor, checkOwner = assertNoDesktopHostForOrigin, wait = delay,
10
16
  acquireOwner = acquireSupervisorOwner, pendingHandover = pendingSupervisorHandover,
11
17
  acknowledgeDrain = acknowledgeSupervisorDrain } = {}) {
@@ -16,13 +22,28 @@ export async function superviseLocalDelivery({ root, webOrigin, once = false, si
16
22
  const owner = await acquireOwner(dirname(root), { handoverVersion: 1 });
17
23
  try {
18
24
  await check();
25
+ const activationFile = join(dirname(root), 'supervisor-runtime/control.json');
26
+ const activation = activationEpoch ? await readActivation(activationFile) : null;
27
+ const activationCurrent = value => value?.version === 1 && value.enabled === true && value.epoch === activationEpoch;
28
+ if (activationEpoch && !activationCurrent(activation)) return;
29
+ const authorizeDispatch = async record => {
30
+ const current = await readActivation(activationFile);
31
+ if (!activationCurrent(current) || !supervisorTargetMatches(current, record)) throw new Error('Supervisor activation or target changed.');
32
+ return activationEpoch;
33
+ };
34
+ const readRecords = async () => (await listRecords(root)).filter(record => supervisorTargetMatches(activation, record));
35
+ if (activationEpoch) await reconcileStartup({ root, records: await readRecords(),
36
+ eventId: `${activationEpoch}:${owner.nonce}`, authorize: authorizeDispatch });
19
37
  const supervisor = createSupervisor({ root, webOrigin,
20
- shouldYield: async () => Boolean(await pendingHandover(dirname(root), owner.nonce)) });
38
+ ...(activationEpoch ? { readRecords, authorizeDispatch } : {}),
39
+ shouldYield: async () => Boolean(activationEpoch && !activationCurrent(await readActivation(activationFile)) ||
40
+ await pendingHandover(dirname(root), owner.nonce)) });
21
41
  const stop = () => { void supervisor.close(); };
22
42
  signal?.addEventListener('abort', stop, { once: true });
23
43
  try {
24
44
  do {
25
45
  if (signal?.aborted) break;
46
+ if (activationEpoch && !activationCurrent(await readActivation(activationFile))) break;
26
47
  const handover = await pendingHandover(dirname(root), owner.nonce);
27
48
  if (handover) {
28
49
  await supervisor.drain();
@@ -30,6 +51,8 @@ export async function superviseLocalDelivery({ root, webOrigin, once = false, si
30
51
  break;
31
52
  }
32
53
  await check();
54
+ if (activationEpoch) await reconcileSessionEnds({ root, records: await readRecords(),
55
+ eventId: activationEpoch, authorize: authorizeDispatch });
33
56
  await supervisor.tick();
34
57
  if (once) { await supervisor.idle?.(); break; }
35
58
  try { await wait(3_000, undefined, { signal }); }
@@ -0,0 +1,25 @@
1
+ import { join } from 'node:path';
2
+ import { readRecord, automaticContinuationAllowed } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
3
+
4
+ // Activation is durable OS recovery consent, separate from task permissions.
5
+ // A scoped activation binds the existing run/consent, never a future task that
6
+ // happens to reuse the conversation id.
7
+ export async function supervisorTarget(root, target) {
8
+ if (!target || !['codex', 'claude'].includes(target.provider) || typeof target.session !== 'string' || !target.session) {
9
+ throw new Error('Select an exact provider and session for supervisor recovery.');
10
+ }
11
+ const record = await readRecord(join(root, 'delivery-hooks'), target.provider, target.session);
12
+ if (!record?.run?.managed || record.autoContinue !== true || !record.autoContinueEpoch ||
13
+ !automaticContinuationAllowed(record) || ['stopped', 'paused', 'completed', 'reported_done', 'budget_exhausted'].includes(record.run.state)) {
14
+ throw new Error('Supervisor activation requires an existing opted-in task; explicit task stops remain stopped.');
15
+ }
16
+ return { provider: record.provider, session: record.session, runId: record.run.id, autoContinueEpoch: record.autoContinueEpoch };
17
+ }
18
+
19
+ export function supervisorTargetMatches(control, record) {
20
+ if (!Object.hasOwn(control || {}, 'targets')) return true;
21
+ return Array.isArray(control.targets) && control.targets.some(target =>
22
+ target.provider === record.provider && target.session === record.session &&
23
+ typeof target.runId === 'string' && target.runId === record.run?.id &&
24
+ typeof target.autoContinueEpoch === 'string' && target.autoContinueEpoch === record.autoContinueEpoch);
25
+ }
@@ -24,12 +24,12 @@ export function supervisorServiceDefinition({ root, entry, node = process.execPa
24
24
  if (platform === 'linux') {
25
25
  const name = 'simy-delivery-supervisor.service';
26
26
  return { name, file: join(home, '.config/systemd/user', name),
27
- content: `# ${marker}\n[Unit]\nDescription=SIMY Delivery supervisor recovery\n\n[Service]\nType=simple\nExecStart=${[node, ...args].map(systemdArgument).join(' ')}\nRestart=on-failure\nRestartSec=15\nKillMode=process\n\n[Install]\nWantedBy=default.target\n` };
27
+ content: `# ${marker}\n[Unit]\nDescription=SIMY Delivery supervisor recovery\n\n[Service]\nType=simple\nExecStart=${[node, ...args].map(systemdArgument).join(' ')}\nRestart=always\nRestartSec=15\nKillMode=process\n\n[Install]\nWantedBy=default.target\n` };
28
28
  }
29
29
  if (platform === 'darwin') {
30
30
  const name = 'com.awak.simy.delivery-supervisor';
31
31
  return { name, file: join(home, 'Library/LaunchAgents', `${name}.plist`), content:
32
- `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><!-- ${marker} --><dict><key>Label</key><string>${name}</string><key>ProgramArguments</key><array>${[node, ...args].map(arg => `<string>${xml(arg)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict><key>ThrottleInterval</key><integer>15</integer></dict></plist>` };
32
+ `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><!-- ${marker} --><dict><key>Label</key><string>${name}</string><key>ProgramArguments</key><array>${[node, ...args].map(arg => `<string>${xml(arg)}</string>`).join('')}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>15</integer></dict></plist>` };
33
33
  }
34
34
  if (platform === 'win32') {
35
35
  const name = 'SIMYDeliverySupervisor';
@@ -1,3 +1,4 @@
1
+ import { supervisorTarget } from './supervisor-activation.js';
1
2
  import { spawn } from 'node:child_process';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -19,7 +20,9 @@ async function verifiedPin(root) {
19
20
  return { digest: pin.digest, release: pin.release };
20
21
  }
21
22
  export async function setSupervisorWatchdog({ root, enabled, webOrigin,
22
- inspectPin = verifiedPin, now = Date.now(), expectedEpoch } = {}) {
23
+ inspectPin = verifiedPin, now = Date.now(), expectedEpoch, target } = {}) {
24
+ if (target && !enabled) throw new Error('A recovery target requires explicit enable.');
25
+ const binding = target ? await supervisorTarget(root, target) : null;
23
26
  const pin = enabled ? await inspectPin(root) : null;
24
27
  const lock = await acquireDesktopProcessLock(join(folder(root), 'control-lock'), {
25
28
  alive: supervisorProcessAlive, identity: supervisorProcessIdentity });
@@ -27,6 +30,8 @@ export async function setSupervisorWatchdog({ root, enabled, webOrigin,
27
30
  const previous = await readSupervisorState(controlPath(root));
28
31
  if (expectedEpoch !== undefined && previous?.epoch !== expectedEpoch) return previous;
29
32
  const control = { version: 1, enabled: enabled === true, epoch: randomUUID(), updatedAt: now,
33
+ selection: { source: 'explicit_command', action: enabled ? 'enable' : 'disable', at: now },
34
+ ...(binding ? { targets: [binding] } : {}),
30
35
  ...(pin ? { ...pin, webOrigin: resolveWebOrigin(webOrigin) } : {}) };
31
36
  await saveSupervisorState(controlPath(root), control);
32
37
  return control;
@@ -49,9 +54,11 @@ export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readC
49
54
  if (closed) return;
50
55
  const at = clock(), control = await readControl();
51
56
  controlEpoch = control?.epoch;
57
+ const wake = lastWall !== null && (at < lastWall || at - lastWall > 60_000);
58
+ lastWall = at;
52
59
  const previous = pendingPin || await readSupervisorState(statePath(root));
53
60
  const state = { version: 1, handoverVersion: 1, watcherNonce, at, action: 'observe', supervisor: null, lastExit,
54
- lastLaunchAt: launchTime ?? previous?.lastLaunchAt ?? null, epoch: control?.epoch || null };
61
+ lastLaunchAt: launchTime ?? previous?.lastLaunchAt ?? null, epoch: control?.epoch || null, lifecycle: wake ? 'wake' : 'observe' };
55
62
  if (!control || control.version !== 1 || control.enabled !== true) { state.action = 'disabled'; await save(state); return state; }
56
63
  state.supervisor = await observeOwner(root, { now: at });
57
64
  if (state.supervisor.state === 'running') {
@@ -77,10 +84,8 @@ export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readC
77
84
  }
78
85
  // Resume/sleep or clock regression is an observation boundary, never proof
79
86
  // that a live provider stopped. Re-observe on the next tick first.
80
- if (lastWall !== null && (at < lastWall || at - lastWall > 60_000)) {
81
- lastWall = at; state.action = 'wake_reobserve'; await save(state); return state;
87
+ if (wake) { state.action = 'wake_reobserve'; await save(state); return state;
82
88
  }
83
- lastWall = at;
84
89
  try { await checkDesktop(root, null); }
85
90
  catch (error) {
86
91
  if (error.code !== 'process_conflict') throw error;
@@ -102,7 +107,7 @@ export function createSupervisorWatchdog({ root, inspectPin = verifiedPin, readC
102
107
  !finalControl?.enabled || finalControl.epoch !== control.epoch) {
103
108
  state.action = 'launch_cancelled'; await save(state); return state;
104
109
  }
105
- const args = [join(pin.release, 'src/index.js'), 'delivery', 'supervise', '--host', control.webOrigin, '--no-auto-update'];
110
+ const args = [join(pin.release, 'src/index.js'), 'delivery', 'supervise', '--activation-epoch', control.epoch, '--host', control.webOrigin, '--no-auto-update'];
106
111
  child = launch(process.execPath, args, { env: { ...process.env, SIMY_HOME: root, SIMY_AUTO_UPDATE: '0' },
107
112
  detached: process.platform !== 'win32', stdio: 'ignore', windowsHide: true });
108
113
  const launched = child;
@@ -8,7 +8,7 @@ export function validDesktopAccessPath(path, slug) {
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
10
  const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
- 'check_update', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
11
+ 'check_update', 'auto_update_set_enabled', 'update', 'prepare_update_restart', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
12
  'provider_disconnect', 'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
14
14
  return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
@@ -1,4 +1,6 @@
1
+ import { remoteOperationFailure } from './session-supervision/remote-errors.js';
1
2
  import { createRemoteHostManager } from './session-supervision/remote-hosts.js';
3
+ import { readDesktopAutoUpdate, writeDesktopAutoUpdate } from './desktop-update-settings.js';
2
4
  import { createUpdateRuntime } from './update-detachment/runtime.js';
3
5
  import { createCoordinatorReconciler } from "./update-detachment/reconciliation-adapter.js";
4
6
  import { createLedgerCoordinatorAdapters } from "./update-detachment/ledger-adapter.js";
@@ -24,7 +26,7 @@ import { controlDelivery, verifyDelivery, createRecordReader, updateRecord, reco
24
26
  import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
25
27
  import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
26
28
  import { createStopNotifier } from "./session-supervision/stop-notifications.js";
27
- import { AutoContinueUnavailableError, answerSession, continueSession, resumeSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
29
+ import { AutoContinueUnavailableError, answerSession, continueSession, followupSession, resumeSession, discoverSessions, enrollSession, monitorSession, trackSession, setSessionAutoContinue } from "./session-supervision/index.js";
28
30
  import { createContinuationAnalysisManager } from "./session-supervision/analysis-manager.js";
29
31
  import { analyzeContinuation, readContinuationContext } from "./session-supervision/continuation-analysis.js";
30
32
  import { continueAnalyzedSession, createAutomaticContinuation } from "./session-supervision/automatic-continuation.js";
@@ -169,10 +171,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
169
171
  let encodedStatus = null, lastStatus = null, updateProgress = null;
170
172
  let remoteHosts;
171
173
  const emitStatus = status => {
174
+ const updateSettings = updater?.snapshot?.() || {};
175
+ status = { ...status, update: { ...status.update,
176
+ auto_update_supported: updateSettings.auto_update_supported === true,
177
+ auto_update_enabled: updateSettings.auto_update_enabled === true,
178
+ auto_update_busy: updateSettings.auto_update_busy === true } };
172
179
  if (env.SIMY_REMOTE_CHILD === "1") status = { ...status, remoteSessionProtocol: 1 };
173
180
  if (remoteHosts) {
174
- void remoteHosts.update(providerLinks.codex.connected ? status : { ...status, access: { state: "unavailable" } }).catch(() => {});
175
- if (providerLinks.codex.connected) status = remoteHosts.project(status);
181
+ void remoteHosts.update(PROVIDER_LINKS.some(provider => providerLinks[provider].connected) ? status : { ...status, access: { state: "unavailable" } }).catch(() => {});
182
+ if (PROVIDER_LINKS.some(provider => providerLinks[provider].connected)) status = remoteHosts.project(status, { providerLinks });
176
183
  }
177
184
  if (updateProgress) status = { ...status, update: { ...status.update, ...updateProgress } };
178
185
  const { sampledAt, ...content } = status;
@@ -644,8 +651,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
644
651
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
645
652
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
646
653
  status.access = sampledAccess;
647
- status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
648
- if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
654
+ status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
649
655
  const requestedLinks = await decorateProviderSettings(status);
650
656
  const sameScope = desktopAccessReady(currentAccess) &&
651
657
  accessScope(lastStatus?.access) === accessScope(currentAccess);
@@ -694,8 +700,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
694
700
  const sampledAccess = structuredClone(status.access || { state: 'unavailable', organizationId: null, organizationName: null });
695
701
  if (!await acceptStatusAccess(sampledAccess)) { publishAgain = true; return; }
696
702
  status.access = sampledAccess;
697
- status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.());
698
- if(!desktopAccessReady(sampledAccess))status.updateDetachment={...status.updateDetachment,tasks:[],actions:status.updateDetachment.actions.filter(action=>action==='cancel_detach_update')};
703
+ status.updateDetachment = updateRuntime.snapshot(coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(sampledAccess)});
699
704
  const requestedScope = accessScope(currentAccess);
700
705
  const requestedLinks = await decorateProviderSettings(status);
701
706
  const allRecords = await recordReader.list();
@@ -841,6 +846,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
841
846
  updater = await (dependencies.createUpdater || (process.platform === "win32" ? (() => createWindowsDesktopUpdater({ version: packageInfo.version, disabled: env.SIMY_AUTO_UPDATE === "0" })) : createCliAutoUpdater))({
842
847
  daemon: true, interactive: true, webOrigin,
843
848
  disabled: env.SIMY_AUTO_UPDATE === "0",
849
+ autoUpdatesEnabled: readDesktopAutoUpdate(root),
844
850
  ...(updatePackageRoot ? { packageRoot: updatePackageRoot } : {}),
845
851
  dependencies: { packageInfo: updatePackageRoot ? await readCliPackageInfo(updatePackageRoot) : packageInfo,
846
852
  logger: message => diagnostics.write(`update: ${message}`) },
@@ -1055,7 +1061,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1055
1061
  return;
1056
1062
  }
1057
1063
  if(command.action==='cancel_detach_update') {
1058
- try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.());coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
1064
+ try {updateRuntime.validate(command,coordinator.snapshot(),updater?.snapshot?.(),{accessReady:desktopAccessReady(currentAccess)});coordinator.cancel();emit({type:'result',id:command.id,ok:true});}
1059
1065
  catch {emit({type:'result',id:command.id,ok:false,message:'This update operation changed. Refresh its status.'});}
1060
1066
  return;
1061
1067
  }
@@ -1124,7 +1130,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1124
1130
  const access = await accessSnapshot();
1125
1131
  const commandScope = accessScope(access);
1126
1132
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
1127
- const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) ||
1133
+ const recoveryControl = ['provider_disconnect', 'session_unmonitor'].includes(action) || action === 'auto_update_set_enabled' && command.enabled === false ||
1128
1134
  ['session_set_auto_continue', 'session_set_email_reports', 'team_thread_share'].includes(action) && command.enabled === false;
1129
1135
  if (['detach_update', 'update_reconnect'].includes(action)) requireDesktopAccess(access);
1130
1136
  else if (!recoveryControl && desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
@@ -1162,7 +1168,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1162
1168
  commandProviderLink = await requireProviderLink({ root: deliveryRoot, provider: command.provider });
1163
1169
  }
1164
1170
  if (command.hostId && command.hostId !== 'local') {
1165
- if (!remoteHosts || command.provider !== 'codex') throw Object.assign(new Error('Remote host is unavailable.'), { code: 'remote_host_unavailable' });
1171
+ if (!remoteHosts || !PROVIDER_LINKS.includes(command.provider)) throw Object.assign(new Error('Remote host is unavailable.'), { code: 'remote_host_unavailable' });
1166
1172
  await authorizeProvider(command.provider, commandScope, commandProviderLink?.epoch);
1167
1173
  const status = await agent.getDesktopStatus();
1168
1174
  // Status collection can yield while provider consent is revoked.
@@ -1278,6 +1284,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
1278
1284
  if (!result.opened) throw new Error('Browser unavailable.');
1279
1285
  } catch { throw Object.assign(new Error('Browser unavailable.'), { code: 'desktop_browser_unavailable' }); }
1280
1286
  }
1287
+ } else if (action === 'auto_update_set_enabled') {
1288
+ if (typeof command.enabled !== 'boolean' || typeof updater.setAutomaticUpdates !== 'function') throw new Error('Automatic updates are unavailable.');
1289
+ const previous = updater.snapshot().auto_update_enabled === true;
1290
+ updater.setAutomaticUpdates(command.enabled);
1291
+ try { writeDesktopAutoUpdate(root, command.enabled); }
1292
+ catch (error) { updater.setAutomaticUpdates(previous); throw error; }
1293
+ emit({ type: 'result', id: command.id, ok: true });
1294
+ await publish();
1295
+ if (command.enabled) setImmediate(() => { if (!closed && !relay) void updater.checkNow().catch(() => {}); });
1296
+ return;
1281
1297
  } else if (action === "check_update") await updater.checkNow();
1282
1298
  else if (action === "update") {
1283
1299
  const status = await agent.getDesktopStatus();
@@ -1514,6 +1530,19 @@ export async function startDesktopHost({ input = process.stdin, output = process
1514
1530
  await (dependencies.monitorSession || monitorSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
1515
1531
  session: command.session, monitor: action === "session_monitor", expectedProviderEpoch: commandProviderLink?.epoch });
1516
1532
  await refreshSessions(true);
1533
+ } else if (action === "session_followup") {
1534
+ const candidate = await selectedSession(command);
1535
+ if (!candidate.followup?.previousRunId || command.previousRunId !== candidate.followup.previousRunId) {
1536
+ throw new Error('The finished task changed. Refresh this conversation before starting its follow-up.');
1537
+ }
1538
+ if (typeof command.objective !== 'string' || !command.objective.trim() || command.objective.length > 4000) {
1539
+ throw new Error('Enter the current goal to continue.');
1540
+ }
1541
+ await authorizeProvider(candidate.provider, commandScope, selectedLinkEpochs.get(candidate));
1542
+ await (dependencies.followupSession || followupSession)({ root: join(root, 'delivery-hooks'),
1543
+ provider: command.provider, session: command.session, previousRunId: command.previousRunId,
1544
+ objective: command.objective, followupAutoContinue: true, expectedProviderEpoch: selectedLinkEpochs.get(candidate) });
1545
+ await refreshSessions(true);
1517
1546
  } else if (action === "session_enroll") {
1518
1547
  await (dependencies.enrollSession || enrollSession)({ root: join(root, "delivery-hooks"), provider: command.provider,
1519
1548
  session: command.session, objective: command.objective, acceptanceCriteria: command.acceptanceCriteria,
@@ -1556,7 +1585,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
1556
1585
  providerLinkFeedback[provider] = { state: 'error', message: desktopActionError(command.action, error) };
1557
1586
  }
1558
1587
  }
1559
- emit({ type: "result", id: command.id, ok: false, ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
1588
+ emit({ type: "result", id: command.id, ok: false, ...(remoteOperationFailure(error?.code).code ? { code: error.code } : {}), ...(error?.code === 'cancelled' || statisticsRequest?.controller.signal.aborted ? { code: 'cancelled' } : {}), message: ["update", "prepare_update_restart"].includes(command.action) && updateProgress ? updateProgress.message : desktopActionError(command.action, error) });
1560
1589
  } finally {
1561
1590
  // Access rejection can happen before the shared reader is constructed.
1562
1591
  if (statisticsRequest && !statisticsRequest.promise && activeStatisticsRead === statisticsRequest) {
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path';
2
+ import { readState, atomicSave } from './update-detachment/state-store.js';
3
+
4
+ export function readDesktopAutoUpdate(root) {
5
+ try {
6
+ const settings = readState(join(root, 'desktop-update-settings.json'));
7
+ return settings === null ? true : settings.enabled === true;
8
+ }
9
+ catch { return false; }
10
+ }
11
+
12
+ export function writeDesktopAutoUpdate(root, enabled) {
13
+ if (typeof enabled !== 'boolean') throw new Error('Choose whether automatic updates are enabled.');
14
+ atomicSave(join(root, 'desktop-update-settings.json'), { schemaVersion: 1, enabled });
15
+ }
package/src/index.js CHANGED
@@ -149,7 +149,7 @@ async function runCommand(installMode) {
149
149
  simy sqm lint Validate module reliability before publication
150
150
  simy sqm replay Replay modules against labelled historical snapshots
151
151
  simy update --check Check the installed and latest stable CLI versions
152
- simy sessions Show local Codex conversations (--interactive for search/navigation)
152
+ simy sessions Show local and registered remote Codex/Claude conversations (--interactive for search/navigation)
153
153
  simy update ${installMode === "macos_installer" ? "Show Apple Silicon update status and installer action" : "Install an eligible stable CLI update"}
154
154
 
155
155
  Options:
@@ -160,17 +160,17 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
160
160
  finally { await handle?.close(); }
161
161
  }
162
162
 
163
- async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, signal } = {}) {
163
+ async function collect({ home = homedir(), all = false, limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, includeBoundary = false, signal } = {}) {
164
164
  if (signal?.aborted) return [];
165
165
  const root = await projectsRoot(home);
166
166
  if (!root) return [];
167
- const maximum = Number.isInteger(limit) ? Math.max(1, Math.min(limit, RECENT_SESSION_LIMIT)) : 100;
167
+ const maximum = all ? Infinity : Number.isInteger(limit) ? Math.max(1, Math.min(limit, RECENT_SESSION_LIMIT)) : 100;
168
168
  const observedAt = Date.now();
169
169
  const result = new Map();
170
170
  const descriptors = [], priorities = new Set(prioritySessions);
171
171
  let projects;
172
172
  try { projects = await readdir(root, { withFileTypes: true }); } catch { return []; }
173
- for (const entry of projects.filter(item => item.isDirectory() && !item.isSymbolicLink()).slice(0, MAX_PROJECTS)) {
173
+ for (const entry of projects.filter(item => item.isDirectory() && !item.isSymbolicLink()).slice(0, all || session ? undefined : MAX_PROJECTS)) {
174
174
  if (signal?.aborted) return [];
175
175
  const project = join(root, entry.name);
176
176
  let files;
@@ -203,11 +203,11 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
203
203
  ...(!candidate.parentSession && origin.parentSession ? { parentSession: origin.parentSession, parentSessionSource: origin.source } : {}) };
204
204
  return candidate;
205
205
  };
206
- const selected = session ? descriptors : [
206
+ const selected = session || all ? descriptors : [
207
207
  ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived !== true), { limit: maximum, priority: item => priorities.has(item.session) }),
208
208
  ...recentSessionWindow(descriptors.filter(item => desktopLineage.get(item.session)?.providerArchived === true), { limit: maximum, priority: item => priorities.has(item.session) }),
209
209
  ];
210
- const filesToInspect = selected.slice(0, MAX_TRANSCRIPTS);
210
+ const filesToInspect = all ? selected : selected.slice(0, MAX_TRANSCRIPTS);
211
211
  let nextFile = 0;
212
212
  // Catalog prefixes are bounded independently of full transcript size. Use a
213
213
  // small I/O pool; exact-context/statistics reads stay serial and identity-safe.
@@ -259,8 +259,8 @@ async function collect({ home = homedir(), limit = 100, session = null, priority
259
259
  ];
260
260
  }
261
261
 
262
- export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
263
- return collect({ home, limit, prioritySessions, signal, cacheMetadata: true });
262
+ export async function listClaudeSessions({ home, all = false, limit, prioritySessions, signal } = {}) {
263
+ return collect({ home, all, limit, prioritySessions, signal, cacheMetadata: true });
264
264
  }
265
265
 
266
266
  export async function readClaudeSession(session, { home } = {}) {