@awak-app/simy-cli 0.5.66 → 0.5.68

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.66",
3
+ "version": "0.5.68",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -878,6 +878,10 @@ export async function startAgent({
878
878
  return (dependencies.runSqmLocalReview || ((value, selected, options) => runStructuredProviderReview(selected, value, options)))(request, provider, { beforeDispatch });
879
879
  },
880
880
  onChange: notifyDesktopStatusChange,
881
+ getWorkspaceUrl: () => {
882
+ const slug = desktopAccess.snapshot().organizationSlug;
883
+ return slug ? new URL(`/org/${encodeURIComponent(slug)}/quality-monitor/`, apiOrigin).toString() : null;
884
+ },
881
885
  getHistoryScope: () => sqmHistoryScope(apiOrigin, session),
882
886
  recordHistory: (scope, item) => sqmHistory.record(scope, item),
883
887
  onHistoryError: () => { if (!quiet) console.error("Could not save the local SQM analysis history."); },
@@ -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.66";
16
+ export const CLI_VERSION = "0.5.68";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -3,6 +3,7 @@ import { lstat, open, readdir, realpath } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
5
  import { boundConversation, CONVERSATION_LIMITS, textContent } from './conversation-context.js';
6
+ import { sessionTitle } from './session-titles.js';
6
7
 
7
8
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
9
  const MAX_PROJECTS = 256;
@@ -54,21 +55,41 @@ function metadataFromPrefix(bytes, id) {
54
55
  const text = bytes.toString('utf8');
55
56
  const complete = text.slice(0, text.lastIndexOf('\n') + 1);
56
57
  if (!complete) return null;
57
- let cwd = '', title = '', found = false;
58
+ let cwd = '', title = '', customTitle = '', found = false;
58
59
  for (const line of complete.split('\n').slice(0, MAX_LINES)) {
59
60
  if (!line) continue;
60
61
  let item;
61
62
  try { item = JSON.parse(line); } catch { return null; }
62
63
  if (item.sessionId !== id) return null;
64
+ if (item.type === 'custom-title' && item.isSidechain !== true) { customTitle = sessionTitle(item.customTitle); continue; }
63
65
  if (item.type !== 'user' && item.type !== 'assistant') continue;
64
66
  if (item.isSidechain === true) return null;
65
67
  if (typeof item.cwd !== 'string' || !isAbsolute(item.cwd)) continue;
66
68
  if (!cwd) cwd = resolve(item.cwd);
67
69
  if (cwd !== resolve(item.cwd)) return null;
68
- if (!title && typeof item.slug === 'string') title = item.slug.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 120);
70
+ if (!title) title = sessionTitle(item.slug);
69
71
  found = true;
70
72
  }
71
- return found ? { cwd, title } : null;
73
+ return found ? { cwd, title: customTitle || title } : null;
74
+ }
75
+
76
+ // /rename appends a custom-title envelope, often beyond the bounded prefix.
77
+ // Inspect only those envelopes; conversation text is never used as a title.
78
+ async function latestCustomTitle(handle, size, id) {
79
+ const offset = Math.max(0, size - 64 * 1024);
80
+ const buffer = Buffer.alloc(Math.min(size, 64 * 1024));
81
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
82
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
83
+ if (offset) text = text.slice(text.indexOf('\n') + 1);
84
+ text = text.slice(0, text.lastIndexOf('\n') + 1);
85
+ let title = '';
86
+ for (const line of text.split('\n')) {
87
+ try {
88
+ const item = JSON.parse(line);
89
+ if (item.type === 'custom-title' && item.sessionId === id && item.isSidechain !== true) title = sessionTitle(item.customTitle);
90
+ } catch { /* Ignore partial or unrelated tail entries. */ }
91
+ }
92
+ return title;
72
93
  }
73
94
 
74
95
  async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false) {
@@ -104,6 +125,7 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
104
125
  const buffer = Buffer.allocUnsafe(Math.min(opened.size, MAX_PREFIX_BYTES));
105
126
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
106
127
  const metadata = metadataFromPrefix(buffer.subarray(0, bytesRead), id);
128
+ if (metadata) metadata.title = await latestCustomTitle(handle, opened.size, id) || metadata.title;
107
129
  if (cacheMetadata) {
108
130
  const [after, pathAfter] = await Promise.all([handle.stat({ bigint: true }), lstat(path, { bigint: true })]);
109
131
  if (fingerprint(after) === key && fingerprint(pathAfter) === key && pathAfter.isFile() && !pathAfter.isSymbolicLink())
@@ -4,6 +4,7 @@ import { TERMINAL as TERMINAL_RUNS, controlDelivery, listRecords, readRecord, pr
4
4
  import { listCodexSessions, openCodexAppServer, readCodexSession, CodexAppServerError } from './codex-app-server.js';
5
5
  import { listClaudeSessions, readClaudeSession } from './claude-sessions.js';
6
6
  import { originalSessionTarget } from './original-session-target.js';
7
+ import { readCodexTitles, sessionTitle } from './session-titles.js';
7
8
 
8
9
  function autoContinueState(record, isRunning, supported) {
9
10
  if (record?.autoContinue !== true) return { autoContinueStatus: 'off', autoContinueReason: null };
@@ -55,7 +56,7 @@ async function liveClaudeHook(record, metadata, inspectProcess) {
55
56
  }
56
57
 
57
58
  export async function discoverSessions({ root, openClient = openCodexAppServer, listClaude = listClaudeSessions,
58
- inspectProcess = processIdentity, now = Date.now(), signal } = {}) {
59
+ inspectProcess = processIdentity, now = Date.now(), signal, listCodexTitles = readCodexTitles } = {}) {
59
60
  const assertActive = () => { if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation discovery was cancelled.'); };
60
61
  assertActive();
61
62
  const records = await listRecords(root);
@@ -81,6 +82,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
81
82
  // Historical sessions must not fan out hundreds of process-inspection
82
83
  // subprocesses. Preserve input ordering while limiting live metadata probes.
83
84
  const items = [...byKey.values()], projected = new Array(items.length);
85
+ const codexTitles = items.some(item => item.provider === 'codex' && !sessionTitle(item.title)) ? await listCodexTitles() : new Map();
86
+ assertActive();
84
87
  let nextItem = 0;
85
88
  const project = async item => {
86
89
  const record = recordsByKey.get(`${item.provider}:${item.session}`);
@@ -97,7 +100,8 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
97
100
  else if (isRunning === undefined && !record.runtime.isRunning) isRunning = false;
98
101
  } catch { /* Unverified process leaves runtime status unknown. */ }
99
102
  }
100
- return { provider: item.provider, session: item.session, cwd: item.cwd, title: item.title, source: item.source,
103
+ return { provider: item.provider, session: item.session, cwd: item.cwd,
104
+ title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
101
105
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
102
106
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
103
107
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
@@ -0,0 +1,48 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const MAX_INDEX_BYTES = 1024 * 1024;
8
+ let cached;
9
+ const fingerprint = info => [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
10
+
11
+ export function sessionTitle(value) {
12
+ return typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200) : '';
13
+ }
14
+
15
+ // Read title metadata only, including when an older CLI cannot list threads.
16
+ // This never reads prompts or changes the provider's continuation capabilities.
17
+ export async function readCodexTitles({ codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') } = {}) {
18
+ const path = join(codexHome, 'session_index.jsonl');
19
+ let handle;
20
+ try {
21
+ const before = await lstat(path, { bigint: true });
22
+ if (!before.isFile() || before.isSymbolicLink()) return new Map();
23
+ const key = fingerprint(before);
24
+ if (cached?.path === path && cached.key === key) return new Map(cached.titles);
25
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
26
+ if (fingerprint(await handle.stat({ bigint: true })) !== key) return new Map();
27
+ const size = Number(before.size), offset = Math.max(0, size - MAX_INDEX_BYTES);
28
+ const buffer = Buffer.alloc(Math.min(size, MAX_INDEX_BYTES));
29
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
30
+ let text = buffer.subarray(0, bytesRead).toString('utf8');
31
+ if (offset) text = text.slice(text.indexOf('\n') + 1);
32
+ text = text.slice(0, text.lastIndexOf('\n') + 1);
33
+ const titles = new Map();
34
+ for (const line of text.split('\n')) {
35
+ try {
36
+ const item = JSON.parse(line);
37
+ if (!UUID.test(item.id || '')) continue;
38
+ const title = sessionTitle(item.thread_name);
39
+ // Appended renames win, including an explicitly cleared title.
40
+ if (typeof item.thread_name === 'string') titles.set(item.id, title);
41
+ } catch { /* A broken entry does not hide other sessions. */ }
42
+ }
43
+ if (fingerprint(await handle.stat({ bigint: true })) !== key || fingerprint(await lstat(path, { bigint: true })) !== key) return new Map();
44
+ cached = { path, key, titles };
45
+ return new Map(titles);
46
+ } catch { return new Map(); }
47
+ finally { await handle?.close(); }
48
+ }
@@ -60,7 +60,7 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
60
60
  const item = (await this.items(scope)).find(item => item.key === key);
61
61
  if (!item) return null;
62
62
  return { key, instruction: item.instruction || `SQM local review · ${item.provider || "local"}`,
63
- result: item.result || "", error: item.state === "interrupted" ? "This client stopped before the local run finished. Check the web workspace for its current retry status." : item.error || "", links: [] };
63
+ result: item.result || "", error: item.state === "interrupted" ? "This client stopped before the local run finished. Check the web workspace for its current retry status." : item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] };
64
64
  },
65
65
  };
66
66
  }
@@ -69,7 +69,7 @@ function metadata(entry) {
69
69
  if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
70
70
  if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
71
71
  const item = {};
72
- for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error"]) {
72
+ for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "webUrl"]) {
73
73
  if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
74
74
  }
75
75
  return item;
@@ -18,11 +18,12 @@ const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
18
18
  export const SQM_CODEX_MODEL = "gpt-6.1-sol";
19
19
 
20
20
  export class SqmLocalReviewError extends Error {
21
- constructor(message, { status = null, code = "sqm_local_review_error" } = {}) {
21
+ constructor(message, { status = null, code = "sqm_local_review_error", stage = null, jobId = null, failureRecorded = false } = {}) {
22
22
  super(message);
23
23
  this.name = "SqmLocalReviewError";
24
24
  this.status = status;
25
25
  this.code = code;
26
+ this.stage = stage; this.jobId = jobId; this.failureRecorded = failureRecorded;
26
27
  }
27
28
  }
28
29
 
@@ -69,6 +70,7 @@ export function createSqmLocalReviewApi({
69
70
  throw new SqmLocalReviewError(webApiErrorMessage(payload, response.status), {
70
71
  status: response.status,
71
72
  code: machineCode(payload?.code || payload?.detail?.code),
73
+ stage: payload?.detail?.stage, jobId: payload?.detail?.job_id, failureRecorded: payload?.detail?.failure_recorded === true,
72
74
  });
73
75
  }
74
76
  return payload;
@@ -87,6 +89,7 @@ export function createSqmLocalReviewWorker({
87
89
  onError = () => {},
88
90
  onChange = () => {},
89
91
  getHistoryScope = () => null,
92
+ getWorkspaceUrl = () => null,
90
93
  recordHistory = async () => {},
91
94
  onHistoryError = () => {},
92
95
  } = {}) {
@@ -101,12 +104,13 @@ export function createSqmLocalReviewWorker({
101
104
  const persist = async (scope, item) => {
102
105
  try { await recordHistory(scope, item); } catch { onHistoryError(); /* History failure must not fail a leased review. */ }
103
106
  };
104
- const updateTask = async (id, state, scope) => {
107
+ const updateTask = async (id, state, scope, failure = null, failureReported = null) => {
105
108
  const item = history.find(item => item.id === id);
106
109
  if (item) {
107
110
  item.state = state; item.finishedAt = new Date().toISOString();
108
111
  item.result = state === "completed" ? "SQM review submitted." : "";
109
- item.error = state === "failed" ? "Local review failed. Check the SQM workspace for retry status." : "";
112
+ item.error = state === "failed" ? formatFailure(failure, failureReported) : "";
113
+ if (failure) { item.phase = failure.stage; item.errorCode = failure.code; }
110
114
  await persist(scope, item);
111
115
  }
112
116
  onChange();
@@ -120,13 +124,12 @@ export function createSqmLocalReviewWorker({
120
124
  lease_token: claim.lease_token,
121
125
  });
122
126
 
123
- const processClaim = async (claim, provider) => {
124
- const historyScope = getHistoryScope();
127
+ const processClaim = async (claim, provider, historyScope, workspaceUrl) => {
125
128
  activeJobId = claim.job_id;
126
129
  const priorIndex = history.findIndex(item => item.id === claim.job_id);
127
130
  if (priorIndex >= 0) history.splice(priorIndex, 1);
128
131
  history.unshift({ id: claim.job_id, key: `sqm:${claim.job_id}`, label: `SQM · ${String(claim.job_id).slice(0, 8)}`,
129
- kind: "analysis", state: "running", provider, startedAt: new Date().toISOString() });
132
+ kind: "analysis", state: "running", provider, startedAt: new Date().toISOString(), webUrl: analysisTaskUrl(workspaceUrl, claim.job_id) });
130
133
  historyScopes.set(history[0], JSON.stringify(historyScope));
131
134
  history.splice(50);
132
135
  await persist(historyScope, history[0]);
@@ -137,8 +140,10 @@ export function createSqmLocalReviewWorker({
137
140
  };
138
141
  heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
139
142
  heartbeatTimer.unref?.();
143
+ let stage = "analysis";
140
144
  try {
141
145
  const analysis = await runReview(claim.request, provider);
146
+ stage = "advance";
142
147
  const advanced = await api.request({
143
148
  action: "advance",
144
149
  ...leaseBody(claim, provider),
@@ -146,7 +151,9 @@ export function createSqmLocalReviewWorker({
146
151
  executor_model: analysis.model,
147
152
  });
148
153
  if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
154
+ stage = "adjudication";
149
155
  const adjudication = await runReview(advanced.request, provider);
156
+ stage = "complete";
150
157
  const completed = await api.request({
151
158
  action: "complete",
152
159
  ...leaseBody(claim, provider),
@@ -157,7 +164,16 @@ export function createSqmLocalReviewWorker({
157
164
  await updateTask(claim.job_id, "completed", historyScope);
158
165
  return completed;
159
166
  } catch (error) {
160
- await updateTask(claim.job_id, "failed", historyScope);
167
+ const failure = reviewFailure(error, stage, provider);
168
+ let failureReported = error.failureRecorded === true;
169
+ if (!failureReported) {
170
+ try {
171
+ const report = await api.request({ action: "fail", ...leaseBody(claim, provider),
172
+ error_stage: failure.stage, error_code: failure.code, ...(failure.status ? { error_status: failure.status } : {}) });
173
+ failureReported = report?.recorded === true;
174
+ } catch { /* Keep the original failure even when reporting fails. */ }
175
+ }
176
+ await updateTask(claim.job_id, "failed", historyScope, failure, failureReported);
161
177
  throw error;
162
178
  } finally {
163
179
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -173,7 +189,7 @@ export function createSqmLocalReviewWorker({
173
189
  desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
174
190
  desktopTaskDetail(key) {
175
191
  const item = visibleHistory().find(item => item.key === key);
176
- return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.state === "failed" ? "Local review failed. Check the SQM workspace for retry status." : "", links: [] } : null;
192
+ return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
177
193
  },
178
194
  resetHistory() { if (!pollPromise) { history.splice(0); issue = null; } },
179
195
  poll() {
@@ -183,6 +199,8 @@ export function createSqmLocalReviewWorker({
183
199
  assertProvider(provider);
184
200
  const releaseWork = beginWork?.();
185
201
  if (!releaseWork) return Promise.resolve(null);
202
+ const historyScope = getHistoryScope();
203
+ const workspaceUrl = getWorkspaceUrl();
186
204
  pollPromise = (async () => {
187
205
  const claim = await api.request({
188
206
  action: "claim",
@@ -192,9 +210,18 @@ export function createSqmLocalReviewWorker({
192
210
  });
193
211
  if (issue) { issue = null; onChange(); }
194
212
  if (!claim?.claimed) return null;
195
- return processClaim(claim, provider);
213
+ return processClaim(claim, provider, historyScope, workspaceUrl);
196
214
  })()
197
- .catch((error) => {
215
+ .catch(async (error) => {
216
+ if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
217
+ const scope = historyScope;
218
+ const failure = reviewFailure(error, "claim", provider);
219
+ const item = { id: error.jobId, key: `sqm:${error.jobId}`, label: `SQM · ${error.jobId.slice(0, 8)}`,
220
+ kind: "analysis", state: "failed", provider, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(),
221
+ phase: failure.stage, errorCode: failure.code, error: formatFailure(failure, error.failureRecorded === true), webUrl: analysisTaskUrl(workspaceUrl, error.jobId) };
222
+ history.unshift(item); historyScopes.set(item, JSON.stringify(scope)); history.splice(50);
223
+ await persist(scope, item);
224
+ }
198
225
  issue = error.status === 403 ? "forbidden" : error.status === 401 ? "signed_out" : "unavailable";
199
226
  onError(error);
200
227
  return null;
@@ -453,3 +480,38 @@ function assertProvider(provider) {
453
480
  throw new TypeError("Unsupported SQM local review provider");
454
481
  }
455
482
  }
483
+
484
+ function reviewFailure(error, stage, provider) {
485
+ let code = machineCode(error?.code);
486
+ const message = errorMessage(error);
487
+ if (code === "sqm_local_review_error" || code === `${provider}_local_review_failed`) {
488
+ if (/timed out|timeout/i.test(message)) code = "analysis_timeout";
489
+ else if (providerSignInRequired(message)) code = "provider_sign_in_required";
490
+ else if (stage === "analysis" || stage === "adjudication") code = `${provider}_local_review_failed`;
491
+ }
492
+ return { stage: /^[a-z_]{1,40}$/.test(error?.stage || "") ? error.stage : stage, code,
493
+ status: Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599 ? error.status : null };
494
+ }
495
+
496
+ function formatFailure(failure, failureReported = null) {
497
+ if (!failure) return "SQM analysis failed. No failure diagnostics were recorded.";
498
+ const hint = failure.code === "provider_sign_in_required" ? "Sign in to the local AI app again."
499
+ : failure.code === "analysis_timeout" ? "The local AI analysis exceeded its time limit."
500
+ : failure.code === "sqm_source_loading_failed" ? "SIMY could not load evidence for this analysis."
501
+ : failure.status === 403 || failure.code === "desktop_access_required" ? "Check SQM and Developer access for the selected organization."
502
+ : failure.status === 401 ? "Reconnect your SIMY session."
503
+ : failure.code.endsWith("_local_unavailable") ? "Install or update the selected local AI app."
504
+ : failure.code.endsWith("_local_review_failed") ? "The local AI app did not complete a valid structured review."
505
+ : "Open analysis details to inspect the task state.";
506
+ return `${failure.stage} · ${failure.code}${failure.status ? ` · HTTP ${failure.status}` : ""}\n${hint}${failureReported === false ? "\nThe failure could not be reported to SIMY Web. The web task may still show as analyzing until its lease expires." : ""}`;
507
+ }
508
+
509
+ function analysisTaskUrl(workspaceUrl, id) {
510
+ if (!workspaceUrl || !/^[a-f0-9-]{36}$/i.test(id)) return "";
511
+ try {
512
+ const url = new URL(workspaceUrl);
513
+ if (!/^https?:$/.test(url.protocol)) return "";
514
+ url.searchParams.set("qualityTab", "analysis"); url.searchParams.set("qualityAnalysis", id);
515
+ return url.toString();
516
+ } catch { return ""; }
517
+ }