impel-cli 0.17.9 → 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/commands/apps.js +17 -0
- package/src/commands/converge.js +16 -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 +3 -0
- package/src/updates.js +20 -2
package/package.json
CHANGED
package/src/commands/apps.js
CHANGED
|
@@ -623,6 +623,23 @@ export async function cmdWindowsApps(argv, overrides = {}) {
|
|
|
623
623
|
|
|
624
624
|
try {
|
|
625
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
|
+
}
|
|
626
643
|
const config = await io.selectedConfig(targets, flags.tenant || null);
|
|
627
644
|
const catalog = await withProgress("Fetching the tenant model catalog", () => (
|
|
628
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/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
|
@@ -212,6 +212,9 @@ export function runSkillCommand(bin, args, env, {
|
|
|
212
212
|
env: environment,
|
|
213
213
|
stdio: ["ignore", "pipe", "pipe"],
|
|
214
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,
|
|
215
218
|
});
|
|
216
219
|
} catch (error) {
|
|
217
220
|
resolve({
|
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 {
|