@awak-app/simy-cli 0.5.39 → 0.5.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.39",
3
+ "version": "0.5.40",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.5.39";
16
+ export const CLI_VERSION = "0.5.40";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -310,7 +310,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
310
310
  const context = await (dependencies.readContinuationContext || readContinuationContext)(candidate);
311
311
  // Provider activity timestamps can have one-second precision. Re-read
312
312
  // the bounded context before enrollment to reject same-second edits.
313
- if (context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
313
+ if (context.awaitingTurn || context.incomplete || !recommendation.contextDigest || context.contextDigest !== recommendation.contextDigest) {
314
314
  await manager.invalidate(candidate);
315
315
  throw new Error('This conversation changed; wait for its updated analysis.');
316
316
  }
@@ -9,7 +9,7 @@ const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting',
9
9
  const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
10
10
  const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
11
11
  export const continuationAnalysisRevision = candidate => createHash('sha256')
12
- .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate)]))
12
+ .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate), ...(candidate.isRunning === true ? ['running'] : [])]))
13
13
  .digest('hex');
14
14
  const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
15
15
  ['unmanaged', 'monitor'].includes(candidate.binding?.mode || 'unmanaged');
@@ -60,15 +60,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
60
60
  } finally { await rm(temporary, { force: true }); }
61
61
  };
62
62
  const changed = () => { void Promise.resolve().then(onChange).catch(() => {}); };
63
- const enqueue = (candidate, force = false) => {
63
+ const enqueue = (candidate, force = false, forceAnalysis = false) => {
64
64
  const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate);
65
65
  const entry = entries.get(key);
66
66
  if (entry?.revision === revision && (['queued', 'analyzing'].includes(entry.result?.state) ||
67
67
  !force && !['failed', 'uncertain'].includes(entry.result?.state))) return false;
68
68
  entries.delete(key);
69
- entries.set(key, { revision, result: { state: 'queued' } });
69
+ entries.set(key, { revision, cwd: candidate.cwd, forceAnalysis, settledResult: entry?.cwd === candidate.cwd ? entry.settledResult : null,
70
+ result: { state: 'queued' } });
70
71
  return true;
71
72
  };
73
+ const finishedAnalysis = (result, previous) => result.contextDigest && result.settledTurnId
74
+ ? { ...result, awaitingTurn: false, analyzedAt: result.contextDigest === previous?.contextDigest ? previous.analyzedAt : now() }
75
+ : previous;
72
76
  const startDrain = () => {
73
77
  if (closed || draining) return;
74
78
  draining = (async () => {
@@ -80,7 +84,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
80
84
  if (entry.result?.state !== 'queued' || !candidate || !eligible(candidate) || entry.revision !== continuationAnalysisRevision(candidate)) continue;
81
85
  entry.result = { state: 'analyzing' };
82
86
  await persist(); changed();
83
- return { key, revision: entry.revision, candidate };
87
+ return { key, revision: entry.revision, candidate, previousAnalysis: entry.forceAnalysis ? null : entry.settledResult };
84
88
  }
85
89
  return null;
86
90
  });
@@ -96,7 +100,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
96
100
  });
97
101
  let result, inference;
98
102
  try {
99
- inference = Promise.resolve(analyze(selected.candidate, { signal }));
103
+ inference = Promise.resolve(analyze(selected.candidate, { signal, previousAnalysis: selected.previousAnalysis }));
100
104
  result = await Promise.race([inference, cancellation]);
101
105
  if (!RESULT_STATES.has(result?.state) || typeof result.reason !== 'string' ||
102
106
  (result.state === 'continue' && (!result.objective || !result.remaining?.length || !result.nextAction || !result.basis?.some(item => item.role === 'user')))) {
@@ -114,12 +118,22 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
114
118
  const current = candidates.get(selected.key), entry = entries.get(selected.key);
115
119
  if (closed) return;
116
120
  if (!current || !eligible(current) || continuationAnalysisRevision(current) !== selected.revision || entry?.revision !== selected.revision) {
121
+ // Live timestamps may advance during inference while the completed
122
+ // boundary stays identical. Retain that snapshot for display/reuse;
123
+ // never publish it as a current actionable recommendation.
124
+ if (current && eligible(current) && current.cwd === selected.candidate.cwd && entry?.cwd === current.cwd &&
125
+ result.contextDigest && result.settledTurnId && ['queued', 'analyzing'].includes(entry.result?.state)) {
126
+ entry.settledResult = finishedAnalysis(result, selected.previousAnalysis);
127
+ await persist(); changed();
128
+ }
117
129
  if (entry?.revision === selected.revision && entry.result?.state === 'analyzing') {
118
130
  entry.result = { state: 'queued' }; await persist(); changed();
119
131
  }
120
132
  return;
121
133
  }
122
- entries.set(selected.key, { revision: selected.revision, result: { ...result, analyzedAt: now() } });
134
+ const settledResult = finishedAnalysis(result, selected.previousAnalysis || entry.settledResult);
135
+ entries.set(selected.key, { revision: selected.revision, cwd: current.cwd, settledResult,
136
+ result: { ...result, analyzedAt: settledResult?.analyzedAt || now() } });
123
137
  await persist(); changed();
124
138
  });
125
139
  }
@@ -141,10 +155,21 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
141
155
  for (const candidate of sorted) {
142
156
  const key = keyOf(candidate), revision = continuationAnalysisRevision(candidate), previous = known.get(key);
143
157
  const active = activityOf(candidate);
144
- const newActivity = document.initialized && (previous ? active > previous.activity : active > document.baselineAt);
145
- if (eligible(candidate) && (backfill.has(key) || newActivity)) dirty = enqueue(candidate) || dirty;
158
+ const newActivity = document.initialized && (previous ? active > previous.activity || (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
159
+ const entry = entries.get(key);
160
+ // Migrate prior idle cache entries without treating historical sessions
161
+ // as fresh activity. Its old revision already proves this exact cwd.
162
+ if (entry && !entry.cwd && entry.revision === continuationAnalysisRevision({ ...candidate, isRunning: false })) {
163
+ entry.cwd = candidate.cwd;
164
+ entry.settledResult = entry.result?.contextDigest ? entry.result : null;
165
+ dirty = true;
166
+ }
167
+ // A reader that raced a terminal boundary (or a provider without live
168
+ // status) is retried at discovery cadence, never a busy inference loop.
169
+ const recheck = entry?.result?.reasonCode === 'turn_settling' || entry?.result?.awaitingTurn === true && candidate.isRunning !== true;
170
+ if (eligible(candidate) && (backfill.has(key) || newActivity || recheck)) dirty = enqueue(candidate, recheck) || dirty;
146
171
  if (previous?.revision !== revision) {
147
- known.delete(key); known.set(key, { revision, activity: active }); dirty = true;
172
+ known.delete(key); known.set(key, { revision, activity: active, isRunning: candidate.isRunning }); dirty = true;
148
173
  }
149
174
  }
150
175
  // Empty discovery during provider startup must not consume backfill.
@@ -155,14 +180,23 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
155
180
  },
156
181
  project(candidate) {
157
182
  const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
158
- return { ...candidate, analysis: entry?.revision === revision ? { ...entry.result, revision } : { state: 'unanalysed', revision } };
183
+ let analysis = entry?.revision === revision ? { ...entry.result, revision } : { state: 'unanalysed', revision };
184
+ const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
185
+ if (candidate.isRunning === true || analysis.awaitingTurn === true) {
186
+ analysis = { state: 'waiting', awaitingTurn: true, revision,
187
+ reason: 'This turn is still running. SIMY analyzes only finished turns and will update automatically when it ends.',
188
+ ...(previous ? { previousAnalysis: previous } : {}) };
189
+ } else if (previous && ['queued', 'analyzing'].includes(analysis.state)) {
190
+ analysis.previousAnalysis = previous;
191
+ }
192
+ return { ...candidate, analysis };
159
193
  },
160
194
  async request(candidate) {
161
195
  if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
162
196
  await exclusive(async () => {
163
197
  if (closed) throw new Error('Session analysis is unavailable.');
164
198
  candidates.set(keyOf(candidate), candidate);
165
- if (enqueue(candidate, true)) { await persist(); changed(); }
199
+ if (enqueue(candidate, true, true)) { await persist(); changed(); }
166
200
  });
167
201
  startDrain();
168
202
  },
@@ -178,7 +212,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
178
212
  requireRecommendation(candidate) {
179
213
  if (closed) throw new Error('Session analysis is unavailable.');
180
214
  const analysis = this.project(candidate).analysis;
181
- if (!eligible(candidate) || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
215
+ if (!eligible(candidate) || candidate.isRunning === true || analysis.awaitingTurn === true || analysis.state !== 'continue') throw new Error('Analyze this conversation before continuing; it needs a current recommendation.');
182
216
  return analysis;
183
217
  },
184
218
  async whenIdle() { do { await serial; await draining; } while (draining); await serial; },
@@ -152,6 +152,7 @@ export function codexThreadMetadata(thread) {
152
152
  lastActivityAt: Number.isFinite(thread.updatedAt) ? thread.updatedAt * 1000 : null,
153
153
  model: typeof thread.model === 'string' ? thread.model : null,
154
154
  observation: { source: 'codex_app_server', observedAt: Date.now() },
155
+ ...(thread.status?.type === 'active' || thread.status?.type === 'idle' ? { isRunning: thread.status.type === 'active' } : {}),
155
156
  availability: 'unknown',
156
157
  };
157
158
  }
@@ -188,16 +189,14 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
188
189
  // reads only metadata and a fixed number of recent display summaries.
189
190
  const metadata = await readCodexSession(client, candidate.session);
190
191
  if (metadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
191
- if (Number.isFinite(candidate.lastActivityAt) && metadata.lastActivityAt !== candidate.lastActivityAt) {
192
- throw new CodexAppServerError('revision_changed', 'Conversation changed since discovery.');
193
- }
194
192
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
193
+ const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status);
195
194
  const turns = [];
196
195
  const turnIds = new Set(), cursors = new Set();
197
196
  let cursor = null;
198
197
  do {
199
198
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
200
- const limit = Math.min(ANALYSIS_PAGE_SIZE, ANALYSIS_MAX_TURNS - turns.length);
199
+ const limit = Math.min(ANALYSIS_PAGE_SIZE, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
201
200
  let page;
202
201
  try {
203
202
  page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
@@ -217,10 +216,26 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
217
216
  cursor = page.nextCursor || null;
218
217
  if (cursor && (!page.data.length || cursors.has(cursor))) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
219
218
  if (cursor) cursors.add(cursor);
220
- } while (cursor && turns.length < ANALYSIS_MAX_TURNS);
219
+ } while (cursor && turns.filter(terminal).length < ANALYSIS_MAX_TURNS && turns.length < ANALYSIS_MAX_TURNS + ANALYSIS_PAGE_SIZE);
220
+ // A timestamp covers the whole live thread. Instead pin the last terminal
221
+ // turn and check it again: streaming output in the newer turn is irrelevant
222
+ // to this read-only snapshot, but a newly settled turn must invalidate it.
223
+ const settled = turns.filter(terminal).slice(0, ANALYSIS_MAX_TURNS);
224
+ const boundary = settled[0];
225
+ const verification = await client.request('thread/turns/list', {
226
+ threadId: candidate.session, cursor: null, limit: ANALYSIS_PAGE_SIZE, sortDirection: 'desc', itemsView: 'summary',
227
+ });
228
+ if (!Array.isArray(verification?.data) || verification.data.length > ANALYSIS_PAGE_SIZE) {
229
+ throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid snapshot check.');
230
+ }
231
+ const latestBoundary = verification.data.find(terminal);
232
+ // Compare the immutable settled summary, never the running turn's prose.
233
+ if (JSON.stringify(boundary) !== JSON.stringify(latestBoundary)) {
234
+ throw new CodexAppServerError('turn_settling', 'The latest turn settled during the snapshot read.');
235
+ }
221
236
  const latestMetadata = await readCodexSession(client, candidate.session);
222
237
  if (latestMetadata.cwd !== candidate.cwd) throw new CodexAppServerError('identity', 'Exact conversation identity is unavailable.');
223
- if (latestMetadata.lastActivityAt !== metadata.lastActivityAt || latestMetadata.model !== metadata.model) {
238
+ if (latestMetadata.model !== metadata.model) {
224
239
  throw new CodexAppServerError('revision_changed', 'Conversation changed while reading recent history.');
225
240
  }
226
241
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
@@ -228,13 +243,13 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
228
243
  // A display summary omits intermediate reasoning/tool context even when
229
244
  // every turn fits. Never represent it as the complete conversation; the
230
245
  // classifier must establish a self-contained objective from retained text.
231
- let incomplete = !turns.length, truncated = true;
232
- for (const [index, turn] of turns.toReversed().entries()) {
233
- if (['inProgress', 'in_progress'].includes(turn?.status)) incomplete = true;
234
- if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === turns[0]) incomplete = true; continue; }
246
+ const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verification.data].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
247
+ let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !['inProgress', 'in_progress'].includes(turn?.status)), truncated = true;
248
+ for (const [index, turn] of settled.toReversed().entries()) {
249
+ if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
235
250
  if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
236
251
  if (!Array.isArray(turn?.items)) { incomplete = true; continue; }
237
- if (turn === turns[0] && !turn.items.length) incomplete = true;
252
+ if (turn === boundary && !turn.items.length) incomplete = true;
238
253
  for (const [itemIndex, item] of turn.items.entries()) {
239
254
  const role = item?.type === 'userMessage' ? 'user' : item?.type === 'agentMessage' ? 'assistant' : null;
240
255
  if (!role) continue;
@@ -243,7 +258,7 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
243
258
  messages.push({ role, turnRef: `${turn.id || index}:${item.id || itemIndex}`, text });
244
259
  }
245
260
  }
246
- return boundConversation(messages, { incomplete, truncated });
261
+ return { ...boundConversation(messages, { incomplete, truncated }), awaitingTurn, settledTurnId: boundary?.id || null };
247
262
  } finally {
248
263
  signal?.removeEventListener('abort', cancelled);
249
264
  await client.close();
@@ -45,6 +45,10 @@ function uncertain(reason) {
45
45
  }
46
46
 
47
47
  function unreadableContext(error) {
48
+ if (error?.code === 'turn_settling') return {
49
+ state: 'waiting', reason: 'The current turn is finishing. SIMY will check its completed result automatically.',
50
+ reasonCode: 'turn_settling', awaitingTurn: true, objective: '', remaining: [], nextAction: null, basis: [],
51
+ };
48
52
  // Provider error messages can include private paths or transcript text. Only
49
53
  // these known codes select product copy; never forward the original message.
50
54
  const reasons = {
@@ -105,11 +109,11 @@ export async function readContinuationContext(candidate, { signal, readContext,
105
109
  if (signal?.aborted) throw new Error('Conversation analysis was cancelled.');
106
110
  const context = boundConversation(raw?.messages, { truncated: raw?.truncated === true, incomplete: raw?.incomplete === true });
107
111
  const contextDigest = createHash('sha256').update(JSON.stringify({ provider: candidate.provider, session: candidate.session, cwd: candidate.cwd, ...context })).digest('hex');
108
- return { ...context, contextDigest };
112
+ return { ...context, contextDigest, awaitingTurn: raw?.awaitingTurn === true, settledTurnId: typeof raw?.settledTurnId === 'string' ? raw.settledTurnId.slice(0, 160) : null };
109
113
  }
110
114
 
111
115
  export async function analyzeContinuation(candidate, {
112
- signal, readContext, openCodexClient, claudeHome,
116
+ signal, readContext, openCodexClient, claudeHome, previousAnalysis,
113
117
  runProvider = runStructuredProviderReview, providerOptions = {},
114
118
  } = {}) {
115
119
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
@@ -121,6 +125,14 @@ export async function analyzeContinuation(candidate, {
121
125
  context = await readContinuationContext(candidate, { signal, readContext, openCodexClient, claudeHome });
122
126
  } catch (error) { return unreadableContext(error); }
123
127
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
128
+ const snapshot = { contextDigest: context.contextDigest, awaitingTurn: context.awaitingTurn, settledTurnId: context.settledTurnId };
129
+ if (context.awaitingTurn && !context.messages.length) return {
130
+ state: 'waiting', reason: 'The first turn is still running. SIMY will analyze it after it finishes.', ...snapshot,
131
+ objective: '', remaining: [], nextAction: null, basis: [],
132
+ };
133
+ if (previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
134
+ return { ...previousAnalysis, ...snapshot };
135
+ }
124
136
  if (context.incomplete || !context.messages.length) return uncertain('The latest conversation request or response is missing or incomplete.');
125
137
  try {
126
138
  const result = await runProvider(candidate.provider, {
@@ -129,7 +141,7 @@ export async function analyzeContinuation(candidate, {
129
141
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
130
142
  const analysis = validateContinuationAnalysis(result?.output, context);
131
143
  const model = typeof result?.model === 'string' && /^[a-zA-Z0-9._/-]{1,120}$/.test(result.model) ? result.model : null;
132
- return { ...analysis, contextDigest: context.contextDigest, ...(model ? { model } : {}) };
144
+ return { ...analysis, ...snapshot, ...(model ? { model } : {}) };
133
145
  } catch {
134
146
  // Provider stderr/errors may contain transcript text or credentials. Never
135
147
  // expose them to the desktop, cache, diagnostics or control plane.
@@ -56,6 +56,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
56
56
  const supported = item.provider === 'codex' && item.observation.source === 'codex_app_server' && Boolean(item.model) && !codexError;
57
57
  const liveHook = item.provider === 'claude' && !claudeError && await liveClaudeHook(record, item, inspectProcess);
58
58
  return { provider: item.provider, session: item.session, cwd: item.cwd, title: item.title, source: item.source,
59
+ ...(typeof item.isRunning === 'boolean' ? { isRunning: item.isRunning } : {}),
59
60
  lastActivityAt: item.lastActivityAt, observation: item.observation, availability: item.availability,
60
61
  binding: { mode, taskId: run?.managed?.taskId || null },
61
62
  originalSessionTarget: originalSessionTarget(item.provider, item.session),