impel-cli 0.17.8 → 0.17.10
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 +1 -1
- package/src/apps.js +65 -8
- package/src/commands/apps.js +31 -4
- package/src/commands/converge.js +16 -3
- package/src/commands/nuke.js +6 -3
- package/src/commands/sessions.js +44 -13
- package/src/commands/update.js +32 -0
- package/src/sessionCollector.js +42 -1
- package/src/skills.js +55 -22
- package/src/updates.js +20 -2
package/package.json
CHANGED
package/src/apps.js
CHANGED
|
@@ -104,6 +104,39 @@ const CLAUDE_AGENT_MENTION_SELECT = 'onSelect:t=>(e(String(t)),Promise.resolve(n
|
|
|
104
104
|
const IMPEL_CLAUDE_BOUND_AGENT_MENTION_SELECT = 'onSelect:t=>"string"==typeof y?Promise.resolve({chipText:String(t)}):(e(String(t)),Promise.resolve(null))';
|
|
105
105
|
const LEGACY_CLAUDE_SAFE_STORAGE_NAME = "Claude";
|
|
106
106
|
const CLAUDE_SAFE_STORAGE_METADATA = "safe-storage.json";
|
|
107
|
+
// The pinned Claude renderer decides desktop-vs-web mode SOLELY by matching its
|
|
108
|
+
// own Electron user-agent against /claude(nest|gov)?\/([^ ]+)/i (functions o_,
|
|
109
|
+
// x_, i_ in ion-dist). Electron derives that UA product token from app.name as
|
|
110
|
+
// `${app.name}/${version}`, and our Safe Storage rename patch sets app.name to
|
|
111
|
+
// this Safe Storage name. So the name MUST yield a "…Claude/<version>" token or
|
|
112
|
+
// the app runs in web mode, where /epitaxy is rewritten to /code and the
|
|
113
|
+
// sunset claude_code_web gate strands every session on /code/disabled ("Code
|
|
114
|
+
// with Claude anywhere"). The token matches iff "claude" is immediately
|
|
115
|
+
// followed by "/", i.e. the app.name ends in "Claude". Keep tenant scoping by
|
|
116
|
+
// putting the tenant BEFORE the word, never after it.
|
|
117
|
+
const CLAUDE_UA_DESKTOP_PATTERN = /claude(nest|gov)?\/[^ ]+/iu;
|
|
118
|
+
|
|
119
|
+
/** The Electron UA token Electron builds from this app.name at the pinned version. */
|
|
120
|
+
function claudeUserAgentToken(appName) {
|
|
121
|
+
return `${appName}/${PINNED_VENDOR_APPS.claude.version}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Whether an app.name keeps the renderer in desktop mode (see pattern above). */
|
|
125
|
+
export function claudeAppNameKeepsDesktopMode(appName) {
|
|
126
|
+
return CLAUDE_UA_DESKTOP_PATTERN.test(claudeUserAgentToken(appName));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Tenant-scoped Safe Storage / app name that still ends in "Claude". */
|
|
130
|
+
function impelClaudeSafeStorageName(tenantId) {
|
|
131
|
+
return tenantId
|
|
132
|
+
? `Impel [${normalizeTenantId(tenantId)}] Claude`
|
|
133
|
+
: "Impel Claude";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The pre-0.17.9 format put the tenant AFTER "Claude" ("Impel Claude [tenant]"),
|
|
137
|
+
// which breaks the desktop-mode UA match. Recognize exactly the names this CLI
|
|
138
|
+
// used to write so we can heal them without touching an unrelated custom name.
|
|
139
|
+
const LEGACY_TRAPPED_CLAUDE_NAME = /^Impel Claude( \[[^\]]+\])?$/u;
|
|
107
140
|
|
|
108
141
|
export const FALLBACK_MODELS = [
|
|
109
142
|
{ id: "claude-opus-4-8", provider: "claude", display_name: "Claude Opus 4.8", family: "opus", family_default: true, default: true, context_window: 200000 },
|
|
@@ -174,7 +207,7 @@ export function managedAppIdentity(target, tenantId = null, tenantName = null) {
|
|
|
174
207
|
|
|
175
208
|
// Bump when the written config/manifest schema changes; a mismatch forces the
|
|
176
209
|
// slow open path (and thus a full config rewrite) after a CLI update.
|
|
177
|
-
export const CURRENT_CONFIG_VERSION =
|
|
210
|
+
export const CURRENT_CONFIG_VERSION = 18;
|
|
178
211
|
|
|
179
212
|
// Identifies the bundle-BUILDING logic — the asar patches, plist rewrites,
|
|
180
213
|
// helper rebranding, and signing. A vendored bundle is rebuilt only when this
|
|
@@ -183,7 +216,7 @@ export const CURRENT_CONFIG_VERSION = 17;
|
|
|
183
216
|
// — which is what made every `impel update` re-trigger macOS permission
|
|
184
217
|
// prompts. Bump this ONLY when a code change alters the bytes of a built
|
|
185
218
|
// bundle; leave it alone for changes that don't touch bundle contents.
|
|
186
|
-
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-21.
|
|
219
|
+
export const BUNDLE_BUILD_FINGERPRINT = "bundle-2026-07-21.2";
|
|
187
220
|
|
|
188
221
|
/** Parse the tenant's install manifest, or null when absent/corrupt. */
|
|
189
222
|
export function readTenantManifest(homeDir = os.homedir(), tenantId = null) {
|
|
@@ -494,20 +527,38 @@ function readClaudeSafeStorageMetadata(paths) {
|
|
|
494
527
|
}
|
|
495
528
|
|
|
496
529
|
/**
|
|
497
|
-
* Electron keys macOS Safe Storage by app.name
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
530
|
+
* Electron keys macOS Safe Storage by app.name, AND derives the renderer's
|
|
531
|
+
* user-agent (hence its desktop-vs-web mode) from it. Existing profiles already
|
|
532
|
+
* have encrypted data under `Claude Safe Storage`, so changing their name would
|
|
533
|
+
* make that data unreadable; they keep the legacy name (which is UA-safe). New
|
|
534
|
+
* profiles get an immutable, tenant-specific namespace that still ends in
|
|
535
|
+
* "Claude" so the renderer stays in desktop mode.
|
|
536
|
+
*
|
|
537
|
+
* A profile provisioned by 0.17.4–0.17.8 was written with the old
|
|
538
|
+
* "Impel Claude [tenant]" format, which forces the renderer into web mode and
|
|
539
|
+
* strands it on /code/disabled. Heal exactly those names in place: such
|
|
540
|
+
* profiles never worked, so they carry no Safe Storage secrets worth keeping.
|
|
501
541
|
*/
|
|
502
542
|
export function ensureClaudeSafeStorageName(paths, tenantId = null) {
|
|
503
543
|
const existing = readClaudeSafeStorageMetadata(paths);
|
|
544
|
+
if (existing && claudeAppNameKeepsDesktopMode(existing.appName)) return existing.appName;
|
|
545
|
+
const metadataPath = path.join(paths.claude.root, CLAUDE_SAFE_STORAGE_METADATA);
|
|
546
|
+
if (existing && LEGACY_TRAPPED_CLAUDE_NAME.test(existing.appName)) {
|
|
547
|
+
const healedName = impelClaudeSafeStorageName(tenantId);
|
|
548
|
+
writeAtomic(metadataPath, `${JSON.stringify({
|
|
549
|
+
schemaVersion: 1,
|
|
550
|
+
appName: healedName,
|
|
551
|
+
mode: existing.mode === "legacy" ? "legacy" : "tenant",
|
|
552
|
+
}, null, 2)}\n`, 0o600);
|
|
553
|
+
return healedName;
|
|
554
|
+
}
|
|
504
555
|
if (existing) return existing.appName;
|
|
505
556
|
const hasExistingProfile = fs.existsSync(paths.claude.userData)
|
|
506
557
|
&& fs.readdirSync(paths.claude.userData).length > 0;
|
|
507
558
|
const appName = hasExistingProfile
|
|
508
559
|
? LEGACY_CLAUDE_SAFE_STORAGE_NAME
|
|
509
|
-
:
|
|
510
|
-
writeAtomic(
|
|
560
|
+
: impelClaudeSafeStorageName(tenantId);
|
|
561
|
+
writeAtomic(metadataPath, `${JSON.stringify({
|
|
511
562
|
schemaVersion: 1,
|
|
512
563
|
appName,
|
|
513
564
|
mode: hasExistingProfile ? "legacy" : "tenant",
|
|
@@ -966,6 +1017,12 @@ function writeClaudeConfig(paths, config, models) {
|
|
|
966
1017
|
// with an inference config present the pinned app selects 3P and hides the
|
|
967
1018
|
// inapplicable Claude.ai sign-in option.
|
|
968
1019
|
disableDeploymentModeChooser: true,
|
|
1020
|
+
// The bundle is pinned to an exact vendor version whose renderer patches
|
|
1021
|
+
// this CLI depends on. Left enabled, the app's Squirrel auto-updater pulls
|
|
1022
|
+
// newer builds from api.anthropic.com (only a re-signing mismatch has been
|
|
1023
|
+
// stopping the install), which would silently move a tenant off the pinned
|
|
1024
|
+
// version and reintroduce the /code/disabled desktop trap fixed here.
|
|
1025
|
+
disableAutoUpdates: true,
|
|
969
1026
|
};
|
|
970
1027
|
writeAtomic(path.join(dir, `${CLAUDE_CONFIG_ID}.json`), JSON.stringify(body, null, 2) + "\n", 0o600);
|
|
971
1028
|
writeAtomic(path.join(dir, "_meta.json"), JSON.stringify({
|
package/src/commands/apps.js
CHANGED
|
@@ -99,21 +99,31 @@ export function selectCatalogAppTargets(
|
|
|
99
99
|
return supported;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Turn a tenant-aware bundle display name into an app-profile sync label:
|
|
103
|
+
// "Impel Claude (cibi)" -> "Impel Claude app (cibi)", "Impel Claude" ->
|
|
104
|
+
// "Impel Claude app". This keeps app-target sync/agent lines attributable to a
|
|
105
|
+
// tenant, matching the "Impel Claude CLI (cibi)" convention the CLI targets use,
|
|
106
|
+
// instead of the ambiguous tenant-less "Impel Claude app" repeated per tenant.
|
|
107
|
+
function appTargetLabel(displayName) {
|
|
108
|
+
const match = displayName.match(/^(.*?)( \(.*\))$/u);
|
|
109
|
+
return match ? `${match[1]} app${match[2]}` : `${displayName} app`;
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
// Each isolated desktop app maps to a client CLI + the env override that points
|
|
103
113
|
// that CLI at the app's private profile, so skill syncing lands in the app's
|
|
104
114
|
// installation rather than a global one.
|
|
105
115
|
function appSkillTarget(target, paths) {
|
|
106
116
|
if (target === "claude") {
|
|
107
|
-
return { client: "claude", env: { CLAUDE_CONFIG_DIR: paths.claude.userData }, label:
|
|
117
|
+
return { client: "claude", env: { CLAUDE_CONFIG_DIR: paths.claude.userData }, label: appTargetLabel(paths.claude.displayName) };
|
|
108
118
|
}
|
|
109
|
-
return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label:
|
|
119
|
+
return { client: "codex", env: { CODEX_HOME: paths.chatgpt.codexHome }, label: appTargetLabel(paths.chatgpt.displayName) };
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
function appAgentProfile(target, paths) {
|
|
113
123
|
if (target === "claude") {
|
|
114
|
-
return { client: "claude", root: paths.claude.userData, label:
|
|
124
|
+
return { client: "claude", root: paths.claude.userData, label: appTargetLabel(paths.claude.displayName) };
|
|
115
125
|
}
|
|
116
|
-
return { client: "codex", root: paths.chatgpt.codexHome, label:
|
|
126
|
+
return { client: "codex", root: paths.chatgpt.codexHome, label: appTargetLabel(paths.chatgpt.displayName) };
|
|
117
127
|
}
|
|
118
128
|
|
|
119
129
|
function windowsAppTargets(targetToken) {
|
|
@@ -613,6 +623,23 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
613
623
|
|
|
614
624
|
try {
|
|
615
625
|
if (action === "open") maybePrintUpdateNotice();
|
|
626
|
+
// Background token-helper refreshes pass --stale-only; honor the manifest
|
|
627
|
+
// TTL here like the darwin refresh path does, so every vendor token call
|
|
628
|
+
// does not become a full catalog fetch + profile rewrite (and its child
|
|
629
|
+
// process fan-out) on Windows.
|
|
630
|
+
if (action === "refresh" && flags["stale-only"]) {
|
|
631
|
+
const stored = loadConfig();
|
|
632
|
+
if (stored?.pat) {
|
|
633
|
+
const staleTenantId = flags.tenant
|
|
634
|
+
? normalizeTenantId(flags.tenant)
|
|
635
|
+
: stored.tenantId || null;
|
|
636
|
+
const stalePaths = appPaths(io.homeDir, staleTenantId, {
|
|
637
|
+
claudeUserData: io.claudeUserData(io.environment, staleTenantId),
|
|
638
|
+
tenantName: staleTenantId === stored.tenantId ? stored.tenantName : null,
|
|
639
|
+
});
|
|
640
|
+
if (manifestIsFresh(stalePaths, stored)) return true;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
616
643
|
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
617
644
|
const catalog = await withProgress("Fetching the tenant model catalog", () => (
|
|
618
645
|
fetchWindowsCatalog(config, io)
|
package/src/commands/converge.js
CHANGED
|
@@ -79,9 +79,22 @@ export async function cmdConverge(argv = [], overrides = {}) {
|
|
|
79
79
|
return false;
|
|
80
80
|
}
|
|
81
81
|
const label = target === "claude" ? "Claude" : "ChatGPT/Codex";
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
)
|
|
82
|
+
// Decline by default after 60s: an unanswered prompt in a forgotten
|
|
83
|
+
// terminal must not park the whole convergence (with its child processes
|
|
84
|
+
// and any background respawns) indefinitely. The prompt says so, and the
|
|
85
|
+
// question is re-asked on the next explicit run. The timer is cleared on
|
|
86
|
+
// answer (not unref'd) so the auto-decline reliably fires even when the
|
|
87
|
+
// prompt is the only thing keeping the event loop alive.
|
|
88
|
+
const allowed = confirmed(await new Promise((resolve) => {
|
|
89
|
+
const timer = setTimeout(() => resolve(""), io.vendorPromptTimeoutMs ?? 60_000);
|
|
90
|
+
const settle = (answer) => {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolve(answer);
|
|
93
|
+
};
|
|
94
|
+
Promise.resolve(io.promptText(
|
|
95
|
+
`Allow the verified ${label} vendor ${context.mode === "update" ? "update" : "installation"}? [y/N] (auto-N in 60s) `,
|
|
96
|
+
)).then(settle, () => settle(""));
|
|
97
|
+
}));
|
|
85
98
|
vendorAppDecisions.set(target, allowed);
|
|
86
99
|
return allowed;
|
|
87
100
|
};
|
package/src/commands/nuke.js
CHANGED
|
@@ -75,9 +75,12 @@ function keychainCandidates(appsRoot) {
|
|
|
75
75
|
const names = new Set();
|
|
76
76
|
const tenantsRoot = path.join(appsRoot, "tenants");
|
|
77
77
|
for (const tenantId of listEntries(tenantsRoot)) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// Cover both app-name eras: the current UA-safe "Impel [tenant] Claude"
|
|
79
|
+
// and the pre-0.17.9 "Impel Claude [tenant]" that may have left an orphaned
|
|
80
|
+
// Keychain item behind.
|
|
81
|
+
names.add(`Impel [${tenantId}] Claude Safe Storage`);
|
|
82
|
+
names.add(`Impel Claude [${tenantId}] Safe Storage`);
|
|
83
|
+
names.add(`Impel ChatGPT [${tenantId}] Safe Storage`);
|
|
81
84
|
const metadataPath = path.join(tenantsRoot, tenantId, "claude", "safe-storage.json");
|
|
82
85
|
try {
|
|
83
86
|
const appName = JSON.parse(fs.readFileSync(metadataPath, "utf8"))?.appName;
|
package/src/commands/sessions.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
import { parseFlags } from "../args.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clearSessionFlushLock,
|
|
6
|
+
collectSessionHook,
|
|
7
|
+
flushCollectedSession,
|
|
8
|
+
readHookInput,
|
|
9
|
+
sessionFlushLockIsFresh,
|
|
10
|
+
sessionFlushLockPath,
|
|
11
|
+
sessionOutboxStatus,
|
|
12
|
+
touchSessionFlushLock,
|
|
13
|
+
} from "../sessionCollector.js";
|
|
5
14
|
import { loadConfig, redactSecretText } from "../config.js";
|
|
6
15
|
import { impelCliInvocation } from "../selfInvocation.js";
|
|
7
16
|
|
|
@@ -57,11 +66,21 @@ export async function cmdSessions(argv) {
|
|
|
57
66
|
flush: false,
|
|
58
67
|
});
|
|
59
68
|
if (config && (config.tenantId === flags.tenant || process.env.IMPEL_SESSIONS_DEV_ORG_ID)) {
|
|
60
|
-
|
|
69
|
+
// Hooks fire on every session event; only spawn a flush child when no
|
|
70
|
+
// live one is already polling this session's outbox (heartbeat lock).
|
|
71
|
+
const lock = sessionFlushLockPath({
|
|
72
|
+
tenantId: flags.tenant,
|
|
61
73
|
provider: flags.provider,
|
|
62
|
-
|
|
63
|
-
session: String(input.session_id || ""),
|
|
74
|
+
sessionKey: String(input.session_id || ""),
|
|
64
75
|
});
|
|
76
|
+
if (!sessionFlushLockIsFresh(lock)) {
|
|
77
|
+
touchSessionFlushLock(lock);
|
|
78
|
+
startDetachedFlush({
|
|
79
|
+
provider: flags.provider,
|
|
80
|
+
tenant: flags.tenant,
|
|
81
|
+
session: String(input.session_id || ""),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
65
84
|
}
|
|
66
85
|
} catch (error) {
|
|
67
86
|
// Session persistence is observational. A collector outage must never
|
|
@@ -83,16 +102,28 @@ export async function cmdSessions(argv) {
|
|
|
83
102
|
if (!flags.provider || !flags.session || !flags.tenant) return;
|
|
84
103
|
if (!["claude_code", "codex"].includes(flags.provider)) return;
|
|
85
104
|
if (!config || flags.tenant !== config.tenantId && !process.env.IMPEL_SESSIONS_DEV_ORG_ID) return;
|
|
105
|
+
const lock = sessionFlushLockPath({
|
|
106
|
+
tenantId: flags.tenant,
|
|
107
|
+
provider: flags.provider,
|
|
108
|
+
sessionKey: flags.session,
|
|
109
|
+
});
|
|
86
110
|
const deadline = Date.now() + 90_000;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
try {
|
|
112
|
+
while (Date.now() < deadline) {
|
|
113
|
+
// Refresh the heartbeat so hook dispatch keeps skipping extra spawns
|
|
114
|
+
// while this child is alive.
|
|
115
|
+
touchSessionFlushLock(lock);
|
|
116
|
+
const result = await flushCollectedSession({
|
|
117
|
+
tenantId: flags.tenant,
|
|
118
|
+
provider: flags.provider,
|
|
119
|
+
sessionKey: flags.session,
|
|
120
|
+
config,
|
|
121
|
+
});
|
|
122
|
+
if (result.pending === 0) break;
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, result.error || result.busy ? 1000 : 250));
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
clearSessionFlushLock(lock);
|
|
96
127
|
}
|
|
97
128
|
return;
|
|
98
129
|
}
|
package/src/commands/update.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `impel update` — update the CLI, then let the freshly installed build
|
|
2
2
|
// reconcile every tenant and managed surface from the live control-plane list.
|
|
3
3
|
|
|
4
|
+
import fs from "node:fs";
|
|
4
5
|
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
|
|
@@ -20,6 +21,21 @@ import {
|
|
|
20
21
|
|
|
21
22
|
const CLI_BIN = fileURLToPath(new URL("../../bin/impel.js", import.meta.url));
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The version now on disk at the package that owns CLI_BIN, read FRESH (never
|
|
26
|
+
* from this process's cached module graph). After `npm install -g` this is the
|
|
27
|
+
* ground truth for whether the cascade would re-execute new code.
|
|
28
|
+
*/
|
|
29
|
+
export function postInstallCliVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const packagePath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
32
|
+
const version = JSON.parse(fs.readFileSync(packagePath, "utf8"))?.version;
|
|
33
|
+
return typeof version === "string" && version.trim() ? version.trim() : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
const HELP = `impel update - update everything Impel in one command
|
|
24
40
|
|
|
25
41
|
Reinstalls impel-cli from npm, then uses the new build to discover every
|
|
@@ -141,6 +157,7 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
141
157
|
platform: process.platform,
|
|
142
158
|
progress: withProgress,
|
|
143
159
|
recoverInstall: runInstallRecovery,
|
|
160
|
+
postInstallVersion: postInstallCliVersion,
|
|
144
161
|
loadConfig,
|
|
145
162
|
...overrides,
|
|
146
163
|
};
|
|
@@ -261,6 +278,21 @@ export async function cmdUpdate(argv, overrides = {}) {
|
|
|
261
278
|
}
|
|
262
279
|
console.log("CLI: recovery verified the update path.");
|
|
263
280
|
}
|
|
281
|
+
// npm exiting 0 is NOT proof the RUNNING install was updated: with multiple
|
|
282
|
+
// Node installs / npm prefixes (common on Windows), the install can land in
|
|
283
|
+
// a different global prefix while CLI_BIN — the path the cascade re-executes
|
|
284
|
+
// — still holds the old build. Cascading then runs old code that believes
|
|
285
|
+
// an update is still pending, which is how unbounded respawn storms start.
|
|
286
|
+
// Probe the version at CLI_BIN fresh from disk and refuse to cascade on skew.
|
|
287
|
+
const postInstall = io.postInstallVersion();
|
|
288
|
+
if (remote && postInstall !== remote) {
|
|
289
|
+
console.error(`impel update: npm reported success but the running CLI still resolves v${postInstall ?? "?"} (expected v${remote}).`);
|
|
290
|
+
console.error(` Running CLI: ${CLI_BIN}`);
|
|
291
|
+
console.error(" This usually means the `impel` on PATH belongs to a different npm prefix than `npm prefix -g`.");
|
|
292
|
+
console.error(" Fix: run `npm prefix -g`, confirm it owns the `impel` shim on PATH, then `npm install --global impel-cli@latest` there.");
|
|
293
|
+
process.exitCode = 1;
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
264
296
|
console.log(`CLI: updated${remote ? ` to v${remote}` : ""}.`);
|
|
265
297
|
}
|
|
266
298
|
|
package/src/sessionCollector.js
CHANGED
|
@@ -471,7 +471,9 @@ function captureTranscript(root, sessionMeta, input) {
|
|
|
471
471
|
|
|
472
472
|
function gitValue(cwd, args) {
|
|
473
473
|
try {
|
|
474
|
-
|
|
474
|
+
// windowsHide: this runs inside console-less detached flush children on
|
|
475
|
+
// Windows, where each git.exe would otherwise allocate a visible console.
|
|
476
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"], windowsHide: true });
|
|
475
477
|
return result.status === 0 ? result.stdout.trim().slice(0, 2048) : "";
|
|
476
478
|
} catch {
|
|
477
479
|
return "";
|
|
@@ -983,6 +985,45 @@ export async function readHookInput(stream = process.stdin) {
|
|
|
983
985
|
return value;
|
|
984
986
|
}
|
|
985
987
|
|
|
988
|
+
/**
|
|
989
|
+
* Heartbeat lock for the detached `sessions flush` child. Hooks fire on every
|
|
990
|
+
* session event; without this, each event stacks another detached child that
|
|
991
|
+
* polls the same outbox for up to 90 s — hundreds of concurrent processes on a
|
|
992
|
+
* busy session. The flush child refreshes the mtime while polling; hook
|
|
993
|
+
* dispatch skips the spawn while the heartbeat is fresh.
|
|
994
|
+
*/
|
|
995
|
+
export function sessionFlushLockPath({ tenantId, provider, sessionKey }) {
|
|
996
|
+
// NOT "flush.lock": acquireLock(root, "flush") owns that exact path (as a
|
|
997
|
+
// directory) for outbox batch mutual exclusion; this heartbeat is a separate,
|
|
998
|
+
// advisory spawn-rate limiter and must never collide with it.
|
|
999
|
+
return path.join(sessionDirectory(tenantId, provider, sessionKey), "flush-heartbeat");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
export function touchSessionFlushLock(target) {
|
|
1003
|
+
try {
|
|
1004
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
1005
|
+
fs.writeFileSync(target, `${process.pid}\n`, { mode: 0o600 });
|
|
1006
|
+
} catch {
|
|
1007
|
+
// Lock upkeep is best-effort; a missing lock only allows an extra child.
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function sessionFlushLockIsFresh(target, maxAgeMs = 15_000, now = Date.now()) {
|
|
1012
|
+
try {
|
|
1013
|
+
return now - fs.statSync(target).mtimeMs < maxAgeMs;
|
|
1014
|
+
} catch {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
export function clearSessionFlushLock(target) {
|
|
1020
|
+
try {
|
|
1021
|
+
fs.rmSync(target, { force: true });
|
|
1022
|
+
} catch {
|
|
1023
|
+
// Stale locks expire via mtime anyway.
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
986
1027
|
export function sessionOutboxStatus({ tenantId, provider, sessionKey }) {
|
|
987
1028
|
const root = sessionDirectory(tenantId, provider, sessionKey);
|
|
988
1029
|
return {
|
package/src/skills.js
CHANGED
|
@@ -27,6 +27,13 @@ import { nativeCommandInvocation } from "./nativeProcess.js";
|
|
|
27
27
|
/** The bundled plugin the Bifrost registry publishes; contains every served skill. */
|
|
28
28
|
export const SKILL_PLUGIN_NAME = "bifrost-all-skills";
|
|
29
29
|
|
|
30
|
+
// The marketplace `name` the gateway declares in every served marketplace.json
|
|
31
|
+
// (identical for the Claude and Codex flavors). Used only as a last resort when
|
|
32
|
+
// BOTH the manifest fetch and the `marketplace list --json` recovery fail:
|
|
33
|
+
// Codex's `plugin add` rejects a bare plugin id, so a known-name @-qualified
|
|
34
|
+
// install still has a chance where a bare add is guaranteed to error.
|
|
35
|
+
export const SKILL_MARKETPLACE_FALLBACK_NAME = "bifrost-skills";
|
|
36
|
+
|
|
30
37
|
/** Final fallback only — prefer the configured gateway (see resolveSkillsGateway). */
|
|
31
38
|
export const SKILLS_FALLBACK_GATEWAY_URL = "https://gateway.useimpel.ai";
|
|
32
39
|
|
|
@@ -150,32 +157,27 @@ export function buildSkillCommands({ client, marketplaceSourceUrl: sourceUrl, ma
|
|
|
150
157
|
args: ["plugin", "update", name ? `${plugin}@${name}` : plugin],
|
|
151
158
|
});
|
|
152
159
|
} else {
|
|
153
|
-
// Codex
|
|
154
|
-
//
|
|
155
|
-
//
|
|
160
|
+
// Codex resolves `plugin add PLUGIN@MARKETPLACE` from the marketplace
|
|
161
|
+
// snapshot cloned at registration time; a re-registration is a no-op that
|
|
162
|
+
// does NOT refresh it. So upgrade the snapshot BEFORE installing — installing
|
|
163
|
+
// first against a snapshot that predates the current bundle fails with
|
|
164
|
+
// "plugin … was not found in marketplace …", then the old code's trailing
|
|
165
|
+
// re-add silently fixed it, producing a spurious warning. With the name we
|
|
166
|
+
// upgrade then install once; without it, an @-qualified install against the
|
|
167
|
+
// known fallback name still beats a bare add, which Codex always rejects.
|
|
168
|
+
const marketplaceName = name || SKILL_MARKETPLACE_FALLBACK_NAME;
|
|
156
169
|
if (name) {
|
|
157
|
-
commands.push({
|
|
158
|
-
phase: "install",
|
|
159
|
-
description: `install ${plugin}`,
|
|
160
|
-
args: ["plugin", "add", `${plugin}@${name}`],
|
|
161
|
-
});
|
|
162
170
|
commands.push({
|
|
163
171
|
phase: "refresh-marketplace",
|
|
164
172
|
description: "refresh marketplace",
|
|
165
173
|
args: ["plugin", "marketplace", "upgrade", name],
|
|
166
174
|
});
|
|
167
|
-
commands.push({
|
|
168
|
-
phase: "refresh-plugin",
|
|
169
|
-
description: `update ${plugin}`,
|
|
170
|
-
args: ["plugin", "add", `${plugin}@${name}`],
|
|
171
|
-
});
|
|
172
|
-
} else {
|
|
173
|
-
commands.push({
|
|
174
|
-
phase: "install",
|
|
175
|
-
description: `install ${plugin}`,
|
|
176
|
-
args: ["plugin", "add", plugin],
|
|
177
|
-
});
|
|
178
175
|
}
|
|
176
|
+
commands.push({
|
|
177
|
+
phase: "install",
|
|
178
|
+
description: `install ${plugin}`,
|
|
179
|
+
args: ["plugin", "add", `${plugin}@${marketplaceName}`],
|
|
180
|
+
});
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
return commands;
|
|
@@ -210,6 +212,9 @@ export function runSkillCommand(bin, args, env, {
|
|
|
210
212
|
env: environment,
|
|
211
213
|
stdio: ["ignore", "pipe", "pipe"],
|
|
212
214
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
215
|
+
// One update run spawns ~150 of these cmd.exe-wrapped plugin commands
|
|
216
|
+
// across tenants; keep them off-screen on Windows.
|
|
217
|
+
windowsHide: true,
|
|
213
218
|
});
|
|
214
219
|
} catch (error) {
|
|
215
220
|
resolve({
|
|
@@ -292,10 +297,19 @@ async function runSkillCommandWithRetry(run, bin, args, env) {
|
|
|
292
297
|
return run(bin, args, env);
|
|
293
298
|
}
|
|
294
299
|
|
|
295
|
-
|
|
296
|
-
|
|
300
|
+
const MARKETPLACE_FETCH_TIMEOUT_MS = 30_000;
|
|
301
|
+
|
|
302
|
+
// The gateway serves marketplace.json from a serverless function with an 8-15s
|
|
303
|
+
// cold TTFB. The vendor Claude CLI aborts its OWN marketplace fetch at a
|
|
304
|
+
// hardcoded 10s, so a cold gateway makes `plugin marketplace update` time out.
|
|
305
|
+
// Fetching here first (with a generous timeout and a retry) both resolves the
|
|
306
|
+
// name and warms the endpoint so the vendor CLI's later fetch hits a warm cache.
|
|
307
|
+
// A process-lifetime memo keeps a multi-tenant run from re-racing the cold path.
|
|
308
|
+
const marketplaceNameCache = new Map();
|
|
309
|
+
|
|
310
|
+
async function fetchMarketplaceNameOnce(url, fetchImpl, timeoutMs) {
|
|
297
311
|
const controller = new AbortController();
|
|
298
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
312
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
299
313
|
try {
|
|
300
314
|
const response = await fetchImpl(url, { signal: controller.signal });
|
|
301
315
|
if (!response?.ok) return null;
|
|
@@ -308,6 +322,25 @@ export async function fetchMarketplaceName(url, fetchImpl = fetch) {
|
|
|
308
322
|
}
|
|
309
323
|
}
|
|
310
324
|
|
|
325
|
+
/** Fetch the marketplace.json and return its registered name, or null on any failure. */
|
|
326
|
+
export async function fetchMarketplaceName(url, fetchImpl = fetch, {
|
|
327
|
+
timeoutMs = MARKETPLACE_FETCH_TIMEOUT_MS,
|
|
328
|
+
useCache = true,
|
|
329
|
+
} = {}) {
|
|
330
|
+
if (useCache && marketplaceNameCache.has(url)) return marketplaceNameCache.get(url);
|
|
331
|
+
let name = await fetchMarketplaceNameOnce(url, fetchImpl, timeoutMs);
|
|
332
|
+
if (name === null) name = await fetchMarketplaceNameOnce(url, fetchImpl, timeoutMs);
|
|
333
|
+
// Only memoize a resolved name: a transient failure must not poison later
|
|
334
|
+
// syncs in the same run, but a warmed name is stable for the process lifetime.
|
|
335
|
+
if (useCache && name !== null) marketplaceNameCache.set(url, name);
|
|
336
|
+
return name;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Test-only: drop the per-process marketplace-name memo. */
|
|
340
|
+
export function clearMarketplaceNameCache() {
|
|
341
|
+
marketplaceNameCache.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
311
344
|
/**
|
|
312
345
|
* Idempotently sync the Bifrost shared-skills plugin into one managed client
|
|
313
346
|
* profile. Targets the SAME binary + profile the caller manages by passing the
|
package/src/updates.js
CHANGED
|
@@ -122,8 +122,15 @@ export function writeUpdateCache(patch) {
|
|
|
122
122
|
return next;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// A failed registry check must still count as "we tried": without stamping the
|
|
126
|
+
// failure, a machine with broken registry access has a permanently stale cache
|
|
127
|
+
// and EVERY TTY command respawns the detached refresh child — which on Windows
|
|
128
|
+
// used to mean one more console window per command, forever.
|
|
129
|
+
const UPDATE_CHECK_FAILURE_BACKOFF_MS = 60 * 60 * 1000;
|
|
130
|
+
|
|
125
131
|
export function cacheIsFresh(cache, now = Date.now()) {
|
|
126
|
-
|
|
132
|
+
if (cache?.checkedAt && now - cache.checkedAt < UPDATE_CHECK_TTL_MS) return true;
|
|
133
|
+
return Boolean(cache?.lastFailedAt && now - cache.lastFailedAt < UPDATE_CHECK_FAILURE_BACKOFF_MS);
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
/** Fetch npm's latest version and record it; returns the updated cache (or null). */
|
|
@@ -131,7 +138,14 @@ export async function refreshUpdateCache(dependencies = {}) {
|
|
|
131
138
|
const fetchLatest = dependencies.fetchRemoteVersion || fetchRemoteVersion;
|
|
132
139
|
const writeCache = dependencies.writeCache || writeUpdateCache;
|
|
133
140
|
const remoteVersion = await fetchLatest();
|
|
134
|
-
if (!remoteVersion)
|
|
141
|
+
if (!remoteVersion) {
|
|
142
|
+
try {
|
|
143
|
+
writeCache({ lastFailedAt: Date.now() });
|
|
144
|
+
} catch {
|
|
145
|
+
// The stamp is a spawn-rate limiter, not required state.
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
135
149
|
return writeCache({ remoteVersion, checkedAt: Date.now() });
|
|
136
150
|
}
|
|
137
151
|
|
|
@@ -178,6 +192,10 @@ function spawnDetached(args) {
|
|
|
178
192
|
const child = spawn(process.execPath, [path.join(CLI_ROOT, "bin", "impel.js"), ...args], {
|
|
179
193
|
detached: true,
|
|
180
194
|
stdio: "ignore",
|
|
195
|
+
// Windows: a detached console-subsystem child gets its OWN console — a
|
|
196
|
+
// visible Command Prompt window per spawn — unless it is hidden. This is
|
|
197
|
+
// the same option startDetachedFlush already passes.
|
|
198
|
+
windowsHide: true,
|
|
181
199
|
});
|
|
182
200
|
child.unref();
|
|
183
201
|
} catch {
|