@awak-app/simy-cli 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -178,7 +178,12 @@ marketplace still points to SIMY’s generated local source. Plugin versions inc
178
178
  receive a fresh cache. Use a new Codex task to pick up updated skills; running
179
179
  tasks are not restarted. Missing or disabled plugins are not enabled automatically.
180
180
  Failed refreshes retry on the next check without blocking daemon startup.
181
- The desktop Sessions page discovers existing provider conversations and lets users
181
+ The desktop Sessions page loads a recent window of up to 200 conversations,
182
+ ranked by last activity. Explicitly tracked, monitored, auto-continue and unsettled
183
+ supervision records remain visible even outside that window. Claude discovery
184
+ ranks file metadata before opening selected transcripts. A conflicting saved
185
+ workspace leaves only that conversation's Action association unavailable; it
186
+ does not stop discovery or change the saved identity. The page lets users
182
187
  choose monitoring or supported session supervision with a goal, acceptance
183
188
  criteria and an autonomy policy. Local time, token and continuation counters do not stop work. Codex enrollment uses an exact
184
189
  app-server session and checks writer ownership before each continuation. Claude
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,7 +38,7 @@
38
38
  "test:e2e:console:real-provider": "node ./scripts/real-provider-console-e2e.js",
39
39
  "test:e2e:console:fullstack": "node ./scripts/fullstack-cli-smoke.js",
40
40
  "check": "npm run check:auto-update && node --check ./src/desktop-executor.js && node --check ./src/local-task.js && node --check ./scripts/desktop-executor-e2e.js && node --check ./scripts/pre-guardrail-e2e.js && node --check ./scripts/fixtures/fake-desktop-provider.js && npm run check:syntax && npm run check:session-supervision-syntax && npm run check:real-provider-syntax && npm test && npm run check:package",
41
- "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
41
+ "check:session-supervision-syntax": "node --check ./src/session-supervision/index.js && node --check ./src/session-supervision/discovery-window.js && node --check ./src/session-supervision/codex-app-server.js && node --check ./src/session-supervision/claude-sessions.js && node --check ./src/session-supervision/original-session-target.js && node --check ./src/session-supervision/action-association.js && node --check ./src/session-supervision/action-association-host.js && node --check ./src/session-supervision/action-association-api.js && node --check ./src/session-supervision/association-store.js && node --check ./src/session-supervision/action-deadline.js && node --check ./src/session-supervision/action-deadline-store.js && node --check ./src/codex-plugins/simy/hooks/action-association.mjs",
42
42
  "check:auto-update": "node --check ./src/auto-update.js && node --check ./scripts/auto-update-e2e.js && npm run check:sqm",
43
43
  "check:workflow-observation-sender": "node --check ./src/workflow-observation-sender.js",
44
44
  "check:sqm": "node --check ./src/sqm/canonical.js && node --check ./src/sqm/validation.js && node --check ./src/sqm/reliability.js && node --check ./src/sqm/repository.js && node --check ./src/sqm/checkers.js && node --check ./src/sqm/bundle-store.js && node --check ./src/sqm/proof.js && node --check ./src/sqm/evidence-client.js && node --check ./src/sqm/session.js && node --check ./src/sqm/index.js && node --check ./src/sqm/command.js && node --check ./src/sqm/agent-skill.js && node --check ./src/sqm/workflow-onboarding.js && node --check ./src/sqm/workflow-update.js && node --check ./src/sqm/history-store.js && node --check ./src/sqm/review-worker.js && node --check ./scripts/sqm-complete-loop-e2e.js && node --check ./scripts/sqm-local-review-e2e.js",
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.6.4";
16
+ export const CLI_VERSION = "0.6.6";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -462,7 +462,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
462
462
  if (command.force !== true) throw new Error("Confirm before stopping this task.");
463
463
  await agent.stopDesktopTask(command.taskKey);
464
464
  emit({ type: "result", id: command.id, ok: true });
465
- void publish(); return;
465
+ void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); return;
466
466
  }
467
467
  const access = await accessSnapshot();
468
468
  const commandScope = accessScope(access);
@@ -482,7 +482,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
482
482
  statisticsCache.clear(); // Only the selected conversation needs chart data.
483
483
  statisticsCache.set(`${candidate.provider}:${candidate.session}`, statistics);
484
484
  emit({ type: "result", id: command.id, ok: true });
485
- void publish(); return;
485
+ void publish().catch(() => emit({ type: "error", message: "SIMY status could not be refreshed." })); return;
486
486
  }
487
487
  if (action === "my_ai_session") {
488
488
  const ticket = await agent.createDesktopWebSession();
@@ -200,7 +200,10 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
200
200
  async decorateSessions(sessions, projection) {
201
201
  await ordinary.decorate(sessions, projection);
202
202
  const records = projection?.byKey || new Map((await list(root)).map(record => [`${record.provider}:${record.session}`, record]));
203
- const status = { runs: sessions.filter(item => records.get(`${item.provider}:${item.session}`)?.run?.managed).map(item => {
203
+ const status = { runs: sessions.filter(item => {
204
+ const record = records.get(`${item.provider}:${item.session}`);
205
+ return record?.run?.managed && record.cwd === item.cwd;
206
+ }).map(item => {
204
207
  const record = records.get(`${item.provider}:${item.session}`);
205
208
  return { managed: true, taskId: record.run.managed.taskId, actionAssociation: record.run.managed.actionAssociation, candidate: item };
206
209
  }) };
@@ -212,6 +215,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
212
215
  const expected = projection ? projection.authRevision : await current(), record = projection
213
216
  ? projection.byKey.get(`${candidate.provider}:${candidate.session}`)
214
217
  : await read(root, candidate.provider, candidate.session);
218
+ if (record?.cwd !== candidate.cwd) return null;
215
219
  const local = record?.run?.managed?.actionAssociation || record?.actionAssociation;
216
220
  if (!expected || projection && auth !== expected || local?.authRevision !== expected || !(candidate.actionAssociation?.canCorrect || candidate.actionAssociation?.canReadBinding)) return null;
217
221
  const binding = candidate.actionAssociation.binding;
@@ -224,7 +228,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
224
228
  const latest = await read(root, candidate.provider, candidate.session);
225
229
  const scoped = latest?.run?.managed?.actionAssociation || latest?.actionAssociation;
226
230
  if (!await valid(expected)) return false;
227
- return (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
231
+ return latest?.cwd === candidate.cwd && (latest?.run?.managed?.taskId || latest?.associationTaskId) === candidate.taskId && scoped?.namespace === local.namespace && scoped?.authRevision === expected && fingerprint(scoped.binding) === fingerprint(binding);
228
232
  } };
229
233
 
230
234
  },
@@ -212,11 +212,20 @@ export function createOrdinaryAssociationStore({
212
212
  for (const taskId of views.keys()) if (!taskIds.has(taskId) && !inFlight.has(taskId)) views.delete(taskId);
213
213
  const pending = [];
214
214
  for (const candidate of sessions) {
215
- const record = await ensure(
215
+ let record;
216
+ try {
217
+ record = await ensure(
216
218
  candidate,
217
219
  records.get(`${candidate.provider}:${candidate.session}`) || null,
218
- ),
219
- taskId = record.run?.managed?.taskId || record.associationTaskId;
220
+ );
221
+ } catch (error) {
222
+ // A stale workspace or unreadable local record affects only this
223
+ // optional association. Never rewrite its identity or stop the host.
224
+ candidate.actionAssociation = { status: error?.code === 'conflict' ? 'conflict' : 'unavailable',
225
+ binding: null, candidates: [], canCorrect: false, canReadBinding: false };
226
+ continue;
227
+ }
228
+ const taskId = record.run?.managed?.taskId || record.associationTaskId;
220
229
  candidate.taskId = taskId;
221
230
  candidate.binding = { ...candidate.binding, taskId };
222
231
  if (record.run?.managed) continue;
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
6
6
  import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
7
7
  import { sessionTitle } from './session-titles.js';
8
+ import { RECENT_SESSION_LIMIT, recentSessionWindow } from './discovery-window.js';
8
9
 
9
10
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
11
  const MAX_PROJECTS = 256;
@@ -147,14 +148,14 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
147
148
  finally { await handle?.close(); }
148
149
  }
149
150
 
150
- async function collect({ home = homedir(), limit = 100, session = null, includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
151
+ async function collect({ home = homedir(), limit = 100, session = null, prioritySessions = [], includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
151
152
  if (signal?.aborted) return [];
152
153
  const root = await projectsRoot(home);
153
154
  if (!root) return [];
154
- const maximum = Number.isInteger(limit) ? Math.max(1, Math.min(limit, 200)) : 100;
155
+ const maximum = Number.isInteger(limit) ? Math.max(1, Math.min(limit, RECENT_SESSION_LIMIT)) : 100;
155
156
  const observedAt = Date.now();
156
157
  const result = new Map();
157
- let inspected = 0;
158
+ const descriptors = [], priorities = new Set(prioritySessions);
158
159
  let projects;
159
160
  try { projects = await readdir(root, { withFileTypes: true }); } catch { return []; }
160
161
  for (const entry of projects.filter(item => item.isDirectory() && !item.isSymbolicLink()).slice(0, MAX_PROJECTS)) {
@@ -162,32 +163,47 @@ async function collect({ home = homedir(), limit = 100, session = null, includeC
162
163
  const project = join(root, entry.name);
163
164
  let files;
164
165
  try { files = await readdir(project, { withFileTypes: true }); } catch { continue; }
165
- for (const file of files) {
166
- if (signal?.aborted) return [];
167
- if (inspected >= MAX_TRANSCRIPTS) break;
168
- if (!file.isFile() || file.isSymbolicLink() || !UUID.test(file.name.replace(/\.jsonl$/, '')) || !file.name.endsWith('.jsonl')) continue;
169
- const id = file.name.slice(0, -'.jsonl'.length);
170
- if (session && id !== session) continue;
171
- inspected++;
172
- const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext, cacheMetadata, includeStatistics);
173
- if (!candidate) continue;
174
- const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${entry.name}\0${file.name}` : ''}`;
175
- if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
176
- }
177
- if (inspected >= MAX_TRANSCRIPTS) break;
166
+ const candidates = files.filter(file => file.isFile() && !file.isSymbolicLink() &&
167
+ file.name.endsWith('.jsonl') && UUID.test(file.name.slice(0, -'.jsonl'.length)) &&
168
+ (!session || file.name === `${session}.jsonl`));
169
+ let next = 0;
170
+ // Ranking uses file metadata only. Opening every transcript before slicing
171
+ // made a small visible catalog scan thousands of message prefixes at startup.
172
+ await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, async () => {
173
+ while (next < candidates.length && !signal?.aborted) {
174
+ const file = candidates[next++];
175
+ try {
176
+ const info = await lstat(join(project, file.name));
177
+ if (info.isFile() && !info.isSymbolicLink() && info.size <= MAX_FILE_BYTES)
178
+ descriptors.push({ projectName: entry.name, fileName: file.name,
179
+ session: file.name.slice(0, -'.jsonl'.length), lastActivityAt: info.mtimeMs });
180
+ } catch { /* A disappearing transcript does not stop discovery. */ }
181
+ }
182
+ }));
183
+ }
184
+ if (signal?.aborted) return [];
185
+ const selected = session ? descriptors : recentSessionWindow(descriptors, {
186
+ limit: maximum, priority: item => priorities.has(item.session),
187
+ });
188
+ for (const file of selected.slice(0, MAX_TRANSCRIPTS)) {
189
+ if (signal?.aborted) return [];
190
+ const candidate = await inspectTranscript(root, file.projectName, file.fileName, observedAt, includeContext, cacheMetadata, includeStatistics);
191
+ if (!candidate) continue;
192
+ const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${file.projectName}\0${file.fileName}` : ''}`;
193
+ if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
178
194
  }
179
195
  const sorted = [...result.values()].sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.session.localeCompare(b.session));
180
- return session ? sorted : sorted.slice(0, maximum);
196
+ return session ? sorted : recentSessionWindow(sorted, { limit: maximum, priority: item => priorities.has(item.session) });
181
197
  }
182
198
 
183
- export async function listClaudeSessions({ home, limit, signal } = {}) {
184
- return collect({ home, limit, signal, cacheMetadata: true });
199
+ export async function listClaudeSessions({ home, limit, prioritySessions, signal } = {}) {
200
+ return collect({ home, limit, prioritySessions, signal, cacheMetadata: true });
185
201
  }
186
202
 
187
203
  export async function readClaudeSession(session, { home } = {}) {
188
204
  const id = typeof session === 'string' ? session : session?.session;
189
205
  if (typeof id !== 'string' || !UUID.test(id)) return null;
190
- const matches = await collect({ home, limit: 200, session: id });
206
+ const matches = await collect({ home, limit: RECENT_SESSION_LIMIT, session: id });
191
207
  const selected = typeof session === 'object' && typeof session.cwd === 'string'
192
208
  ? matches.filter(item => item.cwd === session.cwd) : matches;
193
209
  return selected.length === 1 ? selected[0] : null;
@@ -5,6 +5,7 @@ import { CODEX_WRITER_LOCK_MINIMUM_VERSION, compareVersions, inspectBackendExecu
5
5
  import { assessedCheckpointContract, deliveryProgressContext, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
6
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
7
7
  import { JsonDataUrlFilter } from './json-data-url-filter.js';
8
+ import { RECENT_SESSION_LIMIT } from './discovery-window.js';
8
9
 
9
10
  const REQUEST_TIMEOUT = 15_000;
10
11
  const MAX_LINE = 256 * 1024;
@@ -187,14 +188,15 @@ export function codexThreadMetadata(thread) {
187
188
  };
188
189
  }
189
190
 
190
- export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = 200, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
191
+ export async function listCodexSessions({ client, maxPages = 40, pageSize = 25, maxSessions = RECENT_SESSION_LIMIT, inspectState = false, prioritySessions = [], observationBudgetMs = 3000 } = {}) {
191
192
  const sessions = [];
192
193
  const windowSize = Math.min(maxSessions, maxPages * pageSize);
193
194
  let cursor = null, listed = 0, pages = 0, batchSize = pageSize;
194
195
  while (pages < Math.max(maxPages, Math.ceil(windowSize / batchSize)) && listed < windowSize) {
195
196
  const limit = Math.min(batchSize, windowSize - listed);
196
197
  let response;
197
- try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS, archived: false }); }
198
+ try { response = await client.request('thread/list', { cursor, limit, sourceKinds: SOURCE_KINDS,
199
+ archived: false, sortKey: 'updated_at', sortDirection: 'desc' }); }
198
200
  catch (error) {
199
201
  // Keep the transport's byte limit. Retry the same cursor in smaller
200
202
  // batches rather than dropping discovery for large metadata envelopes.
@@ -0,0 +1,15 @@
1
+ import { TERMINAL } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
2
+
3
+ export const RECENT_SESSION_LIMIT = 200;
4
+
5
+ // Explicit tracking and unsettled work remain visible outside recent history.
6
+ // Completed, untracked records are history, not an unlimited extra catalog.
7
+ export const retainedSession = record => Boolean(record?.autoContinue || record?.monitor || record?.tracked ||
8
+ record?.run && !TERMINAL.has(record.run.state));
9
+
10
+ export function recentSessionWindow(items, { limit = RECENT_SESSION_LIMIT, priority = () => false } = {}) {
11
+ const recent = [...items].sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.session.localeCompare(b.session));
12
+ const retained = [], history = [];
13
+ for (const item of recent) (priority(item) ? retained : history).push(item);
14
+ return [...retained, ...history.slice(0, Math.max(0, limit - retained.length))];
15
+ }
@@ -5,6 +5,7 @@ import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServer
5
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
6
6
  import { originalSessionTarget } from './original-session-target.js';
7
7
  import { readCodexTitles, sessionTitle } from './session-titles.js';
8
+ import { RECENT_SESSION_LIMIT, recentSessionWindow, retainedSession } from './discovery-window.js';
8
9
 
9
10
  function autoContinueState(record, isRunning, supported) {
10
11
  if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
@@ -58,28 +59,34 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
58
59
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
59
60
  assertActive();
60
61
  const records = await listRecords(root);
62
+ const priorities = records.filter(retainedSession);
61
63
  let discovered = [], codexError = null, claude = [], claudeError = null;
62
64
  try { discovered = await withClient(openClient, client => listCodexSessions({ client, inspectState: true,
63
- prioritySessions: records.filter(record => record.provider === 'codex' && (record.autoContinue || record.monitor || record.tracked || record.run)).map(record => record.session) }), signal); }
65
+ maxSessions: RECENT_SESSION_LIMIT,
66
+ prioritySessions: priorities.filter(record => record.provider === 'codex').map(record => record.session) }), signal); }
64
67
  catch (error) { codexError = error?.code === 'unavailable' ? 'Codex executable unavailable.' : error?.code === 'unsupported' ? 'This Codex version lacks a verified app-server writer lock; update before enrollment.' : 'Codex conversation discovery unavailable.'; }
65
68
  assertActive();
66
- try { claude = await listClaude({ signal }); } catch { claudeError = 'Claude conversation discovery unavailable.'; }
69
+ try { claude = await listClaude({ signal, limit: RECENT_SESSION_LIMIT,
70
+ prioritySessions: priorities.filter(record => record.provider === 'claude').map(record => record.session) }); }
71
+ catch { claudeError = 'Claude conversation discovery unavailable.'; }
67
72
  assertActive();
68
73
  const byKey = new Map();
69
74
  const recordsByKey = new Map(records.map(record => [`${record.provider}:${record.session}`, record]));
70
75
  for (const item of [...discovered, ...claude]) byKey.set(`${item.provider}:${item.session}`, item);
71
76
  for (const record of records) {
72
77
  const key = `${record.provider}:${record.session}`;
73
- if (!byKey.has(key) && (record.observedAt || record.admission || record.monitor || record.run)) byKey.set(key, {
78
+ if (!byKey.has(key) && (record.observedAt || record.admission || retainedSession(record))) byKey.set(key, {
74
79
  provider: record.provider, session: record.session, cwd: record.cwd, title: '', source: record.admission?.source || 'hook',
75
80
  lastActivityAt: record.observedAt || record.admission?.validatedAt || record.run?.updatedAt || null,
76
81
  observation: { source: record.admission?.source || `${record.provider}_hook`, observedAt: record.observedAt || record.admission?.validatedAt || null },
77
82
  availability: 'unknown', model: record.admission?.model || null,
78
83
  });
79
84
  }
80
- // Historical sessions must not fan out hundreds of process-inspection
81
- // subprocesses. Preserve input ordering while limiting live metadata probes.
82
- const items = [...byKey.values()], projected = new Array(items.length);
85
+ // Select recent history before live probes, keeping explicit supervision
86
+ // visible even during a truncated discovery or provider outage.
87
+ const items = recentSessionWindow([...byKey.values()], {
88
+ priority: item => retainedSession(recordsByKey.get(`${item.provider}:${item.session}`)),
89
+ }), projected = new Array(items.length);
83
90
  const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
84
91
  assertActive();
85
92
  let nextItem = 0;
@@ -136,11 +143,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
136
143
  }));
137
144
  assertActive();
138
145
  projected.sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0));
139
- // Keep existing bindings visible during a truncated discovery or provider
140
- // outage, even when newer unbound conversations fill the candidate window.
141
- const bound = projected.filter(item => item.autoContinue || item.binding.mode !== 'unmanaged');
142
- const sessions = [...bound, ...projected.filter(item => !item.autoContinue && item.binding.mode === 'unmanaged').slice(0, Math.max(0, 200 - bound.length))];
143
- return { sessions, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
146
+ return { sessions: projected, providers: { codex: { discovery: codexError ? 'unavailable' : 'ready', reason: codexError }, claude: { discovery: claudeError ? 'unavailable' : 'local_index', reason: claudeError } }, refreshedAt: now };
144
147
  }
145
148
 
146
149
  // Explicit execution authority: legacy monitor/tracked records stay read-only.