@awak-app/simy-cli 0.5.21 → 0.5.22

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.
@@ -152,7 +152,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
152
152
  return;
153
153
  }
154
154
  if (action === "refresh" || action === "reconnect") {
155
- await agent.refreshCodexStatus?.();
155
+ if (agent.refreshProviderStatus) await agent.refreshProviderStatus();
156
+ else await agent.refreshCodexStatus?.();
156
157
  await agent.refreshDesktopStatus();
157
158
  }
158
159
  else if (action === "login") {
@@ -166,7 +167,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
166
167
  else if (action === "update") {
167
168
  if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
168
169
  else await updater.performAction();
169
- } else if (action === "codex_login") await (dependencies.codexLogin || openCodexLogin)(root);
170
+ } else if (action === "codex_login" || action === "claude_login") {
171
+ const provider = action === "claude_login" ? "claude" : "codex";
172
+ const login = dependencies[`${provider}Login`];
173
+ if (login) await login(root);
174
+ else await openProviderLogin(provider, root);
175
+ }
170
176
  else if (action === "quit") {
171
177
  const status = await agent.getDesktopStatus();
172
178
  if (status.tasks.activeCount > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
@@ -192,22 +198,28 @@ function validCandidateStatus(status, version, webOrigin) {
192
198
  && Array.isArray(status.tasks.items) && typeof status.codex?.state === "string";
193
199
  }
194
200
 
195
- async function openCodexLogin(root) {
196
- const codex = await inspectBackendExecutable("codex");
197
- if (!codex.available || !codex.executable) throw new Error("Codex is not installed.");
198
- if (process.platform === "win32") {
199
- const child = spawn(join(process.env.SystemRoot || "C:\\Windows", "System32/WindowsPowerShell/v1.0/powershell.exe"),
200
- ["-NoProfile", "-NoExit", "-Command", "& $env:SIMY_CODEX_LOGIN login"],
201
- { detached: true, stdio: "ignore", windowsHide: false, env: { ...process.env, SIMY_CODEX_LOGIN: codex.executable } });
201
+ export async function openProviderLogin(provider, root, {
202
+ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn,
203
+ execFileImpl = promisify(execFile), platform = process.platform, env = process.env,
204
+ } = {}) {
205
+ if (provider !== "codex" && provider !== "claude") throw new Error("Unsupported provider.");
206
+ const executable = await inspectExecutable(provider);
207
+ if (!executable.available || !executable.executable) throw new Error("Provider is not installed.");
208
+ const label = provider === "claude" ? "Claude Code" : "Codex";
209
+ const args = provider === "claude" ? ["auth", "login"] : ["login"];
210
+ if (platform === "win32") {
211
+ const child = spawnImpl(join(env.SystemRoot || "C:\\Windows", "System32/WindowsPowerShell/v1.0/powershell.exe"),
212
+ ["-NoProfile", "-NoExit", "-Command", `& $env:SIMY_PROVIDER_LOGIN ${args.join(" ")}`],
213
+ { detached: true, stdio: "ignore", windowsHide: false, env: { ...env, SIMY_PROVIDER_LOGIN: executable.executable } });
202
214
  await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); });
203
215
  child.unref(); return;
204
216
  }
205
- const directory = await mkdtemp(join(root, "codex-login-"));
206
- const script = join(directory, "Sign in to Codex.command");
217
+ const directory = await mkdtemp(join(root, `${provider}-login-`));
218
+ const script = join(directory, `Sign in to ${label}.command`);
207
219
  const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
208
- await writeFile(script, `#!/bin/bash\nexport PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:$PATH"\n${quote(codex.executable)} login\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
220
+ await writeFile(script, `#!/bin/bash\nexport PATH="/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin:$PATH"\n${quote(executable.executable)} ${args.join(" ")}\nresult=$?\nrm -f -- ${quote(script)}\nrmdir -- ${quote(directory)}\nexit "$result"\n`, { mode: 0o700 });
209
221
  await chmod(script, 0o700);
210
- try { await promisify(execFile)("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
222
+ try { await execFileImpl("/usr/bin/open", ["-a", "Terminal", script], { timeout: 10_000 }); }
211
223
  catch (error) { await rm(directory, { recursive: true, force: true }); throw error; }
212
224
  }
213
225
 
@@ -1,9 +1,9 @@
1
1
  import spawn from "cross-spawn";
2
2
 
3
- import { inspectBackendExecutable } from "./backend-executable.js";
3
+ import { BACKEND_VERSION_POLICIES, inspectBackendExecutable } from "./backend-executable.js";
4
4
  import { isSessionValid } from "./session-store.js";
5
5
 
6
- const CODEX_STATUS_CACHE_MS = 30_000;
6
+ const PROVIDER_STATUS_CACHE_MS = 30_000;
7
7
  const MAX_DETAIL_LENGTH = 240;
8
8
  const MAX_LABEL_LENGTH = 120;
9
9
  const MAX_STATUS_ITEMS = 50;
@@ -92,7 +92,7 @@ export function desktopTaskDetail(registries, key) {
92
92
  const task = registries[registryIndex]?.list?.().find((item) => item.id === match[2]);
93
93
  if (!task) return null;
94
94
  const attempt = task.snapshot?.attempts?.at(-1);
95
- const request = task.request?.requirement || task.instruction || task.snapshot?.charter?.requirement;
95
+ const request = task.request?.requirement || task.display_instruction || task.instruction || task.snapshot?.charter?.requirement;
96
96
  const result = typeof task.result === "string" ? task.result : task.result?.summary || attempt?.summary;
97
97
  const links = [];
98
98
  const pr = safeDesktopLink(attempt?.pr_url);
@@ -119,9 +119,13 @@ function safeDesktopLink(value) {
119
119
  } catch { return null; }
120
120
  }
121
121
 
122
- export function createCodexStatusInspector({
123
- cacheMs = CODEX_STATUS_CACHE_MS,
124
- inspect = inspectCodexStatus,
122
+ export function createCodexStatusInspector(options = {}) {
123
+ return createProviderStatusInspector("codex", options);
124
+ }
125
+
126
+ export function createProviderStatusInspector(provider, {
127
+ cacheMs = PROVIDER_STATUS_CACHE_MS,
128
+ inspect = (options) => inspectProviderStatus(provider, options),
125
129
  now = () => Date.now(),
126
130
  inspectOptions = {},
127
131
  } = {}) {
@@ -145,42 +149,49 @@ export function createCodexStatusInspector({
145
149
  };
146
150
  }
147
151
 
148
- export async function inspectCodexStatus({
152
+ export function inspectCodexStatus(options = {}) {
153
+ return inspectProviderStatus("codex", options);
154
+ }
155
+
156
+ export async function inspectProviderStatus(provider, {
149
157
  inspectExecutable = inspectBackendExecutable,
150
158
  spawnImpl = spawn,
151
159
  timeoutMs = 2_000,
152
160
  now = () => new Date(),
153
161
  } = {}) {
162
+ const policy = BACKEND_VERSION_POLICIES[provider];
163
+ if (!policy) throw new Error("Unsupported provider.");
164
+ const label = policy.label;
154
165
  const checkedAt = isoNow(now);
155
166
  let executable;
156
167
  try {
157
- executable = await inspectExecutable("codex", { spawnImpl, timeoutMs });
168
+ executable = await inspectExecutable(provider, { spawnImpl, timeoutMs });
158
169
  } catch {
159
- return codexStatus("unknown", null, checkedAt, "Codex availability could not be checked.");
170
+ return providerStatus("unknown", null, checkedAt, `${label} availability could not be checked.`);
160
171
  }
161
172
  const version = executable?.installed_version ?? null;
162
173
  if (!executable?.available) {
163
- return codexStatus("missing", version, checkedAt, "Codex is not installed or cannot be started.");
174
+ return providerStatus("missing", version, checkedAt, `${label} is not installed or cannot be started.`);
164
175
  }
165
176
  if (!executable.compatible) {
166
- return codexStatus(
177
+ return providerStatus(
167
178
  executable.status === "outdated" ? "outdated" : "unknown",
168
179
  version,
169
180
  checkedAt,
170
181
  executable.status === "outdated"
171
- ? `Codex ${version || "installed version"} is older than the supported minimum.`
172
- : "The installed Codex version could not be verified.",
182
+ ? `${label} ${version || "installed version"} is older than the supported minimum.`
183
+ : `The installed ${label} version could not be verified.`,
173
184
  );
174
185
  }
175
186
 
176
- const probe = await runLoginStatus(executable.executable, { spawnImpl, timeoutMs });
187
+ const probe = await runLoginStatus(provider, executable.executable, { spawnImpl, timeoutMs });
177
188
  if (probe.kind === "signed_in") {
178
- return codexStatus("ready", version, checkedAt, "Codex is installed and signed in.");
189
+ return providerStatus("ready", version, checkedAt, `${label} is installed and signed in.`);
179
190
  }
180
191
  if (probe.kind === "signed_out") {
181
- return codexStatus("signed_out", version, checkedAt, "Codex is installed but signed out.");
192
+ return providerStatus("signed_out", version, checkedAt, `${label} is installed but signed out.`);
182
193
  }
183
- return codexStatus("unknown", version, checkedAt, "Codex login status could not be verified.");
194
+ return providerStatus("unknown", version, checkedAt, `${label} login status could not be verified.`);
184
195
  }
185
196
 
186
197
  export function safeStatusDetail(cause) {
@@ -219,6 +230,7 @@ function taskStatusItem(task, registryIndex) {
219
230
  label: bounded(
220
231
  String(
221
232
  task?.request?.requirement ||
233
+ task?.display_instruction ||
222
234
  task?.instruction ||
223
235
  task?.snapshot?.charter?.requirement ||
224
236
  task?.id ||
@@ -242,10 +254,11 @@ function sessionUserLabel(session) {
242
254
  return null;
243
255
  }
244
256
 
245
- function runLoginStatus(command, { spawnImpl, timeoutMs }) {
257
+ function runLoginStatus(provider, command, { spawnImpl, timeoutMs }) {
246
258
  return new Promise((resolve) => {
247
259
  let settled = false;
248
260
  let output = "";
261
+ let stdout = "";
249
262
  let timeout;
250
263
  let child;
251
264
  const finish = (kind) => {
@@ -255,7 +268,8 @@ function runLoginStatus(command, { spawnImpl, timeoutMs }) {
255
268
  resolve({ kind });
256
269
  };
257
270
  try {
258
- child = spawnImpl(command, ["login", "status"], {
271
+ child = spawnImpl(command, provider === "claude" ? ["auth", "status", "--json"] : ["login", "status"], {
272
+ windowsHide: true,
259
273
  stdio: ["ignore", "pipe", "pipe"],
260
274
  });
261
275
  } catch {
@@ -266,7 +280,10 @@ function runLoginStatus(command, { spawnImpl, timeoutMs }) {
266
280
  if (output.length >= MAX_PROBE_OUTPUT) return;
267
281
  output += String(chunk).slice(0, MAX_PROBE_OUTPUT - output.length);
268
282
  };
269
- child.stdout?.on?.("data", append);
283
+ child.stdout?.on?.("data", (chunk) => {
284
+ stdout += String(chunk).slice(0, Math.max(0, MAX_PROBE_OUTPUT - stdout.length));
285
+ append(chunk);
286
+ });
270
287
  child.stderr?.on?.("data", append);
271
288
  timeout = setTimeout(() => {
272
289
  child.kill?.("SIGTERM");
@@ -274,6 +291,15 @@ function runLoginStatus(command, { spawnImpl, timeoutMs }) {
274
291
  }, timeoutMs);
275
292
  child.on?.("error", () => finish("unknown"));
276
293
  child.on?.("close", (code) => {
294
+ if (provider === "claude") {
295
+ let status;
296
+ try { status = JSON.parse(stdout); } catch { finish("unknown"); return; }
297
+ // Only the documented boolean and successful exit establish readiness.
298
+ // Never publish email, organization, auth-method details or raw output.
299
+ finish(code === 0 && status?.loggedIn === true ? "signed_in"
300
+ : status?.loggedIn === false && (code === 0 || code === 1) ? "signed_out" : "unknown");
301
+ return;
302
+ }
277
303
  if (/not logged in|signed out|login required|please log in/i.test(output)) {
278
304
  finish("signed_out");
279
305
  } else if (code === 0 && /logged in|signed in/i.test(output)) {
@@ -285,7 +311,7 @@ function runLoginStatus(command, { spawnImpl, timeoutMs }) {
285
311
  });
286
312
  }
287
313
 
288
- function codexStatus(state, version, checkedAt, detail) {
314
+ function providerStatus(state, version, checkedAt, detail) {
289
315
  return { state, version, checkedAt, detail };
290
316
  }
291
317
 
@@ -74,6 +74,7 @@ export async function startDirectTask(
74
74
 
75
75
  return new Promise((resolve) => {
76
76
  let settled = false;
77
+ let terminalError = null;
77
78
  const rawStdout = [];
78
79
  const tokenRecords = [];
79
80
  const child = command.spawn(command.bin, command.args, {
@@ -96,6 +97,7 @@ export async function startDirectTask(
96
97
  backend: task.backend,
97
98
  stream: "stdout",
98
99
  onLine,
100
+ onTerminalError: (message) => { terminalError ||= message; },
99
101
  onUsage: (usage) => tokenRecords.push({ ...usage, phase: "direct", backend: task.backend }),
100
102
  });
101
103
  const stderrDecoder = createProviderStreamDecoder({
@@ -115,6 +117,7 @@ export async function startDirectTask(
115
117
  settled = true;
116
118
  stdoutDecoder.flush();
117
119
  stderrDecoder.flush();
120
+ error ||= terminalError;
118
121
  task.child = null;
119
122
  task.token_usage = { records: tokenRecords };
120
123
  task.status = error || exitCode !== 0 ? "failed" : "succeeded";
@@ -116,7 +116,8 @@ export function buildControlledLocalTaskPlan(orchestration) {
116
116
 
117
117
  const payload = orchestration.request_payload;
118
118
  assertRecord(payload, "orchestration.request_payload");
119
- if (payload.mode !== "local_task") {
119
+ // Current server payloads omit the retired interaction mode; reject conflicting legacy values.
120
+ if (payload.mode !== undefined && payload.mode !== "local_task") {
120
121
  fail("orchestration.request_payload.mode is unsupported.");
121
122
  }
122
123
  const instruction = requiredString(
@@ -252,6 +253,7 @@ export function normalizeDurableLocalTaskStepClaim(
252
253
  "lease_epoch",
253
254
  "lease_expires_at",
254
255
  "claim_replayed",
256
+ ...(Object.hasOwn(claim, "model") ? ["model"] : []),
255
257
  ]),
256
258
  "claim",
257
259
  );
@@ -261,6 +263,7 @@ export function normalizeDurableLocalTaskStepClaim(
261
263
  if (!SUPPORTED_DISPOSITIONS.has(claim.disposition)) {
262
264
  fail("Unsupported durable Local Task step disposition.");
263
265
  }
266
+ const model = claim.model == null ? null : requiredString(claim.model, "model", 160);
264
267
  const turnId = requiredUuid(claim.turn_id, "turn_id");
265
268
  const plan = normalizePlan(claim.plan);
266
269
  const currentStepId = requiredStepId(claim.current_step_id, "current_step_id");
@@ -299,6 +302,7 @@ export function normalizeDurableLocalTaskStepClaim(
299
302
  turnId,
300
303
  plan,
301
304
  step,
305
+ model,
302
306
  progress,
303
307
  previousStepContext,
304
308
  authorization: null,
@@ -355,6 +359,7 @@ export function normalizeDurableLocalTaskStepClaim(
355
359
  turnId,
356
360
  plan,
357
361
  step,
362
+ model,
358
363
  progress,
359
364
  previousStepContext,
360
365
  authorization,
@@ -753,7 +753,9 @@ export function createDurableLocalTaskWorker({
753
753
  task = createLocalTask({
754
754
  id: workspace.localTaskId,
755
755
  instruction: durableLocalTaskStepInstruction(normalized),
756
+ displayInstruction: normalized.step.instruction,
756
757
  backend: normalized.step.backend,
758
+ model: normalized.model,
757
759
  operation: normalized.step.operation,
758
760
  permissionMode: normalized.step.permission_mode,
759
761
  workspaceMode: normalized.step.workspace_mode,
@@ -1108,6 +1110,7 @@ export function createDurableLocalTaskWorker({
1108
1110
  id: workspace.localTaskId,
1109
1111
  instruction: normalized.instruction,
1110
1112
  backend: normalized.spec.backend,
1113
+ model: normalized.spec.model ?? null,
1111
1114
  operation: normalized.spec.operation,
1112
1115
  permissionMode: normalized.spec.permission_mode,
1113
1116
  workspaceMode: normalized.spec.workspace_mode,
@@ -1661,7 +1664,8 @@ function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
1661
1664
  }
1662
1665
  const payload = claim.request_payload;
1663
1666
  const spec = claim.execution_spec;
1664
- if (!payload || typeof payload !== "object" || payload.mode !== "local_task") {
1667
+ if (!payload || typeof payload !== "object" ||
1668
+ (payload.mode !== undefined && payload.mode !== "local_task")) {
1665
1669
  throw workerError("SIMY Web returned an invalid Local Task request.", "invalid_local_task_request");
1666
1670
  }
1667
1671
  const instruction = String(payload.input || "").trim();
@@ -6,6 +6,7 @@ export const EXECUTION_CAPABILITY_CONTRACT_VERSION = 1;
6
6
  export function buildExecutionCapabilityContract({
7
7
  backends = {},
8
8
  backendVersions = {},
9
+ providerStatuses,
9
10
  authenticated = false,
10
11
  } = {}) {
11
12
  const artifactTypes = supportedArtifactTypes();
@@ -17,6 +18,7 @@ export function buildExecutionCapabilityContract({
17
18
  inspection: backendVersions?.[provider],
18
19
  legacyAvailable: backends?.[provider] === true,
19
20
  authenticated,
21
+ authStatus: providerStatuses?.[provider],
20
22
  artifactTypes,
21
23
  }),
22
24
  ),
@@ -29,10 +31,19 @@ function localExecutionCapability({
29
31
  inspection,
30
32
  legacyAvailable,
31
33
  authenticated,
34
+ authStatus,
32
35
  artifactTypes,
33
36
  }) {
34
37
  const binary = normalizeInspection(provider, inspection, legacyAvailable);
35
- const ready = authenticated && binary.compatible;
38
+ const providerAuth = authStatus?.state === "ready" ? "ready"
39
+ : authStatus?.state === "signed_out" ? "required"
40
+ : authStatus?.state === "missing" || authStatus?.state === "outdated" ? "unavailable" : "unknown";
41
+ // Old clients have no provider auth observation. Preserve their v1 contract;
42
+ // once the new status is supplied, unknown or signed-out cannot mean ready.
43
+ const ready = authenticated && binary.compatible && (!authStatus || providerAuth === "ready");
44
+ const reason = ready ? "ready" : !authenticated ? "device_session_required"
45
+ : !binary.compatible ? (binary.status === "outdated" || binary.status === "unknown_version" ? "provider_update_required" : "provider_missing")
46
+ : providerAuth === "required" ? "provider_login_required" : "provider_status_unknown";
36
47
  return {
37
48
  schema_version: EXECUTION_CAPABILITY_SCHEMA_VERSION,
38
49
  capability_contract_version: EXECUTION_CAPABILITY_CONTRACT_VERSION,
@@ -54,28 +65,14 @@ function localExecutionCapability({
54
65
  },
55
66
  authentication_readiness: {
56
67
  device_session: authenticated ? "ready" : "required",
57
- // A version probe proves only that the binary is runnable. It does not
58
- // prove the Provider account is authenticated.
59
- provider: "unknown",
68
+ provider: providerAuth,
60
69
  },
61
- readiness: {
62
- ready,
63
- reason: ready
64
- ? "ready"
65
- : !authenticated
66
- ? "device_session_required"
67
- : binary.status === "outdated" || binary.status === "unknown_version"
68
- ? "provider_update_required"
69
- : "provider_missing",
70
- },
71
- missing_capability_recovery_actions: ready
72
- ? []
73
- : [
74
- {
75
- action: binary.status === "missing" ? "install_provider" : "update_provider",
76
- command: binary.update_command,
77
- },
78
- ],
70
+ readiness: { ready, reason },
71
+ missing_capability_recovery_actions: ready ? []
72
+ : reason === "provider_login_required" ? [{ action: "login_provider", command: provider === "claude" ? "claude auth login" : "codex login" }]
73
+ : reason === "provider_status_unknown" ? [{ action: "refresh_provider" }]
74
+ : reason === "device_session_required" ? [{ action: "connect_device" }]
75
+ : [{ action: binary.status === "missing" ? "install_provider" : "update_provider", command: binary.update_command }],
79
76
  };
80
77
  }
81
78
 
package/src/local-task.js CHANGED
@@ -220,9 +220,18 @@ async function initializeLocalTaskGitRepository(workspacePath) {
220
220
  }
221
221
  }
222
222
 
223
+ function normalizeLocalTaskModel(model) {
224
+ if (model == null) return null;
225
+ if (typeof model !== "string" || !model.trim() || model.length > 200 || /[\r\n]/.test(model)) {
226
+ throw new Error("Local Task model must be a non-empty model identifier.");
227
+ }
228
+ return model.trim();
229
+ }
230
+
223
231
  export function createLocalTask({
224
232
  id = createLocalTaskId(),
225
233
  instruction,
234
+ displayInstruction = null,
226
235
  backend,
227
236
  model = null,
228
237
  operation = "read",
@@ -257,8 +266,9 @@ export function createLocalTask({
257
266
  status: "queued",
258
267
  control_state: "queued",
259
268
  instruction: String(instruction || "").trim(),
269
+ ...(displayInstruction ? { display_instruction: String(displayInstruction).trim() } : {}),
260
270
  backend,
261
- model: model === null ? null : String(model),
271
+ model: normalizeLocalTaskModel(model),
262
272
  operation,
263
273
  permission_mode: permissionMode,
264
274
  workspace_mode: workspaceMode,
@@ -306,6 +316,7 @@ export function hashLocalTaskIdempotencyKey(value) {
306
316
  export function createLocalTaskRequestFingerprint({
307
317
  instruction,
308
318
  backend,
319
+ model = null,
309
320
  operation,
310
321
  permissionMode,
311
322
  workspaceMode,
@@ -316,6 +327,7 @@ export function createLocalTaskRequestFingerprint({
316
327
  attachmentManifest,
317
328
  scenarioPack,
318
329
  }) {
330
+ const normalizedModel = normalizeLocalTaskModel(model);
319
331
  const manifest = Array.isArray(attachmentManifest)
320
332
  ? attachmentManifest
321
333
  .filter((item) => item && typeof item === "object")
@@ -329,6 +341,7 @@ export function createLocalTaskRequestFingerprint({
329
341
  const payload = {
330
342
  instruction: String(instruction || "").trim(),
331
343
  backend: String(backend || ""),
344
+ ...(normalizedModel ? { model: normalizedModel } : {}),
332
345
  operation: String(operation || ""),
333
346
  permission_mode: String(permissionMode || ""),
334
347
  workspace_mode: String(workspaceMode || ""),
@@ -499,6 +512,10 @@ export async function startLocalTask(
499
512
  stream: "stdout",
500
513
  onLine,
501
514
  onUsage,
515
+ onTerminalError: (message) => {
516
+ task.error ||= message;
517
+ task.reason_code ||= "provider_execution_failed";
518
+ },
502
519
  });
503
520
  const stderrDecoder = createProviderStreamDecoder({
504
521
  backend: task.backend,
@@ -663,7 +680,9 @@ export function localTaskSnapshot(task, { includeEvents = true, includeLocalPath
663
680
  status: task.status,
664
681
  control_state: task.control_state,
665
682
  instruction: task.instruction,
683
+ ...(task.display_instruction ? { display_instruction: task.display_instruction } : {}),
666
684
  backend: task.backend,
685
+ ...(task.model ? { model: task.model } : {}),
667
686
  operation: task.operation,
668
687
  permission_mode: task.permission_mode,
669
688
  workspace_mode: task.workspace_mode,
@@ -32,6 +32,15 @@ export function recoveryContractForEvent({ snapshot, state, message, detail = {}
32
32
  }
33
33
 
34
34
  export function recoveryContractForPreflight({ key, summary, details = {} }) {
35
+ if (key === "provider_auth") {
36
+ const loginRequired = details.status === "signed_out";
37
+ const actions = loginRequired ? [{
38
+ id: "login_provider", type: "run_command", label: "Sign in to the provider",
39
+ description: "Finish signing in, then check the connection again.",
40
+ command: details.provider === "claude" ? "claude auth login" : "codex login", payload: {},
41
+ }, checkAgainAction()] : [checkAgainAction()];
42
+ return preflightContract(loginRequired ? "provider_login_required" : "provider_status_unknown", summary, actions);
43
+ }
35
44
  const updateCommand = clean(details.update_command);
36
45
  if (key === "backend") {
37
46
  const reasonCode =
@@ -5,7 +5,7 @@ const STRUCTURED_MARKERS = ["SIMY_RESULT_JSON:", "SIMY_AUDIT_JSON:"];
5
5
  const CODEX_MCP_AUTH_WARNING =
6
6
  "[Codex] MCP warning: optional connector authorization expired; coding continues.";
7
7
 
8
- export function createProviderStreamDecoder({ backend, stream = "stdout", onLine, onUsage }) {
8
+ export function createProviderStreamDecoder({ backend, stream = "stdout", onLine, onUsage, onTerminalError }) {
9
9
  let pending = "";
10
10
  let previous = "";
11
11
  let model = "";
@@ -22,6 +22,9 @@ export function createProviderStreamDecoder({ backend, stream = "stdout", onLine
22
22
  return;
23
23
  }
24
24
 
25
+ const terminalError = providerTerminalError(backend, event);
26
+ if (terminalError && onTerminalError) onTerminalError(terminalError);
27
+
25
28
  model = providerModel(backend, event) || model;
26
29
  const usage = extractProviderTokenUsage(backend, event, { model });
27
30
  if (usage && onUsage) onUsage(usage);
@@ -50,6 +53,20 @@ export function createProviderStreamDecoder({ backend, stream = "stdout", onLine
50
53
  };
51
54
  }
52
55
 
56
+ // Tool failures can be recovered by the provider. Only terminal turn/result
57
+ // envelopes override a successful operating-system exit code.
58
+ export function providerTerminalError(backend, event) {
59
+ if (!event || typeof event !== "object") return null;
60
+ if (backend === "claude" && event.type === "result" &&
61
+ (event.is_error === true || String(event.subtype || "").startsWith("error"))) {
62
+ return cleanLine(event.result || (Array.isArray(event.errors) ? event.errors.join("; ") : "") || event.subtype || "Claude Code execution failed");
63
+ }
64
+ if (backend === "codex" && event.type === "turn.failed") {
65
+ return cleanLine(errorMessage(event));
66
+ }
67
+ return null;
68
+ }
69
+
53
70
  export function extractProviderTokenUsage(backend, event, { model = "" } = {}) {
54
71
  if (!event || typeof event !== "object") return null;
55
72
  if (backend === "claude") {
@@ -124,7 +141,8 @@ function formatClaudeEvent(event) {
124
141
  return formatClaudeToolResults(prefix, event.message?.content);
125
142
  }
126
143
  if (event.type === "result") {
127
- if (event.is_error) return [`${prefix} error: ${cleanLine(event.result || event.subtype)}`];
144
+ const terminalError = providerTerminalError("claude", event);
145
+ if (terminalError) return [`${prefix} error: ${terminalError}`];
128
146
  const turns = Number.isInteger(event.num_turns) ? ` | ${event.num_turns} turn(s)` : "";
129
147
  const duration = Number.isFinite(event.duration_ms)
130
148
  ? ` | ${(event.duration_ms / 1_000).toFixed(1)}s`
package/src/runner.js CHANGED
@@ -701,6 +701,7 @@ async function executeProcessAttempt({
701
701
 
702
702
  return new Promise((resolve) => {
703
703
  let settled = false;
704
+ let terminalError = null;
704
705
  const child = command.spawn(command.bin, command.args, {
705
706
  cwd: repositoryPath,
706
707
  env: { ...process.env, ...command.env },
@@ -713,6 +714,7 @@ async function executeProcessAttempt({
713
714
  backend,
714
715
  stream: "stdout",
715
716
  onLine: (line) => emitOutput(run, line, { backend }),
717
+ onTerminalError: (message) => { terminalError ||= message; },
716
718
  onUsage: (usage) =>
717
719
  tokenUsageRecords.push({
718
720
  ...usage,
@@ -752,8 +754,8 @@ async function executeProcessAttempt({
752
754
  : (value) => parseStructuredMarker(value, marker);
753
755
  const result = parse(assistantText);
754
756
  resolve({
755
- exitCode,
756
- error,
757
+ exitCode: terminalError && exitCode === 0 ? 1 : exitCode,
758
+ error: error || terminalError,
757
759
  result: Object.keys(result).length > 0 ? result : parse(rawStdout),
758
760
  tokenUsage:
759
761
  tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
@@ -4,6 +4,8 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
+ import { inspectClaudePlugin, installClaudePlugin } from "./claude-plugin.js";
8
+
7
9
  export const SQM_AGENT_SKILL_ID = "simy-sqm-pr-check";
8
10
  export const SQM_AGENT_SKILL_VERSION = "3.0.0";
9
11
  export const SIMY_CODEX_PLUGIN_ID = "simy";
@@ -57,6 +59,7 @@ export function sqmAgentSkillDigest() {
57
59
  export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
58
60
  const digest = sqmAgentSkillDigest();
59
61
  const codexPlugin = await inspectCodexPlugin({ home });
62
+ const claudePlugin = await inspectClaudePlugin({ home });
60
63
  const hookPath = managedHookPath(home);
61
64
  const platforms = {};
62
65
  for (const [platform, segments] of Object.entries(TARGETS)) {
@@ -64,7 +67,8 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
64
67
  const installedDigest = await fileDigest(target);
65
68
  const skillMatches = installedDigest === digest;
66
69
  const hookConfigured = await hasManagedHook(platform, home, hookPath);
67
- const platformExtraMatches = platform !== "codex" || codexPlugin.installed;
70
+ const plugin = platform === "codex" ? codexPlugin : claudePlugin;
71
+ const platformExtraMatches = plugin.installed;
68
72
  platforms[platform] = {
69
73
  path: target,
70
74
  hook_path: platformHookConfigPath(platform, home),
@@ -76,7 +80,7 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
76
80
  : skillMatches && !hookConfigured && platformExtraMatches
77
81
  ? "installed"
78
82
  : "outdated",
79
- ...(platform === "codex" ? { plugin: codexPlugin } : {}),
83
+ plugin,
80
84
  };
81
85
  }
82
86
  return {
@@ -85,6 +89,7 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
85
89
  digest: `sha256:${digest}`,
86
90
  enforcement: "explicit_opt_in",
87
91
  codex_plugin: codexPlugin,
92
+ claude_plugin: claudePlugin,
88
93
  installed: Object.values(platforms).every((entry) => entry.installed),
89
94
  platforms,
90
95
  };
@@ -96,6 +101,7 @@ export async function installSqmAgentSkill({ home = homedir(), platforms = Objec
96
101
  throw new Error("platforms must contain codex and/or claude");
97
102
  }
98
103
  if (requested.includes("codex")) await installCodexPlugin({ home });
104
+ if (requested.includes("claude")) await installClaudePlugin({ home });
99
105
  await retireLegacySqmGate({ home });
100
106
  for (const platform of requested) {
101
107
  await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
@@ -0,0 +1,41 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from "./claude-plugin.js";
4
+
5
+ export async function refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run }) {
6
+ if (!status.claude_plugin?.installed) return { outcome: "not_enabled" };
7
+ const executable = await resolveExecutable("claude");
8
+ if (!executable) return { outcome: "not_enabled" };
9
+ const options = { timeout: 10_000, maxBuffer: 1024 * 1024,
10
+ env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: claudeHome } };
11
+ const list = async () => JSON.parse((await run(executable, ["plugin", "list", "--json"], options)).stdout);
12
+ const installed = (await list()).find((entry) => entry.id === CLAUDE_PLUGIN_SELECTOR && entry.scope === "user");
13
+ // Never install or re-enable a plugin on a background update, or modify a
14
+ // project/local/managed scope that SIMY did not install for this user.
15
+ if (!installed?.enabled) return { outcome: "not_enabled" };
16
+ const marketplaces = JSON.parse((await run(executable, ["plugin", "marketplace", "list", "--json"], options)).stdout);
17
+ const marketplace = marketplaces.find((entry) => entry.name === CLAUDE_MARKETPLACE);
18
+ if (marketplace?.source !== "directory" || !marketplace.path ||
19
+ await realpath(marketplace.path) !== await realpath(status.claude_plugin.marketplace_root)) {
20
+ throw new Error("Enabled SIMY Claude plugin points to a different marketplace; refresh skipped.");
21
+ }
22
+ const cacheCurrent = async (entry) => {
23
+ if (!entry?.installPath || entry.version !== status.claude_plugin.version) return false;
24
+ const cacheRoot = await realpath(path.join(claudeHome, "plugins/cache")).catch(() => null);
25
+ if (!cacheRoot) return false;
26
+ const installedPath = await realpath(entry.installPath).catch(() => null);
27
+ const relative = installedPath && path.relative(cacheRoot, installedPath);
28
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false;
29
+ try {
30
+ return JSON.parse(await readFile(path.join(installedPath, ".claude-plugin/plugin.json"), "utf8")).version === status.claude_plugin.version;
31
+ } catch (error) { if (error.code === "ENOENT" || error instanceof SyntaxError) return false; throw error; }
32
+ };
33
+ if (await cacheCurrent(installed)) return { outcome: "current" };
34
+ await run(executable, ["plugin", "marketplace", "update", CLAUDE_MARKETPLACE], options);
35
+ await run(executable, ["plugin", "update", CLAUDE_PLUGIN_SELECTOR, "--scope", "user"], options);
36
+ const refreshed = (await list()).find((entry) => entry.id === CLAUDE_PLUGIN_SELECTOR && entry.scope === "user");
37
+ if (!refreshed?.enabled || !await cacheCurrent(refreshed)) {
38
+ throw new Error("Claude Code did not confirm the updated SIMY plugin cache; will retry.");
39
+ }
40
+ return { outcome: "updated", version: status.claude_plugin.version };
41
+ }