impel-cli 0.20.43 → 0.20.45

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
@@ -188,6 +188,16 @@ impel claude --agent research-agent --print "answer this question"
188
188
  impel codex --agent research-agent exec "answer this question"
189
189
  ```
190
190
 
191
+ Claude profiles also generate `/ask-<agent>` commands for synchronized,
192
+ read-only agents that support direct answers. The generated command invokes
193
+ the exact tenant-bound agent, attributes the response, and returns its answer
194
+ verbatim; the ordinary relay subagent remains available. To suppress these
195
+ additive commands and remove previously generated ones on the next sync, run:
196
+
197
+ ```sh
198
+ IMPEL_NATIVE_SLASH_COMMANDS=0 impel agents sync claude
199
+ ```
200
+
191
201
  Both managed paths verify the selected tenant manifest and generated adapter
192
202
  bytes, resolve the canonical native agent name, and omit the normal parent
193
203
  specialist instructions. Claude Code receives the explicit 2.1.220 opt-in and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.43",
3
+ "version": "0.20.45",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  claudeFaithfulCompletionGuidance,
47
47
  claudeVerbatimCompletionGuidance,
48
48
  customAgentVerbatimDescriptionLead,
49
+ VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT,
49
50
  usesVerbatimRelay,
50
51
  } from "./verbatimRelay.js";
51
52
  import { renameWithWindowsRetry } from "./windowsFs.js";
@@ -64,7 +65,7 @@ export const NATIVE_AGENT_RESUME_TOOL = "resume_native_agent_run";
64
65
  export const NATIVE_AGENT_RECOVER_TOOL = "recover_native_agent_runs";
65
66
  export const NATIVE_AGENT_CONTINUATION_SCHEMA = "impel.native-agent-continuation.v1";
66
67
  export const MANAGED_AGENT_MCP_SERVER = "impel_agent";
67
- export const MANAGED_AGENT_MANIFEST_VERSION = 23;
68
+ export const MANAGED_AGENT_MANIFEST_VERSION = 24;
68
69
  export const IMPEL_NATIVE_PARENT_DIRECT_ENV = "IMPEL_NATIVE_PARENT_DIRECT";
69
70
  export const IMPEL_NATIVE_SLASH_COMMANDS_ENV = "IMPEL_NATIVE_SLASH_COMMANDS";
70
71
 
@@ -88,8 +89,11 @@ function eagerNativeAgentTransportEnabled() {
88
89
  return process.env.IMPEL_NATIVE_EAGER_TRANSPORT !== "0";
89
90
  }
90
91
 
91
- function enabledProfileFlag(environment, name) {
92
- return ["1", "true"].includes(String(environment?.[name] || "").toLowerCase());
92
+ function enabledProfileFlag(environment, name, { defaultEnabled = false } = {}) {
93
+ const configured = String(environment?.[name] || "").toLowerCase();
94
+ if (["1", "true"].includes(configured)) return true;
95
+ if (["0", "false"].includes(configured)) return false;
96
+ return defaultEnabled;
93
97
  }
94
98
 
95
99
  export function nativeParentDirectEnabled(environment = process.env) {
@@ -97,7 +101,11 @@ export function nativeParentDirectEnabled(environment = process.env) {
97
101
  }
98
102
 
99
103
  export function nativeSlashCommandsEnabled(environment = process.env) {
100
- return enabledProfileFlag(environment, IMPEL_NATIVE_SLASH_COMMANDS_ENV);
104
+ // This surface is additive: the relay agent remains installed. Keep the
105
+ // explicit false literals as a symmetric rollback for managed profiles.
106
+ return enabledProfileFlag(environment, IMPEL_NATIVE_SLASH_COMMANDS_ENV, {
107
+ defaultEnabled: true,
108
+ });
101
109
  }
102
110
  const MAX_RETIRED_AGENT_BINDINGS = 50;
103
111
  const MAX_NATIVE_AGENT_STATE_BYTES = 512 * 1024;
@@ -919,6 +927,7 @@ function claudeParentDirectInstructions(tenantId, agent) {
919
927
  `Call ${nativeToolName(NATIVE_AGENT_ANSWER_TOOL)} exactly once with question set to the user's complete request. Do not spawn a relay subagent and do not perform the request yourself.`,
920
928
  `If the answer returns an object whose schema is exactly ${JSON.stringify(NATIVE_AGENT_CONTINUATION_SCHEMA)}, pass that complete object unchanged as the handle to ${nativeToolName(NATIVE_AGENT_RESUME_TOOL)} until terminal; after a continuation exists, never call the answer tool again.`,
921
929
  `On success, present the attribution line ${JSON.stringify(attribution)}, followed by the returned finalText verbatim with no rewriting, Markdown changes, or independent synthesis. On failure, attribute the failure to the same named managed agent and do not invent a replacement answer.`,
930
+ ...(usesVerbatimRelay(agent) ? [VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT] : []),
922
931
  ].join(" ");
923
932
  }
924
933
 
@@ -1124,6 +1133,7 @@ export function renderManagedAgents(client, tenantId, agents, invocation = null,
1124
1133
  policyFingerprint: nativeAgentPolicyFingerprint(agent),
1125
1134
  retired: false,
1126
1135
  name,
1136
+ ...(usesVerbatimRelay(agent) ? { verbatimRelay: true } : {}),
1127
1137
  fileName: parentDirect ? null : `${fileStem}${extension}`,
1128
1138
  contents,
1129
1139
  ...(claudeRendered ? { launchDefinition: claudeRendered.launchDefinition } : {}),
@@ -1167,6 +1177,7 @@ function renderClaudeSlashCommand(tenantId, agent, commandName, invocation) {
1167
1177
  "",
1168
1178
  `The JSON below came from managed agent ${JSON.stringify(agent.title)} (${agent.agentId}), not from the host model.`,
1169
1179
  `Run the fixed command exactly once. If ok is true, output the attribution line ${JSON.stringify(attribution)} followed by finalText verbatim. If ok is false, attribute the reported error to the same managed agent. Do not perform, rewrite, or independently answer the task.`,
1180
+ ...(usesVerbatimRelay(agent) ? [VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT] : []),
1170
1181
  "",
1171
1182
  `!\`${bashCommand}\``,
1172
1183
  "",
@@ -1669,22 +1680,23 @@ function profileIsFresh(profile, tenantId, now, ttlMs) {
1669
1680
  if (!Array.isArray(manifest.files) || !manifest.contentDigests
1670
1681
  || typeof manifest.contentDigests !== "object"
1671
1682
  || Array.isArray(manifest.contentDigests)) return false;
1683
+ const profileEnvironment = profile.environment ?? process.env;
1672
1684
  const expectedDirectProfiles = profile.client === "codex" && profile.directProfiles !== false;
1673
1685
  const expectedDirectCodeMode = profile.client === "codex" && profile.directCodeMode !== false;
1674
1686
  const expectedNativeParentDirect = profile.client === "claude"
1675
- && (profile.nativeParentDirect ?? nativeParentDirectEnabled());
1687
+ && (profile.nativeParentDirect ?? nativeParentDirectEnabled(profileEnvironment));
1676
1688
  const expectedNativeSlashCommands = profile.client === "claude"
1677
- && (profile.nativeSlashCommands ?? nativeSlashCommandsEnabled());
1689
+ && (profile.nativeSlashCommands ?? nativeSlashCommandsEnabled(profileEnvironment));
1678
1690
  const expectedNativeIntercept = profile.client === "claude"
1679
- && (profile.nativeIntercept ?? nativeInterceptEnabled());
1691
+ && (profile.nativeIntercept ?? nativeInterceptEnabled(profileEnvironment));
1680
1692
  const expectedNativeInterceptTimeoutMs = expectedNativeIntercept
1681
- ? (profile.nativeInterceptTimeoutMs ?? nativeInterceptTimeoutMs())
1693
+ ? (profile.nativeInterceptTimeoutMs ?? nativeInterceptTimeoutMs(profileEnvironment))
1682
1694
  : null;
1683
1695
  const expectedTelemetryEnvironment = nativeAgentTelemetryEnvironment(
1684
- profile.environment ?? process.env,
1696
+ profileEnvironment,
1685
1697
  );
1686
1698
  const expectedBenchmark = nativeBenchmarkHeaderValue(
1687
- profile.environment ?? process.env,
1699
+ profileEnvironment,
1688
1700
  ) !== null;
1689
1701
  if (manifest.directProfiles !== expectedDirectProfiles
1690
1702
  || manifest.directCodeMode !== expectedDirectCodeMode
@@ -1729,11 +1741,13 @@ export function syncAgentProfile({
1729
1741
  agents,
1730
1742
  directProfiles = client === "codex",
1731
1743
  directCodeMode = client === "codex",
1732
- nativeParentDirect = client === "claude" && nativeParentDirectEnabled(),
1733
- nativeSlashCommands = client === "claude" && nativeSlashCommandsEnabled(),
1734
- nativeIntercept = client === "claude" && nativeInterceptEnabled(),
1735
- nativeInterceptTimeoutMs: interceptTimeoutMs = nativeIntercept ? nativeInterceptTimeoutMs() : null,
1736
1744
  environment = process.env,
1745
+ nativeParentDirect = client === "claude" && nativeParentDirectEnabled(environment),
1746
+ nativeSlashCommands = client === "claude" && nativeSlashCommandsEnabled(environment),
1747
+ nativeIntercept = client === "claude" && nativeInterceptEnabled(environment),
1748
+ nativeInterceptTimeoutMs: interceptTimeoutMs = nativeIntercept
1749
+ ? nativeInterceptTimeoutMs(environment)
1750
+ : null,
1737
1751
  invocation = null,
1738
1752
  now = Date.now(),
1739
1753
  nativeAgentRunsRoot = path.join(CONFIG_DIR, "native-agent-runs"),
@@ -1966,6 +1980,7 @@ export function syncAgentProfile({
1966
1980
  policyFingerprint: agent.policyFingerprint,
1967
1981
  retired: agent.retired,
1968
1982
  name: agent.name,
1983
+ ...(agent.verbatimRelay ? { verbatimRelay: true } : {}),
1969
1984
  ...(client === "claude" ? {
1970
1985
  ...(agent.parentLaunchDefinition
1971
1986
  ? { launchMode: "parent-direct", parentLaunchDefinition: agent.parentLaunchDefinition }
package/src/apps.js CHANGED
@@ -302,6 +302,10 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
302
302
  // 35: enable the Code Mode host in managed desktop profiles (pinned Codex
303
303
  // fails closed on code_mode_only models without it) and serve CLI model
304
304
  // catalogs from the shared registry so efforts and tiers cannot drift.
305
+ // 36: install attributed /ask-<agent> slash commands for read-only direct
306
+ // answers in managed Claude profiles by default.
307
+ // 37: forbid external retrieval and citation edits on every opt-in
308
+ // verbatim-relay surface.
305
309
  export { CURRENT_CONFIG_VERSION };
306
310
 
307
311
  // Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
@@ -52,7 +52,6 @@ import {
52
52
  ensureWindowsClaudeApp,
53
53
  ensureWindowsChatGPTApp,
54
54
  findWindowsClaudeApp,
55
- findWindowsChatGPTApp,
56
55
  findManagedWindowsChatGPTApp,
57
56
  launchWindowsClaudeApp,
58
57
  pinnedWindowsChatGPTApp,
@@ -424,8 +423,11 @@ export async function reconcileWindowsTenantApps({
424
423
  // missing vendor app) must not abort the other target's work: install
425
424
  // recovery approves one vendor app at a time, and that approved install
426
425
  // has to run even while its sibling stays missing.
426
+ // `reason` distinguishes a real installer failure from the synthetic decline
427
+ // recorded when a caller (install recovery) approved only the sibling app, so
428
+ // callers can attribute each failure to the product that actually failed.
427
429
  const failed = [];
428
- const fail = (target, error) => failed.push({ target, error });
430
+ const fail = (target, error, reason = "install-failed") => failed.push({ target, error, reason });
429
431
  const hasFailed = (target) => failed.some((failure) => failure.target === target);
430
432
 
431
433
  for (const target of actionTargets) {
@@ -437,7 +439,11 @@ export async function reconcileWindowsTenantApps({
437
439
  if (!preparedTargets.has(target)) {
438
440
  const confirmed = await confirmVendorInstall(target, { platform: "win32", mode });
439
441
  if (!confirmed) {
440
- fail(target, `${label} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`);
442
+ fail(
443
+ target,
444
+ `${label} vendor ${mode === "update" ? "update" : "installation"} requires confirmation`,
445
+ "confirmation-required",
446
+ );
441
447
  continue;
442
448
  }
443
449
  const vendor = await ensure({ update: mode === "update" }, { environment, homeDir });
@@ -568,13 +574,13 @@ export async function reconcileMacTenantApps({
568
574
  // target so the sibling app still converges (install recovery approves one
569
575
  // vendor app at a time).
570
576
  const failed = [];
571
- const fail = (target, error) => failed.push({ target, error });
577
+ const fail = (target, error, reason = "install-failed") => failed.push({ target, error, reason });
572
578
  const hasFailed = (target) => failed.some((failure) => failure.target === target);
573
579
  for (const target of actionTargets.filter((target) => !preparedTargets.has(target))) {
574
580
  if (vendorPaths[target]) continue;
575
581
  const confirmed = await confirmVendorInstall(target, { platform: "darwin", mode });
576
582
  if (!confirmed) {
577
- fail(target, `${target} vendor installation requires confirmation`);
583
+ fail(target, `${target} vendor installation requires confirmation`, "confirmation-required");
578
584
  continue;
579
585
  }
580
586
  const result = await io.ensureVendor(target, { homeDir });
@@ -6,12 +6,12 @@ import {
6
6
  printReconciliationSummary,
7
7
  reconcileAllTenants,
8
8
  selectDefaultTenant,
9
- vendorAppTargetReady,
10
9
  } from "../provisioning.js";
11
10
  import { fetchTenants } from "../tenants.js";
12
11
  import { promptText } from "../prompt.js";
13
12
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
14
13
  import { restoreNativeProfiles } from "./use.js";
14
+ import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
15
15
 
16
16
  const RETRYABLE_TENANT_DISCOVERY_ERROR = /could not reach|request timed out|fetch failed|ECONNRESET|ECONNABORTED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|network error|socket/iu;
17
17
 
@@ -19,17 +19,6 @@ function confirmed(answer) {
19
19
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
20
20
  }
21
21
 
22
- function mergeTenantReport(report, replacement) {
23
- const byId = new Map(replacement.tenants.map((tenant) => [tenant.tenantId, tenant]));
24
- report.tenants = report.tenants.map((tenant) => byId.get(tenant.tenantId) || tenant);
25
- report.passed = report.tenants.every((tenant) => (
26
- ["ready", "unavailable"].includes(tenant.cli)
27
- && ["ready", "unavailable"].includes(tenant.apps)
28
- && ["ready", "unavailable"].includes(tenant.shell)
29
- ));
30
- return report;
31
- }
32
-
33
22
  export async function cmdConverge(argv = [], overrides = {}) {
34
23
  const skipApps = argv.includes("--skip-apps");
35
24
  const skipClis = argv.includes("--skip-clis");
@@ -264,12 +253,14 @@ export async function cmdConverge(argv = [], overrides = {}) {
264
253
  if (!tenant) continue;
265
254
  let retryReport = null;
266
255
  let retryPassed = false;
256
+ const recoveryReport = createTenantRecoveryReportTracker(report);
267
257
  const retryTenant = async (confirmInstall) => {
268
258
  retryReport = await run([tenant], !skipApps, confirmInstall);
269
259
  retryPassed = retryReport.passed;
270
260
  return retryPassed;
271
261
  };
272
262
  const retrySafeState = async () => {
263
+ recoveryReport.clearVendorAppAttempt();
273
264
  const profileReport = await run([tenant], false, async () => false);
274
265
  const profile = profileReport.tenants[0];
275
266
  const current = report.tenants.find((candidate) => candidate.tenantId === tenant.id);
@@ -321,23 +312,13 @@ export async function cmdConverge(argv = [], overrides = {}) {
321
312
  // sibling app still fails: the goal check keeps demanding full
322
313
  // tenant readiness, so recovery can approve one app at a time.
323
314
  installVendorApp: async (target) => {
324
- await retryTenant(async (product) => (
325
- target === "all"
326
- || product === target
327
- || (target === "codex" && product === "chatgpt")
328
- ));
329
- const installed = vendorAppTargetReady(retryReport, target);
330
- return {
331
- installed,
332
- error: installed
333
- ? null
334
- : retryReport?.tenants?.[0]?.errors?.join("; ")
335
- || `The ${target} vendor app did not reach a verified state.`,
336
- };
315
+ const attempt = recoveryReport.vendorAppAttempt(target);
316
+ await retryTenant(attempt.confirm);
317
+ return attempt.outcome(retryReport);
337
318
  },
338
319
  },
339
320
  }, overrides.recoveryOverrides || {});
340
- if (retryReport) mergeTenantReport(report, retryReport);
321
+ if (retryReport) report = recoveryReport.merge(retryReport);
341
322
  }
342
323
  }
343
324
  printReconciliationSummary(report);
@@ -16,7 +16,6 @@ import {
16
16
  printReconciliationSummary,
17
17
  reconcileAllTenants,
18
18
  selectDefaultTenant,
19
- vendorAppTargetReady,
20
19
  } from "../provisioning.js";
21
20
  import { describeCliFailure, preparePlatformClis } from "../platformSetup.js";
22
21
  import { runInstallRecovery } from "../installRecovery/engine.js";
@@ -27,7 +26,9 @@ import {
27
26
  cacheProviderReadiness,
28
27
  probeGatewayProviderReadiness,
29
28
  providerAvailable,
29
+ PROVIDER_READINESS_TIMEOUTS_MS,
30
30
  } from "../providerReadiness.js";
31
+ import { createTenantRecoveryReportTracker } from "./tenantRecoveryReport.js";
31
32
 
32
33
  const HELP = brandedText(`impel setup - prepare every accessible Impel tenant
33
34
 
@@ -57,7 +58,13 @@ export function resolveTenantChoice(listing, { requested = null, answer = null,
57
58
  return selectDefaultTenant(listing, { currentTenantId });
58
59
  }
59
60
 
60
- export async function probeGateway(gatewayUrl, pat, tenantId, fetchImpl = fetch, timeoutsMs = [5_000, 10_000]) {
61
+ export async function probeGateway(
62
+ gatewayUrl,
63
+ pat,
64
+ tenantId,
65
+ fetchImpl = fetch,
66
+ timeoutsMs = PROVIDER_READINESS_TIMEOUTS_MS,
67
+ ) {
61
68
  return probeGatewayProviderReadiness(
62
69
  gatewayUrl,
63
70
  tenantCredential(pat, tenantId),
@@ -112,12 +119,6 @@ function recomputeReport(report) {
112
119
  return report;
113
120
  }
114
121
 
115
- function mergeTenantReport(report, replacement) {
116
- const byId = new Map(replacement.tenants.map((tenant) => [tenant.tenantId, tenant]));
117
- report.tenants = report.tenants.map((tenant) => byId.get(tenant.tenantId) || tenant);
118
- return recomputeReport(report);
119
- }
120
-
121
122
  function confirmed(answer) {
122
123
  return /^(?:y|yes)$/iu.test(String(answer || "").trim());
123
124
  }
@@ -416,12 +417,14 @@ export async function cmdSetup(argv, overrides = {}) {
416
417
  if (!tenant) continue;
417
418
  let retryReport = null;
418
419
  let retryPassed = false;
420
+ const recoveryReport = createTenantRecoveryReportTracker(report);
419
421
  const retryTenant = async (confirmInstall) => {
420
422
  retryReport = await verifyConvergence(!flags["skip-apps"], [tenant], confirmInstall);
421
423
  retryPassed = retryReport.passed;
422
424
  return retryPassed;
423
425
  };
424
426
  const retrySafeState = async () => {
427
+ recoveryReport.clearVendorAppAttempt();
425
428
  const profileReport = await verifyConvergence(false, [tenant], async () => false);
426
429
  const profile = profileReport.tenants[0];
427
430
  const current = report.tenants.find((candidate) => candidate.tenantId === tenant.id);
@@ -474,23 +477,13 @@ export async function cmdSetup(argv, overrides = {}) {
474
477
  // sibling app still fails: the goal check keeps demanding full
475
478
  // tenant readiness, so recovery can approve one app at a time.
476
479
  installVendorApp: async (target) => {
477
- await retryTenant(async (product) => (
478
- target === "all"
479
- || product === target
480
- || (target === "codex" && product === "chatgpt")
481
- ));
482
- const installed = vendorAppTargetReady(retryReport, target);
483
- return {
484
- installed,
485
- error: installed
486
- ? null
487
- : retryReport?.tenants?.[0]?.errors?.join("; ")
488
- || `The ${target} vendor app did not reach a verified state.`,
489
- };
480
+ const attempt = recoveryReport.vendorAppAttempt(target);
481
+ await retryTenant(attempt.confirm);
482
+ return attempt.outcome(retryReport);
490
483
  },
491
484
  },
492
485
  }, overrides.recoveryOverrides || {});
493
- if (retryReport) mergeTenantReport(report, retryReport);
486
+ if (retryReport) report = recoveryReport.merge(retryReport);
494
487
  }
495
488
  }
496
489
  recomputeReport(report);
@@ -10,7 +10,15 @@ import {
10
10
  PRODUCT_ACCESS_WORKSPACE,
11
11
  } from "../tenants.js";
12
12
 
13
- const VALID_PROGRESS = new Set(["none", "todo", "progress", "review", "done"]);
13
+ export const TASK_PROGRESS_VALUES = [
14
+ "none",
15
+ "todo",
16
+ "progress",
17
+ "review",
18
+ "done",
19
+ "cancelled",
20
+ ];
21
+ const VALID_PROGRESS = new Set(TASK_PROGRESS_VALUES);
14
22
  const VALID_PRIORITY = new Set(["none", "urgent", "high", "medium", "low"]);
15
23
  const VALID_LABELS = new Set(["feature", "bug", "engineering", "design", "product"]);
16
24
 
@@ -35,7 +43,7 @@ Options:
35
43
  --description <markdown> Ticket body markdown.
36
44
  --description-file <path> Read ticket body markdown from a file.
37
45
  --description-mode replace|append Description update mode. Default: replace.
38
- --progress none|todo|progress|review|done
46
+ --progress none|todo|progress|review|done|cancelled
39
47
  --priority none|urgent|high|medium|low
40
48
  --labels <csv> Full label set, e.g. feature,engineering. Empty clears labels.
41
49
  --assignee <id|none> Assignee member id, or "none".
@@ -0,0 +1,101 @@
1
+ import {
2
+ recomputeTenantAggregates,
3
+ vendorAppTargetReady,
4
+ } from "../provisioning.js";
5
+
6
+ function reportPassed(tenants) {
7
+ return tenants.every((tenant) => (
8
+ ["ready", "unavailable"].includes(tenant.cli)
9
+ && ["ready", "unavailable"].includes(tenant.apps)
10
+ && ["ready", "unavailable"].includes(tenant.shell)
11
+ ));
12
+ }
13
+
14
+ function mergeTenantReports(report, replacement, products) {
15
+ const byId = new Map(replacement.tenants.map((tenant) => [tenant.tenantId, tenant]));
16
+ const tenants = report.tenants.map((tenant) => {
17
+ const attempted = byId.get(tenant.tenantId);
18
+ if (!attempted || !products) return attempted || tenant;
19
+
20
+ const clients = { ...attempted.clients };
21
+ const declined = [];
22
+ const restored = [];
23
+ for (const product of ["claude", "codex"]) {
24
+ if (products.includes(product)) continue;
25
+ const attempt = clients[product];
26
+ const prior = tenant.clients[product];
27
+ if (!prior || attempt?.appErrorReason !== "confirmation-required") continue;
28
+ if (attempt.appError) declined.push(attempt.appError);
29
+ clients[product] = {
30
+ ...attempt,
31
+ app: prior.app,
32
+ shell: prior.shell,
33
+ appError: prior.appError,
34
+ appErrorReason: prior.appErrorReason,
35
+ };
36
+ if (prior.appError) restored.push(prior.appError);
37
+ }
38
+ if (!declined.length && !restored.length) return attempted;
39
+
40
+ const kept = attempted.errors.filter((error) => !declined.includes(error));
41
+ // recomputeTenantAggregates mutates its argument, so give it a fresh
42
+ // tenant and client map rather than the replacement report's tenant.
43
+ return recomputeTenantAggregates({
44
+ ...attempted,
45
+ clients,
46
+ errors: [...new Set([...kept, ...restored])],
47
+ });
48
+ });
49
+ return { ...report, tenants, passed: reportPassed(tenants) };
50
+ }
51
+
52
+ function vendorAppOutcome(retryReport, target, products) {
53
+ const installed = vendorAppTargetReady(retryReport, target);
54
+ if (installed) return { installed: true, error: null };
55
+
56
+ const attempted = retryReport?.tenants?.[0];
57
+ const clients = attempted?.clients;
58
+ const attributed = Object.values(clients || {}).some((client) => client.appError);
59
+ // Reports without per-client attribution (no vendor step ran, or an older
60
+ // shape) still fall back to the tenant's complete error list.
61
+ const errors = attributed
62
+ ? products.map((product) => clients?.[product]?.appError).filter(Boolean)
63
+ : attempted?.errors || [];
64
+ return {
65
+ installed: false,
66
+ error: errors.join("; ") || `The ${target} vendor app did not reach a verified state.`,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Keep one tenant-recovery retry's product attribution and report merge in
72
+ * sync. A targeted vendor retry auto-declines its sibling, so the retry's
73
+ * outcome must use only the requested product while the final report restores
74
+ * the sibling's earlier, better-known state.
75
+ */
76
+ export function createTenantRecoveryReportTracker(report) {
77
+ let attemptedProducts = null;
78
+
79
+ return {
80
+ clearVendorAppAttempt() {
81
+ attemptedProducts = null;
82
+ },
83
+
84
+ vendorAppAttempt(target) {
85
+ const products = target === "all" ? ["claude", "codex"] : [target];
86
+ attemptedProducts = products;
87
+ return {
88
+ confirm: (product) => (
89
+ target === "all"
90
+ || product === target
91
+ || (target === "codex" && product === "chatgpt")
92
+ ),
93
+ outcome: (retryReport) => vendorAppOutcome(retryReport, target, products),
94
+ };
95
+ },
96
+
97
+ merge(replacement) {
98
+ return mergeTenantReports(report, replacement, attemptedProducts);
99
+ },
100
+ };
101
+ }
@@ -1,4 +1,4 @@
1
1
  // Fleet generation shared by managed-profile writers and upstream clients.
2
2
  // Keep this isolated from apps.js so latency-sensitive transports do not load
3
3
  // desktop bundle machinery just to identify their managed config contract.
4
- export const CURRENT_CONFIG_VERSION = 35;
4
+ export const CURRENT_CONFIG_VERSION = 37;
@@ -3,6 +3,7 @@ import { redactSecretText } from "./config.js";
3
3
  const PROVIDERS = new Set(["claude", "codex"]);
4
4
  const POOL_STATES = new Set(["healthy", "exhausted", "rate_limited", "expired", "no_seat"]);
5
5
  export const PROVIDER_READINESS_CACHE_MS = 60_000;
6
+ export const PROVIDER_READINESS_TIMEOUTS_MS = Object.freeze([5_000, 10_000]);
6
7
 
7
8
  function providersFromPayload(payload) {
8
9
  if (!Array.isArray(payload?.data)) return { error: "gateway model catalog has no model data array" };
@@ -57,7 +58,8 @@ async function probeGatewayOnce(gatewayUrl, credential, tenantId, fetchImpl, tim
57
58
  let payload;
58
59
  try {
59
60
  payload = await response.json();
60
- } catch {
61
+ } catch (error) {
62
+ if (error?.name === "AbortError") throw error;
61
63
  return { ...result, healthy: false, error: "gateway model catalog was not valid JSON" };
62
64
  }
63
65
  const catalog = providersFromPayload(payload);
@@ -87,7 +89,7 @@ export async function probeGatewayProviderReadiness(
87
89
  credential,
88
90
  tenantId,
89
91
  fetchImpl = fetch,
90
- timeoutsMs = [5_000, 10_000],
92
+ timeoutsMs = PROVIDER_READINESS_TIMEOUTS_MS,
91
93
  ) {
92
94
  let probe = { reachable: false, error: "not probed" };
93
95
  for (const timeoutMs of timeoutsMs) {
@@ -280,7 +280,16 @@ export async function reconcileAllTenants({
280
280
  ? "ready"
281
281
  : failedTargets.has("chatgpt") ? "failed" : "unsupported";
282
282
  result.apps = failedTargets.size ? "failed" : supported.size ? "ready" : "unavailable";
283
- for (const failure of failedApps) result.errors.push(redactSecretText(failure.error));
283
+ // Record each failure on its own client too. `errors` keeps every
284
+ // message for the summary line; the per-client facets let a targeted
285
+ // retry tell its own failure apart from an auto-declined sibling's.
286
+ for (const failure of failedApps) {
287
+ const message = redactSecretText(failure.error);
288
+ const client = failure.target === "claude" ? "claude" : "codex";
289
+ result.clients[client].appError = message;
290
+ result.clients[client].appErrorReason = failure.reason || "install-failed";
291
+ result.errors.push(message);
292
+ }
284
293
  } catch (error) {
285
294
  result.apps = "failed";
286
295
  for (const client of Object.values(result.clients)) {
@@ -360,6 +369,25 @@ export function vendorAppTargetReady(report, target) {
360
369
  return converged(target === "claude" ? clients.claude : clients.codex);
361
370
  }
362
371
 
372
+ /**
373
+ * Re-derive a tenant's aggregate app/shell/status fields from its per-client
374
+ * facets. Used after a partial merge swaps one product's facets back to an
375
+ * earlier, better-known state.
376
+ */
377
+ export function recomputeTenantAggregates(tenant) {
378
+ const clients = Object.values(tenant.clients);
379
+ const collapse = (states) => (
380
+ states.includes("failed") ? "failed"
381
+ : states.includes("ready") ? "ready"
382
+ : states.includes("pending") ? "pending" : "unavailable"
383
+ );
384
+ tenant.apps = collapse(clients.map((client) => client.app));
385
+ tenant.shell = collapse(clients.map((client) => client.shell));
386
+ const states = clients.flatMap((client) => [client.cli, client.app, client.shell]);
387
+ tenant.status = states.includes("failed") ? "failed" : states.includes("pending") ? "partial" : "ready";
388
+ return tenant;
389
+ }
390
+
363
391
  export function printReconciliationSummary(report, log = console.log) {
364
392
  log("Tenant readiness:");
365
393
  for (const tenant of report.tenants) {
@@ -13,6 +13,12 @@ export const VERBATIM_SPAWN_REQUIREMENT = 'fork_turns="none"';
13
13
  export const VERBATIM_FINAL_TEXT_CONSTRAINTS =
14
14
  "no preface, rewriting, Markdown changes, or independent synthesis";
15
15
 
16
+ // A relayed answer is authoritative only if nothing else reached it. Hosts that
17
+ // can browse will otherwise blend their own retrieval into the Sources section.
18
+ export const VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT =
19
+ "Never search the web, browse, open URLs, or consult any source outside this agent for the request, "
20
+ + "and never add, merge, reorder, or replace the sources and citations it returns.";
21
+
16
22
  export function usesVerbatimRelay(agent) {
17
23
  return agent?.verbatimRelay === true;
18
24
  }
@@ -21,7 +27,8 @@ export function parentVerbatimRelayAppendix() {
21
27
  return (
22
28
  `When an explicit custom agent's catalog-derived description declares ${VERBATIM_RELAY_OPT_IN_MARKER}, ` +
23
29
  `spawn it with ${VERBATIM_SPAWN_REQUIREMENT} and relay its finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}; ` +
24
- "preserve Sources sections and citations exactly. Start one child, then use one blocking wait sized for the child budget; " +
30
+ `preserve Sources sections and citations exactly. ${VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT} ` +
31
+ "Start one child, then use one blocking wait sized for the child budget; " +
25
32
  `on Codex, call wait_agent exactly once with timeout_ms=${VERBATIM_RELAY_PARENT_WAIT_MS}. Do not send follow-ups, short-poll the child, or synthesize while it is running. ` +
26
33
  "Custom agents without that declaration keep the default delegation behavior."
27
34
  );
@@ -37,7 +44,8 @@ export function customAgentVerbatimDescriptionLead() {
37
44
  export function adapterCallerSpawnGuidance(clientLabel) {
38
45
  return (
39
46
  `Callers must spawn this explicit custom ${clientLabel} agent with ${VERBATIM_SPAWN_REQUIREMENT} ` +
40
- "and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior."
47
+ "and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior. " +
48
+ VERBATIM_NO_EXTERNAL_SOURCE_CONSTRAINT
41
49
  );
42
50
  }
43
51