@awak-app/simy-cli 0.7.32 → 0.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +22 -3
  2. package/package.json +3 -2
  3. package/src/auto-update.js +10 -1
  4. package/src/cli-contract.js +1 -1
  5. package/src/cli-observability.js +1 -1
  6. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +44 -12
  7. package/src/codex-plugins/simy/hooks/purpose-workspace.mjs +13 -4
  8. package/src/delivery-hooks/desktop.js +1 -1
  9. package/src/desktop-access.js +1 -1
  10. package/src/desktop-frame-writer.js +29 -0
  11. package/src/desktop-host.js +252 -77
  12. package/src/orchestrator/loop.js +2 -0
  13. package/src/session-supervision/analysis-manager.js +83 -18
  14. package/src/session-supervision/claude-desktop-lineage.js +1 -1
  15. package/src/session-supervision/claude-sessions.js +16 -4
  16. package/src/session-supervision/codex-app-server.js +45 -30
  17. package/src/session-supervision/continuation-readiness.js +58 -0
  18. package/src/session-supervision/decision-presentation.js +11 -3
  19. package/src/session-supervision/index.js +126 -28
  20. package/src/session-supervision/owner-transport.js +23 -8
  21. package/src/session-supervision/progressive-discovery.js +79 -0
  22. package/src/session-supervision/remote-entry.js +7 -4
  23. package/src/session-supervision/remote-host-recovery.js +21 -0
  24. package/src/session-supervision/remote-hosts.js +28 -8
  25. package/src/session-supervision/remote-owner-entry.js +20 -0
  26. package/src/session-supervision/remote-service.js +36 -0
  27. package/src/session-supervision/statistics.js +19 -3
  28. package/src/session-supervision/work-state.js +7 -1
  29. package/src/sqm/local-tests.js +2 -1
  30. package/src/update-connection.js +16 -0
@@ -7,6 +7,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { compareSessionWork } from './work-state.js';
9
9
  import { manualAcceptanceCommand, matchesManualAcceptanceControls, manualAcceptanceContinuation } from './manual-acceptance-command.js';
10
+ import { remoteHostRecovery } from './remote-host-recovery.js';
10
11
 
11
12
  const MAX_FRAME = 8 * 1024 * 1024;
12
13
  const MAX_HOSTS = 8;
@@ -83,9 +84,10 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
83
84
  let preferenceScope = null;
84
85
  let scope = null, local = null, closed = false, settingsError = null, writes = Promise.resolve(), generation = 0;
85
86
  const changed = () => { if (!closed) onChange(); };
86
- function disconnect(connection, reason = 'Remote connection closed.') {
87
+ function disconnect(connection, reason = 'Remote connection closed.', errorCode = 'transport_unavailable') {
87
88
  const child = connection.child;
88
89
  connection.child = null; connection.ready = false; connection.reason = reason; connection.state = 'unavailable'; connection.retryAt = now() + retryMs;
90
+ connection.errorCode = errorCode;
89
91
  clearTimeout(connection.handshake);
90
92
  for (const job of connection.pending.values()) { clearTimeout(job.timer); job.reject(error('Remote operation result is unknown. Check the original conversation before retrying.', 'remote_operation_unknown')); }
91
93
  connection.pending.clear();
@@ -106,7 +108,8 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
106
108
  const id = `${connection.id}:${frame.id}`;
107
109
  return preferences.change(expectedScope, state => {
108
110
  if (state.events[id] || closed || preferenceScope !== expectedScope || !connection.ready) return;
109
- onNotify({ ...stamp(event, connection), hostId: connection.id, id });
111
+ onNotify({ ...stamp(event, connection), hostId: connection.id, id,
112
+ ...(event.continuationWaitId ? { sessionIdentity: `${event.provider}|${event.session}|${connection.id}` } : {}) });
110
113
  state.events[id] = now();
111
114
  });
112
115
  }
@@ -139,7 +142,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
139
142
  }
140
143
  function connect(connection) {
141
144
  if (closed || connection.enabled === false || connection.child || now() < connection.retryAt) return;
142
- connection.state = 'connecting'; connection.reason = null;
145
+ connection.state = 'connecting'; connection.reason = null; connection.errorCode = null;
143
146
  let child;
144
147
  try { child = spawnImpl('ssh', remoteSshArgs(connection.alias, REMOTE_HOST_COMMAND), { stdio: ['pipe', 'pipe', 'pipe'], shell: false }); }
145
148
  catch { disconnect(connection, 'Could not start SSH.'); return; }
@@ -175,10 +178,11 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
175
178
  connection.capabilities = Array.isArray(status?.remoteControl?.actions)
176
179
  ? status.remoteControl.actions.filter(action => typeof action === 'string' && allowedActions.has(action))
177
180
  : status?.remoteControl === undefined && status?.remoteSessionProtocol === 1 ? [...legacyActions] : [];
178
- if (status?.remoteSessionProtocol !== 1) { disconnect(connection, 'Update SIMY on the remote host to use remote sessions.'); changed(); return; }
181
+ if (status?.remoteSessionProtocol !== 1) { disconnect(connection, 'Update SIMY on the remote host to use remote sessions.', 'update_required'); changed(); return; }
179
182
  if (!usable(status) || status.auth.userId !== local?.auth?.userId || status.access.organizationId !== local?.access?.organizationId) {
180
183
  clearTimeout(connection.handshake);
181
184
  connection.ready = false; connection.state = 'access_required'; connection.reason = 'Use Remote connections in this CLI to sign in to this host.';
185
+ connection.errorCode = status?.auth?.state !== 'signed_in' ? 'simy_auth_required' : 'access_required';
182
186
  connection.status = null;
183
187
  for (const [id, job] of connection.pending) if (!job.setup) { clearTimeout(job.timer); job.reject(error(connection.reason, 'remote_operation_unknown')); connection.pending.delete(id); }
184
188
  } else if (!Array.isArray(status.sessionSupervision?.sessions) || !Array.isArray(status.deliveryHooks?.runs)) {
@@ -186,6 +190,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
186
190
  } else {
187
191
  clearTimeout(connection.handshake);
188
192
  connection.status = status; connection.ready = true; connection.state = 'ready'; connection.reason = null; connection.receivedAt = now();
193
+ connection.errorCode = null;
189
194
  if (status.controllerEventsVersion === 1) void pullEvents(connection).catch(() => {});
190
195
  else for (const event of status.controllerEvents || []) void receiveNotification(connection, event)?.catch(() => {});
191
196
  const expectedScope = preferenceScope;
@@ -210,7 +215,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
210
215
  } else if (frame.type === 'notify' && connection.ready && local && typeof frame.id === 'string' && ['finished','failed','paused','needs_decision'].includes(frame.kind)) {
211
216
  void receiveNotification(connection, frame)?.catch(() => {});
212
217
  } else if (frame.type === 'error') {
213
- disconnect(connection, remoteOperationFailure(frame.code).message); changed(); return;
218
+ disconnect(connection, remoteOperationFailure(frame.code).message, frame.code === 'remote_owner_origin_mismatch' ? 'access_required' : 'transport_unavailable'); changed(); return;
214
219
  } else if (frame.type === 'task_detail') {
215
220
  const pending = connection.pending.get(frame.id);
216
221
  pending?.onDetail?.({ ...frame, id: pending.id, taskKey: pending.taskKey });
@@ -252,7 +257,7 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
252
257
  c.label = host.label; c.enabled = host.enabled !== false;
253
258
  if (!c.enabled) {
254
259
  if (c.child) disconnect(c);
255
- c.ready = false; c.status = null; c.state = 'disconnected'; c.reason = null;
260
+ c.ready = false; c.status = null; c.state = 'disconnected'; c.reason = null; c.errorCode = null;
256
261
  continue;
257
262
  }
258
263
  if (c.state === 'disconnected') c.retryAt = 0;
@@ -339,8 +344,22 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
339
344
  if (!c) return;
340
345
  c.enabled = enabled;
341
346
  if (!enabled) { disconnect(c); c.status = null; c.state = 'disconnected'; c.reason = null; }
342
- else { c.retryAt = 0; if (c.state === 'disconnected') c.state = 'connecting'; }
347
+ else {
348
+ // Explicit Connect also retries a stale or unauthorized live transport.
349
+ // Resetting backoff alone cannot replace a child that is still present.
350
+ // Keep healthy connections and an in-flight handshake intact.
351
+ if (c.child && c.state !== 'connecting' && (!c.ready || now() - c.receivedAt > 45_000)) disconnect(c);
352
+ c.retryAt = 0;
353
+ if (!c.child) c.state = 'connecting';
354
+ }
343
355
  });
356
+ const c = connections.get(host.id);
357
+ if (enabled && c?.child && c.capabilities?.includes('refresh')) {
358
+ // Explicit Connect/Check again must request a new host observation even
359
+ // when SSH is healthy. Keep its owner and in-flight mutation receipts:
360
+ // refreshing providers is read-only and must never replay execution.
361
+ c.child.stdin.write(JSON.stringify({ id: randomUUID(), action: 'refresh' }) + '\n');
362
+ }
344
363
  }
345
364
  async function forward(command, status) {
346
365
  if (closed || !usable(status) || scopeOf(status) !== scope) throw error('SIMY account changed. Refresh before using the remote host.', 'remote_host_access');
@@ -478,7 +497,8 @@ export function createRemoteHostManager({ root, store, spawnImpl = spawn, onChan
478
497
  const remoteActiveCount = hosts.filter(c => c.ready).reduce((n,c) => n + (c.status?.[key]?.activeCount || 0), 0);
479
498
  return { ...status[key], items: [...localItems, ...rows], localActiveCount: Math.max(0, (status[key]?.activeCount || 0) - priorRemote), activeCount: Math.max(0, (status[key]?.activeCount || 0) - priorRemote) + remoteActiveCount, remoteActiveCount, unknownHosts: hosts.filter(c => !c.ready).map(c => c.id) };
480
499
  }
481
- return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, transportId: c.ready ? c.transportId : null, reason: c.reason })) },
500
+ return { ...status, remoteHosts: { error: settingsError, items: hosts.map(c => ({ id: c.id, alias: c.alias, label: c.label, enabled: c.enabled !== false, state: c.ready && now() - c.receivedAt > 45_000 ? 'unavailable' : c.state, transportId: c.ready ? c.transportId : null, reason: c.reason,
501
+ recovery: remoteHostRecovery({ state: c.state, enabled: c.enabled, status: c.status, ready: c.ready, fresh: now() - c.receivedAt <= 45_000, errorCode: c.errorCode }) })) },
482
502
  tasks: aggregate('tasks', tasks), sqmTasks: aggregate('sqmTasks', sqmTasks),
483
503
  sessionSupervision: { ...status.sessionSupervision, selectionCandidates: [...(status.sessionSupervision?.selectionCandidates || []).filter(row => !row.hostId || row.hostId === 'local'), ...selectionCandidates], sessions: [...(status.sessionSupervision?.sessions || []).filter(row => !row.hostId || row.hostId === 'local'), ...sessions].sort(compareSessionWork) },
484
504
  deliveryHooks: { ...status.deliveryHooks, runs: [...(status.deliveryHooks?.runs || []).filter(row => !row.hostId || row.hostId === 'local'), ...runs] } };
@@ -0,0 +1,20 @@
1
+ // Separate from the SSH entry so reconnects only attach and never replace work.
2
+ import { PassThrough, Writable } from 'node:stream';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { adoptInstalledRuntime, pinnedWorkflowSource } from '../delivery-hooks/local-runtime.js';
7
+
8
+ process.env.SIMY_REMOTE_CHILD = '1';
9
+ process.env.SIMY_AUTO_UPDATE = '0';
10
+ const root = process.env.SIMY_HOME?.trim() || join(homedir(), '.simy');
11
+ try {
12
+ await adoptInstalledRuntime({ root });
13
+ const selected = await pinnedWorkflowSource({ root });
14
+ const host = await import(selected ? pathToFileURL(join(selected, 'src/desktop-host.js')).href : '../desktop-host.js');
15
+ if (host.remoteSessionProtocol !== 1) throw new Error('Update the remote SIMY runtime.');
16
+ host.startDesktopEntry({ input: new PassThrough(), output: new Writable({ write(_chunk, _encoding, done) { done(); } }) });
17
+ } catch (error) {
18
+ process.stderr.write(`Remote SIMY owner could not start: ${error.message}\n`);
19
+ process.exitCode = 1;
20
+ }
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, open } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { relayToOwner } from './owner-transport.js';
7
+
8
+ // The SSH viewer is disposable; the ordinary locked desktop owner is not.
9
+ // Startup contenders use the existing desktop lock, never remove it or stop an
10
+ // owner. A lost viewer must not turn into a repeated execution command.
11
+ export async function attachRemoteService({ root, webOrigin, input = process.stdin, output = process.stdout,
12
+ relay = relayToOwner, spawnImpl = spawn, timeoutMs = 30_000, retryMs = 200 } = {}) {
13
+ if (await relay({ root, webOrigin, input, output })) return;
14
+ await mkdir(join(root, 'logs'), { recursive: true, mode: 0o700 });
15
+ const log = await open(join(root, 'logs', 'remote-service.log'),
16
+ constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | (constants.O_NOFOLLOW || 0), 0o600);
17
+ try {
18
+ const env = { ...process.env, SIMY_HOME: root, SIMY_WEB_ORIGIN: webOrigin,
19
+ SIMY_REMOTE_CHILD: '1', SIMY_AUTO_UPDATE: '0' };
20
+ // A viewer cannot hand its update-candidate exemption to a new owner.
21
+ delete env.SIMY_DESKTOP_SUPERVISOR_TOKEN;
22
+ delete env.SIMY_UPDATE_TRANSFER_TOKEN;
23
+ const child = spawnImpl(process.execPath, [fileURLToPath(new URL('./remote-owner-entry.js', import.meta.url))], {
24
+ detached: true, stdio: ['ignore', log.fd, log.fd],
25
+ env,
26
+ });
27
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
28
+ child.unref();
29
+ } finally { await log.close(); }
30
+ const until = Date.now() + timeoutMs;
31
+ while (Date.now() < until) {
32
+ if (await relay({ root, webOrigin, input, output })) return;
33
+ await new Promise(resolve => setTimeout(resolve, retryMs));
34
+ }
35
+ throw Object.assign(new Error('Remote SIMY did not start. Inspect logs/remote-service.log; existing work was retained.'), { code: 'remote_owner_unavailable' });
36
+ }
@@ -10,6 +10,8 @@ export const unavailableStatistics = () => ({ state: 'unavailable', hours: [], a
10
10
  const READ_BYTES = 1024 * 1024;
11
11
  const MAX_LINE_BYTES = 4 * READ_BYTES;
12
12
  const WINDOW_MS = 7 * 24 * HOUR;
13
+ const CODEX_WORK_EVENTS = new Set(['task_started', 'task_complete', 'task_aborted', 'turn_aborted',
14
+ 'item_completed', 'user_message', 'agent_message', 'agent_reasoning', 'agent_reasoning_raw_content']);
13
15
  // One selected transcript only; no raw messages or on-disk cache. Identity and
14
16
  // ctime prevent reuse after replacement/truncation even when size is unchanged.
15
17
  let cachedTranscript;
@@ -54,6 +56,15 @@ class StatisticsAccumulator {
54
56
  const key = `codex:${at}:${total}`;
55
57
  if (delta > 0 || !this.usage.has(key)) this.usage.set(key, { at, tokens: delta });
56
58
  }
59
+ addOversized(prefix) {
60
+ // Only the canonical Codex response envelope is safe to dismiss here:
61
+ // response items contain activity, not cumulative usage or session identity.
62
+ // Keep unknown headers and oversized counters/metadata incomplete.
63
+ const header = /^\s*\{\s*"timestamp"\s*:\s*"([^"\\]+)"\s*,\s*(?:"ordinal"\s*:\s*\d+\s*,\s*)?"type"\s*:\s*"response_item"\s*,/.exec(prefix.subarray(0, 1024).toString('utf8'));
64
+ const at = header ? Date.parse(header[1]) : NaN;
65
+ if (this.provider === 'codex' && Number.isFinite(at) && at < this.scanNow - WINDOW_MS) return;
66
+ this.missing = true;
67
+ }
57
68
  add(line) {
58
69
  if (!line.trim()) return;
59
70
  let row;
@@ -77,7 +88,7 @@ class StatisticsAccumulator {
77
88
  if (!Number.isFinite(at)) return;
78
89
  const inWindow = at >= this.scanNow - WINDOW_MS;
79
90
  const active = this.provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
80
- : ['response_item', 'event_msg'].includes(row.type) && row.payload?.type !== 'token_count';
91
+ : row.type === 'response_item' || row.type === 'event_msg' && CODEX_WORK_EVENTS.has(row.payload?.type);
81
92
  if (active) {
82
93
  this.earliest = Math.min(this.earliest, at);
83
94
  if (inWindow) this.events.push(at);
@@ -189,7 +200,7 @@ async function readReverse(handle, size, accumulator, signal) {
189
200
  for (let index = data.lastIndexOf(10); index >= 0; index = data.lastIndexOf(10, index - 1)) {
190
201
  const line = data.subarray(index + 1, end);
191
202
  if (!skipping && line.length <= MAX_LINE_BYTES) accumulator.add(line.toString('utf8'));
192
- else accumulator.missing = true;
203
+ else accumulator.addOversized(line);
193
204
  skipping = false; end = index;
194
205
  // Disk I/O alone does not yield while parsing a dense page. Keep status,
195
206
  // selection and cancellation responsive even for huge cached transcripts.
@@ -200,10 +211,15 @@ async function readReverse(handle, size, accumulator, signal) {
200
211
  }
201
212
  if (index === 0) break;
202
213
  }
203
- if (end > MAX_LINE_BYTES || skipping) { carry = Buffer.alloc(0); skipping = true; accumulator.missing = true; }
214
+ if (end > MAX_LINE_BYTES || skipping) {
215
+ // Retain a bounded prefix until the start of this line is reached. The
216
+ // prefix may straddle a page, so keep the adjacent bytes when skipping.
217
+ carry = Buffer.from(data.subarray(0, Math.min(end, 1024))); skipping = true;
218
+ }
204
219
  else carry = Buffer.from(data.subarray(0, end));
205
220
  }
206
221
  if (!skipping) accumulator.add(carry.toString('utf8'));
222
+ else accumulator.addOversized(carry);
207
223
  return bytesRead;
208
224
  }
209
225
 
@@ -85,7 +85,13 @@ export function sessionWorkState(candidate, run, { now = Date.now(), provider }
85
85
  const phase = run?.phase || run?.managed?.phase;
86
86
  const stopped = run?.state === 'stopped' || run?.stopReason?.actor === 'user' ||
87
87
  run?.state === 'paused' && /^(Paused by user\.|Automatic continuation paused by user\.|User interrupted the provider\.|Stopped by user\.)$/.test(run.reason || '');
88
- if (stopped) return result('stopped', '自分で停止', '停止の指示を維持しています。', 'resume');
88
+ if (stopped) {
89
+ if (run?.reason === 'User interrupted the provider.' && run?.stopReason?.actor !== 'user')
90
+ return result('stopped', '中断を検出', '元のAIから中断が通知されました。本人による停止操作かは確認できません。', 'resume', true);
91
+ if (run?.stopReason?.actor === 'user' || /^(Stopped by user\.|Paused by user\.|Automatic continuation paused by user\.)$/.test(run?.reason || ''))
92
+ return result('stopped', '自分で停止', '停止の指示を維持しています。', 'resume');
93
+ return result('stopped', '停止中', '停止理由を確認してから再開してください。', 'resume', true);
94
+ }
89
95
  if (approval && !approval.denied || candidate.decisionRequest?.kind === 'approval')
90
96
  return result('approval', '承認待ち', candidate.decisionRequest?.context || '実行する操作の確認が必要です。', 'review_approval', true);
91
97
  if (question || phase === 'needs_clarification' || candidate.analysis?.state === 'needs_user' || candidate.decisionRequest?.kind === 'question')
@@ -16,7 +16,8 @@ const profiles = {
16
16
  // finite catalog-owned deadline while allowing slow local process I/O.
17
17
  { id: 'repair-and-stop-boundaries', timeoutMs: 1_200_000, files: ['test/purpose-search.test.js', 'test/delivery-explicit-resume.test.js', 'test/purpose-continuation.test.js'] },
18
18
  { id: 'sqm-runner', files: ['test/sqm-local-tests.test.js'] },
19
- { id: 'provider-startup', files: ['test/codex-app-server-discovery.test.js', 'test/session-supervision.test.js'] },
19
+ { id: 'provider-startup', files: ['test/codex-app-server-discovery.test.js', 'test/session-supervision.test.js',
20
+ 'test/progressive-discovery.test.js', 'test/desktop-record-index.test.js', 'test/desktop-background-performance.test.js'] },
20
21
  { id: 'handoff-and-audit-recovery', files: ['test/consistency-handoff.test.js', 'test/consistency-monitor.test.js', 'test/consistency-auditor.test.js', 'test/desktop-host.test.js'] },
21
22
  { id: 'migration-and-current-outcomes', files: ['test/autonomous-upgrade.test.js', 'test/local-delivery-runtime.test.js', 'test/delivery-migration-crash.test.js', 'test/purpose-inspection.test.js', 'test/purpose-search-scenarios.test.js', 'test/sqm-large-diff.test.js'] },
22
23
  ],
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+ import { normalizeWebOrigin } from './web-origin.js';
3
+
4
+ // Pin the resolved settings directory and origin, not a parent shell's defaults.
5
+ // Session/remote-host files stay in that directory across runtime replacements.
6
+ export function updateConnectionEnvironment({ env = process.env, root, webOrigin }) {
7
+ if (typeof root !== 'string' || !root.trim()) throw new Error('SIMY state directory is required for an update.');
8
+ return { ...env, SIMY_HOME: path.resolve(root), SIMY_WEB_ORIGIN: normalizeWebOrigin(webOrigin) };
9
+ }
10
+
11
+ export function sameUpdateIdentity(expected, actual) {
12
+ if (expected?.auth?.state !== 'signed_in') return actual?.auth?.state !== 'signed_in';
13
+ return actual?.auth?.state === 'signed_in' && Boolean(expected.auth.userId) &&
14
+ actual.auth.userId === expected.auth.userId &&
15
+ (expected.access?.organizationId || null) === (actual.access?.organizationId || null);
16
+ }