@awak-app/simy-cli 0.5.24 → 0.5.26

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
@@ -5,20 +5,26 @@ Local execution agent for SIMY Local Tasks and Agentic Loop runs.
5
5
  ## Apple Silicon Mac app
6
6
 
7
7
  The macOS installer includes a native status window and menu bar app. Open
8
- SIMY CLI from Applications and choose **Sign in**. The window shows SIMY login,
8
+ SIMY from Applications and choose **Sign in**. The window shows SIMY login,
9
9
  the connected Web address and last successful heartbeat, Codex installation and
10
10
  login status, tasks, and updates separately. A Codex login check does not prove
11
11
  that a task can execute successfully; permissions and service availability are
12
12
  checked when work starts.
13
13
 
14
14
  Closing the window keeps the agent running. Open the app again or choose
15
- **Show SIMY CLI** from its menu bar icon to return. **Quit SIMY CLI** asks before
15
+ **Show SIMY** from its menu bar icon to return. **Quit SIMY** asks before
16
16
  interrupting active tasks. The normal `simy` command remains available.
17
17
 
18
18
  Signed patch/minor updates wait for idle and verify the new runtime before
19
19
  handoff; failed startup restores the previous installed version. After a runtime
20
- update, quit and reopen the app to load an updated native interface. The status
21
- window reports App and runtime versions separately while they differ.
20
+ update, choose **Restart to update** to load the updated native interface. This
21
+ action is available only when tasks are idle; the service checks again before
22
+ quitting. The window reports App and runtime versions separately while they
23
+ differ, and normal app launches select the newer installed interface.
24
+
25
+ Older installers that predate native app relaunch support need a one-time
26
+ reinstallation from the official download page to update their Applications
27
+ entry. Updating only their background runtime does not replace that entry.
22
28
 
23
29
  For development, `npm run build:macos-installer:dev` creates an unsigned app/DMG;
24
30
  production builds require Developer ID signing and notarization. Never publish
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.24",
3
+ "version": "0.5.26",
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
@@ -141,6 +141,8 @@ export async function startAgent({
141
141
  webOrigin = null,
142
142
  sessionRoot,
143
143
  updateManager = null,
144
+ deferStartupSynchronization = false,
145
+ onDesktopStatusChange = null,
144
146
  dependencies = {},
145
147
  quiet = false,
146
148
  } = {}) {
@@ -188,7 +190,10 @@ export async function startAgent({
188
190
  : [],
189
191
  );
190
192
  const installMode = updateManager?.snapshot?.()?.install_mode ?? null;
191
- let contractSourceCapabilities = daemonCapabilities(await readCapabilities(dependencies), daemon);
193
+ let contractSourceCapabilities = daemonCapabilities(
194
+ deferStartupSynchronization ? unverifiedCapabilities() : await readCapabilities(dependencies),
195
+ daemon,
196
+ );
192
197
  let availableCapabilities;
193
198
  let providerStatuses = { codex: { state: "unknown" }, claude: { state: "unknown" } };
194
199
  const refreshAvailableCapabilities = () => {
@@ -212,6 +217,11 @@ export async function startAgent({
212
217
  let port = requestedPort;
213
218
  let heartbeatTimer = null;
214
219
  let heartbeatInFlight = null;
220
+ let shuttingDown = false;
221
+ const notifyDesktopStatusChange = () => {
222
+ if (shuttingDown) return;
223
+ try { onDesktopStatusChange?.(); } catch { /* Desktop status polling remains available. */ }
224
+ };
215
225
  let durableLocalTaskWorker = null;
216
226
  let sqmLocalReviewWorker = null;
217
227
  let loginUrl = null;
@@ -226,19 +236,31 @@ export async function startAgent({
226
236
  now: dependencies.statusNowMs,
227
237
  });
228
238
 
229
- const readProviderStatuses = async ({ force = false } = {}) => {
230
- const [codex, claude] = await Promise.all([codexStatus({ force }), claudeStatus({ force })]);
231
- const binaryStateChanged = ["codex", "claude"].some((provider) => {
232
- const next = provider === "codex" ? codex : claude;
233
- return providerStatuses[provider]?.version !== next.version ||
234
- providerStatuses[provider]?.state !== next.state;
235
- });
236
- if (force || binaryStateChanged) {
237
- contractSourceCapabilities = daemonCapabilities(await readCapabilities(dependencies), daemon);
238
- }
239
- providerStatuses = { codex, claude };
240
- refreshAvailableCapabilities();
241
- return providerStatuses;
239
+ let providerReadInFlight = null;
240
+ const readProviderStatuses = ({ force = false } = {}) => {
241
+ if (providerReadInFlight) return providerReadInFlight;
242
+ providerReadInFlight = (async () => {
243
+ const previous = providerStatuses;
244
+ await Promise.all(["codex", "claude"].map(async (provider) => {
245
+ const next = await (provider === "codex" ? codexStatus : claudeStatus)({ force });
246
+ // Publish each verified result independently. A slow Claude probe must
247
+ // not keep a completed Codex result hidden (or vice versa).
248
+ providerStatuses = { ...providerStatuses, [provider]: next };
249
+ refreshAvailableCapabilities();
250
+ if (previous[provider]?.version !== next.version || previous[provider]?.state !== next.state) {
251
+ notifyDesktopStatusChange();
252
+ }
253
+ }));
254
+ const binaryStateChanged = ["codex", "claude"].some((provider) =>
255
+ previous[provider]?.version !== providerStatuses[provider]?.version ||
256
+ previous[provider]?.state !== providerStatuses[provider]?.state);
257
+ if (!shuttingDown && (force || binaryStateChanged)) {
258
+ contractSourceCapabilities = daemonCapabilities(await readCapabilities(dependencies), daemon);
259
+ refreshAvailableCapabilities();
260
+ }
261
+ return providerStatuses;
262
+ })().finally(() => { providerReadInFlight = null; });
263
+ return providerReadInFlight;
242
264
  };
243
265
 
244
266
  const inspectReadyBackend = async (backend, options) => {
@@ -275,7 +297,7 @@ export async function startAgent({
275
297
  };
276
298
 
277
299
  const restoreRemoteRuns = async () => {
278
- if (!isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
300
+ if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin) || !session?.device_id) return;
279
301
  const snapshots = dependencies.listRemoteRuns
280
302
  ? await dependencies.listRemoteRuns({
281
303
  apiOrigin,
@@ -287,7 +309,9 @@ export async function startAgent({
287
309
  apiBaseUrl: session.api_base_url,
288
310
  token: session.token,
289
311
  });
312
+ let restored = false;
290
313
  for (const snapshot of snapshots) {
314
+ if (shuttingDown) return;
291
315
  if (!snapshot?.id || registry.has(snapshot.id)) continue;
292
316
  const repository = findRepository(repositoryInventory, snapshot.charter?.repository);
293
317
  const restoredRun = restoreRun({
@@ -297,14 +321,17 @@ export async function startAgent({
297
321
  localPath: repository?.local_path || null,
298
322
  });
299
323
  registry.create(restoredRun);
324
+ restored = true;
300
325
  await publishRestoredLocalCodingRun(restoredRun);
301
326
  }
327
+ if (restored) notifyDesktopStatusChange();
302
328
  };
303
329
 
304
330
  const heartbeat = () => {
305
- if (!isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
331
+ if (shuttingDown || !isSessionValid(session, Date.now(), apiOrigin)) return Promise.resolve(false);
306
332
  if (heartbeatInFlight) return heartbeatInFlight;
307
333
  connectionStatus.attempt();
334
+ notifyDesktopStatusChange();
308
335
  let deviceHeartbeatSucceeded = false;
309
336
  const advertisedProviders = JSON.stringify(providerStatuses);
310
337
  heartbeatInFlight = Promise.resolve()
@@ -319,24 +346,36 @@ export async function startAgent({
319
346
  }),
320
347
  )
321
348
  .then(async () => {
349
+ if (shuttingDown) return false;
322
350
  deviceHeartbeatSucceeded = true;
323
351
  connectionStatus.success();
352
+ notifyDesktopStatusChange();
324
353
  // Register the device immediately; provider subprocesses must not delay
325
354
  // browser authorization. Publish verified readiness after their probe.
326
- await readProviderStatuses();
327
- if (JSON.stringify(providerStatuses) !== advertisedProviders) {
328
- await heartbeatDevice({ apiOrigin, apiBaseUrl: session.api_base_url, token: session.token,
329
- port, capabilities: refreshAvailableCapabilities(), repoInventory: repositoryInventory });
355
+ const publishVerifiedProviders = async () => {
356
+ await readProviderStatuses();
357
+ if (!shuttingDown && JSON.stringify(providerStatuses) !== advertisedProviders) {
358
+ await heartbeatDevice({ apiOrigin, apiBaseUrl: session.api_base_url, token: session.token,
359
+ port, capabilities: refreshAvailableCapabilities(), repoInventory: repositoryInventory });
360
+ }
361
+ };
362
+ if (deferStartupSynchronization) {
363
+ void publishVerifiedProviders().catch((error) => reportHeartbeatError(error, quiet));
364
+ } else {
365
+ await publishVerifiedProviders();
330
366
  }
367
+ if (shuttingDown) return false;
331
368
  const sqmResult = sqmLocalReviewWorker
332
369
  ? await sqmLocalReviewWorker.poll()
333
370
  : null;
371
+ if (shuttingDown) return false;
334
372
  if (!sqmResult && durableLocalTaskWorker) await durableLocalTaskWorker.poll();
335
373
  return true;
336
374
  })
337
375
  .catch((error) => {
338
376
  if (!deviceHeartbeatSucceeded) {
339
377
  const state = connectionStatus.failure(error);
378
+ notifyDesktopStatusChange();
340
379
  if (state === "unauthorized") {
341
380
  session = null;
342
381
  loginUrl = null;
@@ -1634,20 +1673,34 @@ export async function startAgent({
1634
1673
  }
1635
1674
  }
1636
1675
 
1637
- if (isSessionValid(session, Date.now(), apiOrigin)) {
1676
+ const synchronizeStartup = async () => {
1638
1677
  try {
1639
1678
  await refreshAuthorizedRepositoryInventory();
1679
+ if (shuttingDown) return;
1640
1680
  await heartbeat();
1681
+ if (shuttingDown) return;
1641
1682
  await restoreRemoteRuns();
1642
1683
  } catch (error) {
1643
1684
  reportStartupConnectionError(error, quiet);
1644
1685
  }
1686
+ };
1687
+ if (deferStartupSynchronization) {
1688
+ // The desktop can show restored tasks and account state while network and
1689
+ // provider checks run. Unknown remains unknown until each probe succeeds.
1690
+ void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
1691
+ if (isSessionValid(session, Date.now(), apiOrigin)) {
1692
+ startHeartbeatTimer();
1693
+ void synchronizeStartup();
1694
+ }
1695
+ } else if (isSessionValid(session, Date.now(), apiOrigin)) {
1696
+ await synchronizeStartup();
1645
1697
  startHeartbeatTimer();
1646
1698
  }
1647
1699
 
1648
1700
  let shutdownPromise = null;
1649
1701
  const shutdown = () => {
1650
1702
  if (shutdownPromise) return shutdownPromise;
1703
+ shuttingDown = true;
1651
1704
  shutdownPromise = (async () => {
1652
1705
  if (heartbeatTimer) clearInterval(heartbeatTimer);
1653
1706
  updateManager?.stop?.();
@@ -1670,7 +1723,14 @@ export async function startAgent({
1670
1723
  });
1671
1724
  const getDesktopStatus = async () => {
1672
1725
  const auth = desktopAuthStatus(session, { webOrigin: apiOrigin });
1673
- const { codex, claude } = await readProviderStatuses();
1726
+ if (deferStartupSynchronization && !shuttingDown) {
1727
+ // Cached checks return immediately; expired checks refresh in the
1728
+ // background without making the desktop frame wait for subprocesses.
1729
+ void readProviderStatuses().catch((error) => reportStartupConnectionError(error, quiet));
1730
+ }
1731
+ const { codex, claude } = deferStartupSynchronization
1732
+ ? providerStatuses
1733
+ : await readProviderStatuses();
1674
1734
  return {
1675
1735
  schemaVersion: 1,
1676
1736
  sampledAt: new Date().toISOString(),
@@ -1995,6 +2055,26 @@ async function capabilities() {
1995
2055
  inspectDesktopExecutor("codex"),
1996
2056
  inspectDesktopExecutor("claude"),
1997
2057
  ]);
2058
+ return capabilitiesFromInspections(codex, claude);
2059
+ }
2060
+
2061
+ function unverifiedCapabilities() {
2062
+ const unknown = (backend) => ({
2063
+ backend,
2064
+ execution_target: DESKTOP_EXECUTION_TARGET,
2065
+ execution_label: DESKTOP_EXECUTION_LABEL,
2066
+ provider_label: BACKEND_VERSION_POLICIES[backend].label,
2067
+ status: "checking",
2068
+ available: false,
2069
+ compatible: false,
2070
+ installed_version: null,
2071
+ minimum_version: BACKEND_VERSION_POLICIES[backend].minimumVersion,
2072
+ update_command: BACKEND_VERSION_POLICIES[backend].updateCommand,
2073
+ });
2074
+ return capabilitiesFromInspections(unknown("codex"), unknown("claude"));
2075
+ }
2076
+
2077
+ function capabilitiesFromInspections(codex, claude) {
1998
2078
  return {
1999
2079
  backends: { codex: codex.compatible, claude: claude.compatible },
2000
2080
  backend_versions: {
@@ -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.24";
16
+ export const CLI_VERSION = "0.5.26";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -78,6 +78,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
78
78
  });
79
79
  agent = await (dependencies.startAgent || startAgent)({
80
80
  daemon: true, webOrigin, sessionRoot: root, quiet: true, updateManager: updater,
81
+ deferStartupSynchronization: true,
82
+ onDesktopStatusChange: () => {
83
+ // Let an in-progress frame finish before publishing its new snapshot.
84
+ setImmediate(() => { void publish().catch(() => {}); });
85
+ },
81
86
  });
82
87
  stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
83
88
  updater.on?.("change", () => { void publish().catch(() => {}); });
@@ -93,14 +93,18 @@ export function desktopTaskDetail(registries, key) {
93
93
  if (!task) return null;
94
94
  const attempt = task.snapshot?.attempts?.at(-1);
95
95
  const request = task.request?.requirement || task.display_instruction || task.instruction || task.snapshot?.charter?.requirement;
96
+ const purpose = purposePresentation(request);
96
97
  const result = typeof task.result === "string" ? task.result : task.result?.summary || attempt?.summary;
98
+ const report = reportPresentation(task.result) || reportPresentation(attempt?.raw_output?.agent_report, true) || reportPresentation(result);
97
99
  const links = [];
98
100
  const pr = safeDesktopLink(attempt?.pr_url);
99
101
  if (pr) links.push({ label: "Pull request", url: pr });
100
102
  return {
101
103
  key,
102
- instruction: desktopContent(request),
103
- result: desktopContent(result),
104
+ instruction: desktopContent(purpose?.purpose || request),
105
+ ...(purpose ? { purpose, technicalInstruction: desktopContent(request) } : {}),
106
+ result: desktopContent(report?.summary || result),
107
+ ...(report ? { report, ...(typeof result === "string" && result.trimStart().startsWith("{") ? { technicalResult: desktopContent(result) } : {}) } : {}),
104
108
  error: desktopContent(task.error, 1000),
105
109
  truncated: [request, result].some((value) => typeof value === "string" && value.length > 16000),
106
110
  links,
@@ -224,26 +228,82 @@ function taskStatusItem(task, registryIndex) {
224
228
  ),
225
229
  48,
226
230
  );
231
+ const request = task?.request?.requirement || task?.display_instruction || task?.instruction ||
232
+ task?.snapshot?.charter?.requirement;
233
+ const label = purposePresentation(request)?.purpose || request || task?.id || "Task";
227
234
  return {
228
235
  key: `${registryIndex}:${bounded(task?.id || "", 240)}`,
229
236
  id: bounded(String(task?.id || `unknown-${registryIndex}`), MAX_LABEL_LENGTH),
230
- label: bounded(
231
- String(
232
- task?.request?.requirement ||
233
- task?.display_instruction ||
234
- task?.instruction ||
235
- task?.snapshot?.charter?.requirement ||
236
- task?.id ||
237
- "Task",
238
- ),
239
- MAX_LABEL_LENGTH,
240
- ),
237
+ label: bounded(String(label), MAX_LABEL_LENGTH),
241
238
  state,
242
239
  updatedAt:
243
240
  Date.parse(task?.updated_at || task?.snapshot?.updated_at || task?.startedAt || 0) || 0,
244
241
  };
245
242
  }
246
243
 
244
+ // The Purpose-driven executor embeds a user-facing charter inside a generated
245
+ // instruction. Project only known charter fields; keep the envelope available
246
+ // on selection so no original context is lost or reinterpreted as progress.
247
+ function purposePresentation(request) {
248
+ if (typeof request !== "string" || request.length > 262_144 ||
249
+ !(request.includes("Purpose Charter and constraints (authoritative context):") ||
250
+ request.includes("Purpose Charter, hypotheses, verification order, and completion criteria (authoritative context):")) ||
251
+ !request.includes("You are the executor, not the conductor.")) return null;
252
+ const marker = "\nPurpose Charter:\n";
253
+ const start = request.indexOf(marker);
254
+ if (start < 0) return null;
255
+ const jsonStart = request.indexOf("{", start + marker.length);
256
+ if (jsonStart < 0) return null;
257
+ let depth = 0, quoted = false, escaped = false, end = -1;
258
+ for (let i = jsonStart; i < request.length; i += 1) {
259
+ const char = request[i];
260
+ if (escaped) { escaped = false; continue; }
261
+ if (quoted && char === "\\") { escaped = true; continue; }
262
+ if (char === '"') { quoted = !quoted; continue; }
263
+ if (quoted) continue;
264
+ if (char === "{") depth += 1;
265
+ if (char === "}") {
266
+ depth -= 1;
267
+ if (depth === 0) { end = i + 1; break; }
268
+ }
269
+ }
270
+ if (end < 0) return null;
271
+ try {
272
+ const charter = JSON.parse(request.slice(jsonStart, end));
273
+ const clean = (value, max = 2000) => typeof value === "string" ? desktopContent(value, max).trim() : "";
274
+ const purpose = clean(charter.original_request || charter.requirement || charter.purpose);
275
+ if (!purpose) return null;
276
+ return {
277
+ purpose,
278
+ persona: clean(charter.persona),
279
+ expectedValue: clean(charter.expected_value),
280
+ successCriteria: Array.isArray(charter.success_criteria)
281
+ ? charter.success_criteria.filter((value) => typeof value === "string").slice(0, 20).map((value) => clean(value, 1000)).filter(Boolean)
282
+ : [],
283
+ };
284
+ } catch { return null; }
285
+ }
286
+
287
+ function reportPresentation(result, allowSchemaLess = false) {
288
+ if (typeof result !== "string" && (typeof result !== "object" || result === null)) return null;
289
+ try {
290
+ const value = typeof result === "string"
291
+ ? result.length <= 65_536 && result.trimStart().startsWith("{") ? JSON.parse(result) : null
292
+ : result;
293
+ if ((value?.schema_version !== "agent_report.v1" && !(allowSchemaLess && !value?.schema_version)) || typeof value.summary !== "string") return null;
294
+ const list = (field) => Array.isArray(value[field])
295
+ ? value[field].filter((item) => typeof item === "string").slice(0, 20).map((item) => desktopContent(item, 1000))
296
+ : [];
297
+ return {
298
+ summary: desktopContent(value.summary),
299
+ testsRun: list("tests_run"),
300
+ evidenceRefs: list("evidence_refs"),
301
+ unresolvedItems: list("unresolved_items"),
302
+ risks: list("risks"),
303
+ };
304
+ } catch { return null; }
305
+ }
306
+
247
307
  function sessionUserLabel(session) {
248
308
  if (!session) return null;
249
309
  for (const key of ["user_label", "display_name", "email", "account_id", "auth_user_id"]) {