blun-king-cli 9.1.523 → 9.1.526
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/CHANGELOG.md +19 -0
- package/LIESMICH.txt +4 -2
- package/README.md +4 -2
- package/agent-spine-plugin/.claude-plugin/marketplace.json +1 -1
- package/agent-spine-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-spine-plugin/.codex-plugin/plugin.json +1 -2
- package/agent-spine-plugin/CHANGELOG.md +81 -14
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/README.md +6 -4
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/blun.plugin.json +1 -1
- package/agent-spine-plugin/docs/acceptance.md +2 -2
- package/agent-spine-plugin/docs/architecture.md +1 -1
- package/agent-spine-plugin/docs/gateway-runtime.md +2 -2
- package/agent-spine-plugin/docs/host-integration.md +2 -2
- package/agent-spine-plugin/docs/learning.md +37 -2
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/docs/quality-gates.md +1 -1
- package/agent-spine-plugin/docs/relationships.md +1 -1
- package/agent-spine-plugin/docs/session-briefing.md +1 -1
- package/agent-spine-plugin/docs/source-roots.md +1 -1
- package/agent-spine-plugin/hooks/version.json +1 -1
- package/agent-spine-plugin/package.json +1 -1
- package/agent-spine-plugin/scripts/check-hosts.js +2 -2
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +3 -3
- package/agent-spine-plugin/src/cli.js +69 -5
- package/agent-spine-plugin/src/hook.js +41 -23
- package/agent-spine-plugin/src/index.js +2 -1
- package/agent-spine-plugin/src/lib/acceptance.js +2 -3
- package/agent-spine-plugin/src/lib/attention.js +4 -4
- package/agent-spine-plugin/src/lib/audit.js +12 -3
- package/agent-spine-plugin/src/lib/authentication.js +4 -2
- package/agent-spine-plugin/src/lib/briefing.js +20 -8
- package/agent-spine-plugin/src/lib/catalog.js +28 -3
- package/agent-spine-plugin/src/lib/channel-runtime.js +2 -2
- package/agent-spine-plugin/src/lib/continuity.js +18 -13
- package/agent-spine-plugin/src/lib/documents.js +23 -5
- package/agent-spine-plugin/src/lib/feed-transport.js +4 -2
- package/agent-spine-plugin/src/lib/graph.js +47 -12
- package/agent-spine-plugin/src/lib/learning.js +422 -48
- package/agent-spine-plugin/src/lib/paths.js +50 -1
- package/agent-spine-plugin/src/lib/persona-runtime.js +20 -14
- package/agent-spine-plugin/src/lib/preflight.js +50 -26
- package/agent-spine-plugin/src/lib/source-roots.js +38 -7
- package/agent-spine-plugin/src/mcp.js +50 -4
- package/agent-spine-plugin/src/version.js +1 -1
- package/bin/reload-plugin-bootstrap.cjs +18 -0
- package/blun.mjs +92 -4
- package/package.json +7 -2
- package/scripts/check-bundled-agent-spine-regression.js +48 -0
- package/scripts/check-reload-agent-spine-regression.js +76 -0
- package/scripts/check-session-picker-resume-metrics-regression.js +97 -0
- package/scripts/check-shell-terminal-isolation-regression.js +81 -0
- package/scripts/check-telegram-loop-exactly-once-regression.js +71 -0
package/blun.mjs
CHANGED
|
@@ -316411,7 +316411,7 @@ function buildLocalSpawnOptions(isWindows, cwd, env) {
|
|
|
316411
316411
|
"pipe",
|
|
316412
316412
|
"pipe"
|
|
316413
316413
|
],
|
|
316414
|
-
detached:
|
|
316414
|
+
detached: true,
|
|
316415
316415
|
windowsHide: true
|
|
316416
316416
|
};
|
|
316417
316417
|
}
|
|
@@ -317353,6 +317353,7 @@ var init_core_impl = __esmMin((() => {
|
|
|
317353
317353
|
if (active?.hasActiveTurn === true) throw new BlunError(ErrorCodes.TURN_AGENT_BUSY, `Session "${summary.id}" cannot be reloaded while a turn is running`, { details: { sessionId: summary.id } });
|
|
317354
317354
|
this.reloadProviderManager();
|
|
317355
317355
|
this.clearRuntimeCache();
|
|
317356
|
+
createRequire(import.meta.url)("./bin/reload-plugin-bootstrap.cjs").installBundledAgentSpineForReload({ blunDir: this.homeDir });
|
|
317356
317357
|
await this.reloadPlugins({});
|
|
317357
317358
|
if (active !== void 0) {
|
|
317358
317359
|
await active.closeForReload();
|
|
@@ -424165,7 +424166,8 @@ async function handleLoopCommand(host, args) {
|
|
|
424165
424166
|
host.setAppState?.({ loop });
|
|
424166
424167
|
host.track("loop_start", { interval: loop.interval });
|
|
424167
424168
|
host.showStatus(`/loop ${loop.status}`);
|
|
424168
|
-
if (
|
|
424169
|
+
if (host.telegramRemoteCommandContext !== void 0) host.sendNormalUserInput(loop.prompt);
|
|
424170
|
+
else if (isStreaming(host) || host.state.appState.isCompacting) host.enqueueNormalUserInput(loop.prompt);
|
|
424169
424171
|
else host.sendNormalUserInput(loop.prompt);
|
|
424170
424172
|
return;
|
|
424171
424173
|
}
|
|
@@ -500660,6 +500662,10 @@ registerUiCatalogFragment({
|
|
|
500660
500662
|
"sessionPicker.time.daysAgo": "{count}d ago",
|
|
500661
500663
|
"sessionPicker.current": "current",
|
|
500662
500664
|
"sessionPicker.imported": "imported",
|
|
500665
|
+
"sessionPicker.resume.metrics": "Resume {resumeSize} · {messages} messages · {tokens} tokens · Wire {wireSize}",
|
|
500666
|
+
"sessionPicker.resume.missing": "checkpoint missing",
|
|
500667
|
+
"sessionPicker.resume.stale": "checkpoint stale",
|
|
500668
|
+
"sessionPicker.resume.invalid": "checkpoint invalid",
|
|
500663
500669
|
"sessionPicker.footer.matches": "Showing {from}-{to} of {loaded} loaded / {matches} matches",
|
|
500664
500670
|
"sessionPicker.footer.sessions": "Showing {from}-{to} of {count} sessions",
|
|
500665
500671
|
"sessionPicker.footer.loadedSessions": "Showing {from}-{to} of {loaded} loaded / {total} sessions"
|
|
@@ -500684,6 +500690,10 @@ registerUiCatalogFragment({
|
|
|
500684
500690
|
"sessionPicker.time.daysAgo": "vor {count} T.",
|
|
500685
500691
|
"sessionPicker.current": "aktuell",
|
|
500686
500692
|
"sessionPicker.imported": "importiert",
|
|
500693
|
+
"sessionPicker.resume.metrics": "Resume {resumeSize} · {messages} Nachrichten · {tokens} Tokens · Wire {wireSize}",
|
|
500694
|
+
"sessionPicker.resume.missing": "Checkpoint fehlt",
|
|
500695
|
+
"sessionPicker.resume.stale": "Checkpoint veraltet",
|
|
500696
|
+
"sessionPicker.resume.invalid": "Checkpoint ungültig",
|
|
500687
500697
|
"sessionPicker.footer.matches": "Anzeige {from}-{to} · {loaded} geladen · {matches} Treffer",
|
|
500688
500698
|
"sessionPicker.footer.sessions": "Anzeige {from}-{to} von {count} Sitzungen",
|
|
500689
500699
|
"sessionPicker.footer.loadedSessions": "Anzeige {from}-{to} · {loaded} von {total} Sitzungen geladen"
|
|
@@ -500708,6 +500718,10 @@ registerUiCatalogFragment({
|
|
|
500708
500718
|
"sessionPicker.time.daysAgo": "hace {count} d",
|
|
500709
500719
|
"sessionPicker.current": "actual",
|
|
500710
500720
|
"sessionPicker.imported": "importada",
|
|
500721
|
+
"sessionPicker.resume.metrics": "Reanudación {resumeSize} · {messages} mensajes · {tokens} tokens · Wire {wireSize}",
|
|
500722
|
+
"sessionPicker.resume.missing": "Falta el punto de reanudación",
|
|
500723
|
+
"sessionPicker.resume.stale": "Punto de reanudación desactualizado",
|
|
500724
|
+
"sessionPicker.resume.invalid": "Punto de reanudación no válido",
|
|
500711
500725
|
"sessionPicker.footer.matches": "Mostrando {from}-{to} · {loaded} cargadas · {matches} coincidencias",
|
|
500712
500726
|
"sessionPicker.footer.sessions": "Mostrando {from}-{to} de {count} sesiones",
|
|
500713
500727
|
"sessionPicker.footer.loadedSessions": "Mostrando {from}-{to} · {loaded} de {total} sesiones cargadas"
|
|
@@ -500732,6 +500746,10 @@ registerUiCatalogFragment({
|
|
|
500732
500746
|
"sessionPicker.time.daysAgo": "il y a {count} j",
|
|
500733
500747
|
"sessionPicker.current": "actuelle",
|
|
500734
500748
|
"sessionPicker.imported": "importée",
|
|
500749
|
+
"sessionPicker.resume.metrics": "Reprise {resumeSize} · {messages} messages · {tokens} tokens · Wire {wireSize}",
|
|
500750
|
+
"sessionPicker.resume.missing": "Point de reprise manquant",
|
|
500751
|
+
"sessionPicker.resume.stale": "Point de reprise obsolète",
|
|
500752
|
+
"sessionPicker.resume.invalid": "Point de reprise non valide",
|
|
500735
500753
|
"sessionPicker.footer.matches": "Affichage de {from}-{to} · {loaded} chargées · {matches} résultats",
|
|
500736
500754
|
"sessionPicker.footer.sessions": "Affichage de {from}-{to} sur {count} sessions",
|
|
500737
500755
|
"sessionPicker.footer.loadedSessions": "Affichage de {from}-{to} · {loaded} sessions chargées sur {total}"
|
|
@@ -500756,6 +500774,10 @@ registerUiCatalogFragment({
|
|
|
500756
500774
|
"sessionPicker.time.daysAgo": "för {count} d sedan",
|
|
500757
500775
|
"sessionPicker.current": "aktuell",
|
|
500758
500776
|
"sessionPicker.imported": "importerad",
|
|
500777
|
+
"sessionPicker.resume.metrics": "Återupptagning {resumeSize} · {messages} meddelanden · {tokens} token · Wire {wireSize}",
|
|
500778
|
+
"sessionPicker.resume.missing": "Återupptagningspunkt saknas",
|
|
500779
|
+
"sessionPicker.resume.stale": "Återupptagningspunkt är inaktuell",
|
|
500780
|
+
"sessionPicker.resume.invalid": "Ogiltig återupptagningspunkt",
|
|
500759
500781
|
"sessionPicker.footer.matches": "Visar {from}-{to} · {loaded} inlästa · {matches} träffar",
|
|
500760
500782
|
"sessionPicker.footer.sessions": "Visar {from}-{to} av {count} sessioner",
|
|
500761
500783
|
"sessionPicker.footer.loadedSessions": "Visar {from}-{to} · {loaded} av {total} sessioner inlästa"
|
|
@@ -500780,6 +500802,10 @@ registerUiCatalogFragment({
|
|
|
500780
500802
|
"sessionPicker.time.daysAgo": "před {count} d",
|
|
500781
500803
|
"sessionPicker.current": "aktuální",
|
|
500782
500804
|
"sessionPicker.imported": "importováno",
|
|
500805
|
+
"sessionPicker.resume.metrics": "Obnovení {resumeSize} · {messages} zpráv · {tokens} tokenů · Wire {wireSize}",
|
|
500806
|
+
"sessionPicker.resume.missing": "Bod obnovení chybí",
|
|
500807
|
+
"sessionPicker.resume.stale": "Bod obnovení je zastaralý",
|
|
500808
|
+
"sessionPicker.resume.invalid": "Neplatný bod obnovení",
|
|
500783
500809
|
"sessionPicker.footer.matches": "Zobrazeno {from}–{to}; načteno {loaded}, shod {matches}",
|
|
500784
500810
|
"sessionPicker.footer.sessions": "Zobrazeno {from}–{to} z {count} relací",
|
|
500785
500811
|
"sessionPicker.footer.loadedSessions": "Zobrazeno {from}–{to}; načteno {loaded} z {total} relací"
|
|
@@ -501034,6 +501060,21 @@ var SessionPickerComponent = class extends Container {
|
|
|
501034
501060
|
const promptLine = indent + currentTheme.fg("textDim", promptMarker + promptText);
|
|
501035
501061
|
card.push(promptLine);
|
|
501036
501062
|
}
|
|
501063
|
+
const resume = session.resume;
|
|
501064
|
+
if (resume !== void 0) {
|
|
501065
|
+
if (resume.status === "missing") card.push(indent + currentTheme.fg("warning", uiText("sessionPicker.resume.missing")));
|
|
501066
|
+
else if (resume.status === "invalid") card.push(indent + currentTheme.fg("error", uiText("sessionPicker.resume.invalid")));
|
|
501067
|
+
else {
|
|
501068
|
+
const metrics = uiText("sessionPicker.resume.metrics", {
|
|
501069
|
+
resumeSize: formatByteSize(resume.resumeBytes),
|
|
501070
|
+
messages: resume.messages.toLocaleString(getCurrentUiLocale()),
|
|
501071
|
+
tokens: formatLiveTokenCount(resume.tokens),
|
|
501072
|
+
wireSize: formatByteSize(resume.wireBytes)
|
|
501073
|
+
});
|
|
501074
|
+
const suffix = resume.status === "stale" ? ` · ${uiText("sessionPicker.resume.stale")}` : "";
|
|
501075
|
+
card.push(indent + currentTheme.fg(resume.status === "stale" ? "warning" : "textMuted", truncateToWidth(metrics + suffix, Math.max(8, width - indentWidth), ELLIPSIS$4)));
|
|
501076
|
+
}
|
|
501077
|
+
}
|
|
501037
501078
|
return card;
|
|
501038
501079
|
}
|
|
501039
501080
|
};
|
|
@@ -516682,9 +516723,56 @@ function sessionRowsForPicker(sessions, currentSessionId, currentSessionHasConte
|
|
|
516682
516723
|
last_prompt: session.lastPrompt ?? null,
|
|
516683
516724
|
work_dir: session.workDir,
|
|
516684
516725
|
updated_at: session.updatedAt ?? session.createdAt ?? 0,
|
|
516685
|
-
metadata: session.metadata
|
|
516726
|
+
metadata: session.metadata,
|
|
516727
|
+
session_dir: session.sessionDir
|
|
516686
516728
|
}));
|
|
516687
516729
|
}
|
|
516730
|
+
const SESSION_RESUME_METRICS_CONCURRENCY = 8;
|
|
516731
|
+
const SESSION_RESUME_STALE_AFTER_MS = 6e4;
|
|
516732
|
+
async function statSessionFile(filePath) {
|
|
516733
|
+
try {
|
|
516734
|
+
return await stat(filePath);
|
|
516735
|
+
} catch {
|
|
516736
|
+
return null;
|
|
516737
|
+
}
|
|
516738
|
+
}
|
|
516739
|
+
async function readSessionResumeMetrics(session) {
|
|
516740
|
+
if (typeof session.session_dir !== "string" || session.session_dir.length === 0) return { status: "missing" };
|
|
516741
|
+
const agentDir = join(session.session_dir, "agents", "main");
|
|
516742
|
+
const wirePath = join(agentDir, "wire.jsonl");
|
|
516743
|
+
const checkpointPath = join(agentDir, "resume-checkpoint.json");
|
|
516744
|
+
const [resumeStat, wireStat] = await Promise.all([
|
|
516745
|
+
statSessionFile(checkpointPath),
|
|
516746
|
+
statSessionFile(wirePath)
|
|
516747
|
+
]);
|
|
516748
|
+
if (resumeStat === null) return { status: "missing" };
|
|
516749
|
+
if (wireStat === null) return { status: "invalid" };
|
|
516750
|
+
const checkpoint = await loadResumeCheckpoint({ wirePath }).catch(() => ({ ok: false }));
|
|
516751
|
+
if (!checkpoint.ok) return { status: "invalid" };
|
|
516752
|
+
const context = checkpoint.snapshot?.context;
|
|
516753
|
+
const messages = Array.isArray(context?.history) ? context.history.length : Number.isSafeInteger(context?.tokenCountCoveredMessageCount) ? context.tokenCountCoveredMessageCount : 0;
|
|
516754
|
+
const tokens = Number.isSafeInteger(context?.tokenCount) ? context.tokenCount : 0;
|
|
516755
|
+
const stale = wireStat.size > checkpoint.offset && wireStat.mtimeMs - resumeStat.mtimeMs > SESSION_RESUME_STALE_AFTER_MS;
|
|
516756
|
+
return {
|
|
516757
|
+
status: stale ? "stale" : "ok",
|
|
516758
|
+
resumeBytes: resumeStat.size,
|
|
516759
|
+
wireBytes: wireStat.size,
|
|
516760
|
+
messages,
|
|
516761
|
+
tokens
|
|
516762
|
+
};
|
|
516763
|
+
}
|
|
516764
|
+
async function enrichSessionRowsForPicker(rows) {
|
|
516765
|
+
let nextIndex = 0;
|
|
516766
|
+
const workerCount = Math.min(SESSION_RESUME_METRICS_CONCURRENCY, rows.length);
|
|
516767
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
516768
|
+
while (nextIndex < rows.length) {
|
|
516769
|
+
const index = nextIndex++;
|
|
516770
|
+
const row = rows[index];
|
|
516771
|
+
if (row !== void 0) row.resume = await readSessionResumeMetrics(row);
|
|
516772
|
+
}
|
|
516773
|
+
}));
|
|
516774
|
+
return rows;
|
|
516775
|
+
}
|
|
516688
516776
|
//#endregion
|
|
516689
516777
|
//#region src/tui/utils/startup.ts
|
|
516690
516778
|
function combineStartupNotice(existing, next) {
|
|
@@ -519889,7 +519977,7 @@ var BlunTUI = class {
|
|
|
519889
519977
|
this.state.sessionsScope = scope;
|
|
519890
519978
|
try {
|
|
519891
519979
|
const sessions = scope === "all" ? await this.harness.listSessions({}) : await this.harness.listSessions({ workDir: this.state.appState.workDir });
|
|
519892
|
-
this.state.sessions = sessionRowsForPicker(sessions, this.state.appState.sessionId, this.hasSessionContent());
|
|
519980
|
+
this.state.sessions = await enrichSessionRowsForPicker(sessionRowsForPicker(sessions, this.state.appState.sessionId, this.hasSessionContent()));
|
|
519893
519981
|
} catch {} finally {
|
|
519894
519982
|
this.state.loadingSessions = false;
|
|
519895
519983
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blun-king-cli",
|
|
3
|
-
"version": "9.1.
|
|
3
|
+
"version": "9.1.526",
|
|
4
4
|
"description": "BLUN CLI - your own AI agent with a Telegram channel. Get it done. With BLUN.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"test": "node --test test/*.test.js",
|
|
12
|
-
"prepack": "node scripts/check-release-metadata.js && node scripts/check-todo-loop-regression.js && node scripts/check-todo-recovery-catalog-regression.js && node scripts/check-historical-tool-result-preview-regression.js && node scripts/check-session-start-hook-context-regression.js && node scripts/check-queue-controls-regression.js && node scripts/check-approval-queue-shortcuts-regression.js && node scripts/check-approval-observability-regression.js && node scripts/check-slash-escape-regression.js && node scripts/check-telegram-bridge-watchdog.js && node scripts/check-resume-replay-regression.js && node scripts/check-session-cancel-regression.js && node scripts/check-plugin-startup-regression.js && node scripts/check-active-profile-plugin-startup.js && node scripts/check-mcp-startup-wait-budget.js",
|
|
12
|
+
"prepack": "node scripts/check-release-metadata.js && node scripts/check-shell-terminal-isolation-regression.js && node scripts/check-todo-loop-regression.js && node scripts/check-telegram-loop-exactly-once-regression.js && node scripts/check-session-picker-resume-metrics-regression.js && node scripts/check-bundled-agent-spine-regression.js && node scripts/check-reload-agent-spine-regression.js && node scripts/check-todo-recovery-catalog-regression.js && node scripts/check-historical-tool-result-preview-regression.js && node scripts/check-session-start-hook-context-regression.js && node scripts/check-queue-controls-regression.js && node scripts/check-approval-queue-shortcuts-regression.js && node scripts/check-approval-observability-regression.js && node scripts/check-slash-escape-regression.js && node scripts/check-telegram-bridge-watchdog.js && node scripts/check-resume-replay-regression.js && node scripts/check-session-cancel-regression.js && node scripts/check-plugin-startup-regression.js && node scripts/check-active-profile-plugin-startup.js && node scripts/check-mcp-startup-wait-budget.js",
|
|
13
13
|
"release:verify": "node scripts/check-release-metadata.js --external",
|
|
14
14
|
"postinstall": "node scripts/fix-node-pty-perms.js"
|
|
15
15
|
},
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
"scripts/check-package-regression.js",
|
|
39
39
|
"scripts/check-active-profile-plugin-startup.js",
|
|
40
40
|
"scripts/check-approval-queue-shortcuts-regression.js",
|
|
41
|
+
"scripts/check-bundled-agent-spine-regression.js",
|
|
42
|
+
"scripts/check-reload-agent-spine-regression.js",
|
|
41
43
|
"scripts/check-approval-observability-regression.js",
|
|
42
44
|
"scripts/check-slash-escape-regression.js",
|
|
43
45
|
"scripts/check-mcp-startup-wait-budget.js",
|
|
@@ -46,9 +48,12 @@
|
|
|
46
48
|
"scripts/check-telegram-bridge-watchdog.js",
|
|
47
49
|
"scripts/check-resume-replay-regression.js",
|
|
48
50
|
"scripts/check-session-cancel-regression.js",
|
|
51
|
+
"scripts/check-shell-terminal-isolation-regression.js",
|
|
52
|
+
"scripts/check-session-picker-resume-metrics-regression.js",
|
|
49
53
|
"scripts/check-release-metadata.js",
|
|
50
54
|
"scripts/check-historical-tool-result-preview-regression.js",
|
|
51
55
|
"scripts/check-session-start-hook-context-regression.js",
|
|
56
|
+
"scripts/check-telegram-loop-exactly-once-regression.js",
|
|
52
57
|
"scripts/check-todo-recovery-catalog-regression.js",
|
|
53
58
|
"scripts/check-todo-loop-regression.js",
|
|
54
59
|
"scripts/fix-node-pty-perms.js",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const { execFileSync } = require('node:child_process');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const expectedVersion = '0.11.4';
|
|
7
|
+
const pluginRoot = process.env.BLUN_AGENT_SPINE_ROOT_UNDER_TEST
|
|
8
|
+
? path.resolve(process.env.BLUN_AGENT_SPINE_ROOT_UNDER_TEST)
|
|
9
|
+
: path.resolve(__dirname, '..', 'agent-spine-plugin');
|
|
10
|
+
|
|
11
|
+
function readJson(relativePath) {
|
|
12
|
+
return JSON.parse(fs.readFileSync(path.join(pluginRoot, relativePath), 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const relativePath of [
|
|
16
|
+
'package.json',
|
|
17
|
+
'hooks/version.json',
|
|
18
|
+
'.claude-plugin/plugin.json',
|
|
19
|
+
'.codex-plugin/plugin.json',
|
|
20
|
+
'blun.plugin.json',
|
|
21
|
+
]) {
|
|
22
|
+
assert.equal(
|
|
23
|
+
readJson(relativePath).version,
|
|
24
|
+
expectedVersion,
|
|
25
|
+
`BUNDLED_AGENT_SPINE_REGRESSION: ${relativePath} must declare ${expectedVersion}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const runtimeVersion = execFileSync(
|
|
30
|
+
process.execPath,
|
|
31
|
+
[path.join(pluginRoot, 'bin', 'agentspine.js'), '--version'],
|
|
32
|
+
{ encoding: 'utf8' },
|
|
33
|
+
).trim();
|
|
34
|
+
assert.equal(
|
|
35
|
+
runtimeVersion,
|
|
36
|
+
expectedVersion,
|
|
37
|
+
'BUNDLED_AGENT_SPINE_REGRESSION: packaged runtime version is stale',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
for (const relativePath of ['CONTRIBUTING.md', 'SECURITY.md']) {
|
|
41
|
+
assert.equal(
|
|
42
|
+
fs.existsSync(path.join(pluginRoot, relativePath)),
|
|
43
|
+
true,
|
|
44
|
+
`BUNDLED_AGENT_SPINE_REGRESSION: README link target ${relativePath} is missing`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(`bundled-agent-spine-regression PASS (${expectedVersion})`);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const rootArg = process.argv.find((value) => value.startsWith('--root='));
|
|
9
|
+
const packageRoot = rootArg ? path.resolve(rootArg.slice('--root='.length)) : path.resolve(__dirname, '..');
|
|
10
|
+
const bundle = fs.readFileSync(path.join(packageRoot, 'blun.mjs'), 'utf8');
|
|
11
|
+
const reloadStart = bundle.indexOf('async reloadSession(input) {');
|
|
12
|
+
const bootstrapCall = bundle.indexOf('installBundledAgentSpineForReload({ blunDir: this.homeDir });', reloadStart);
|
|
13
|
+
const pluginReload = bundle.indexOf('await this.reloadPlugins({});', reloadStart);
|
|
14
|
+
|
|
15
|
+
assert.notEqual(reloadStart, -1, 'reloadSession must exist');
|
|
16
|
+
assert.ok(
|
|
17
|
+
bootstrapCall > reloadStart && bootstrapCall < pluginReload,
|
|
18
|
+
'/reload must materialize bundled AgentSpine before reloading the plugin catalogue',
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const helperPath = path.join(packageRoot, 'bin', 'reload-plugin-bootstrap.cjs');
|
|
22
|
+
assert.ok(fs.existsSync(helperPath), 'reload plugin bootstrap helper must be packaged');
|
|
23
|
+
const { installBundledAgentSpineForReload } = require(helperPath);
|
|
24
|
+
|
|
25
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'blun-reload-agent-spine-'));
|
|
26
|
+
const profile = path.join(root, 'profile');
|
|
27
|
+
const pluginsDir = path.join(profile, 'plugins');
|
|
28
|
+
const installedFile = path.join(pluginsDir, 'installed.json');
|
|
29
|
+
const unrelatedRoot = path.join(root, 'unrelated-plugin');
|
|
30
|
+
fs.mkdirSync(pluginsDir, { recursive: true });
|
|
31
|
+
fs.mkdirSync(unrelatedRoot, { recursive: true });
|
|
32
|
+
fs.writeFileSync(installedFile, `${JSON.stringify({
|
|
33
|
+
version: 1,
|
|
34
|
+
plugins: [{
|
|
35
|
+
id: 'unrelated',
|
|
36
|
+
root: unrelatedRoot,
|
|
37
|
+
source: 'local-path',
|
|
38
|
+
enabled: false,
|
|
39
|
+
capabilities: {},
|
|
40
|
+
}],
|
|
41
|
+
}, null, 2)}\n`);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const first = installBundledAgentSpineForReload({
|
|
45
|
+
packageRoot,
|
|
46
|
+
blunDir: profile,
|
|
47
|
+
now: '2026-09-01T00:00:00.000Z',
|
|
48
|
+
});
|
|
49
|
+
const installed = JSON.parse(fs.readFileSync(installedFile, 'utf8'));
|
|
50
|
+
const agentSpine = installed.plugins.find((plugin) => plugin.id === 'agent-spine');
|
|
51
|
+
const unrelated = installed.plugins.find((plugin) => plugin.id === 'unrelated');
|
|
52
|
+
|
|
53
|
+
assert.ok(agentSpine, 'reload bootstrap must add bundled AgentSpine');
|
|
54
|
+
assert.equal(agentSpine.root, first.pluginRoot);
|
|
55
|
+
assert.equal(agentSpine.enabled, true);
|
|
56
|
+
assert.equal(agentSpine.capabilities.mcpServers['agent-spine'].enabled, true);
|
|
57
|
+
assert.equal(unrelated.root, unrelatedRoot, 'reload bootstrap must preserve unrelated plugins');
|
|
58
|
+
assert.equal(unrelated.enabled, false, 'reload bootstrap must preserve unrelated plugin state');
|
|
59
|
+
assert.equal(
|
|
60
|
+
fs.readFileSync(path.join(agentSpine.root, 'package.json'), 'utf8'),
|
|
61
|
+
fs.readFileSync(path.join(packageRoot, 'agent-spine-plugin', 'package.json'), 'utf8'),
|
|
62
|
+
'managed AgentSpine must come from the current package bundle',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const beforeSecondRun = fs.readFileSync(installedFile, 'utf8');
|
|
66
|
+
const second = installBundledAgentSpineForReload({
|
|
67
|
+
packageRoot,
|
|
68
|
+
blunDir: profile,
|
|
69
|
+
now: '2026-09-01T00:01:00.000Z',
|
|
70
|
+
});
|
|
71
|
+
assert.equal(second.reused, true, 'an unchanged bundle must be reused');
|
|
72
|
+
assert.equal(fs.readFileSync(installedFile, 'utf8'), beforeSecondRun, 'idempotent reload must not rewrite installed.json');
|
|
73
|
+
process.stdout.write('Reload AgentSpine regression gate: PASS\n');
|
|
74
|
+
} finally {
|
|
75
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
76
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const bundlePath = process.env.BLUN_BUNDLE_UNDER_TEST
|
|
6
|
+
? path.resolve(process.env.BLUN_BUNDLE_UNDER_TEST)
|
|
7
|
+
: path.join(__dirname, '..', 'blun.mjs');
|
|
8
|
+
const bundle = fs.readFileSync(bundlePath, 'utf8');
|
|
9
|
+
|
|
10
|
+
const rowsMatch = /function sessionRowsForPicker\(sessions, currentSessionId, currentSessionHasContent\) \{([\s\S]*?)\n\}/.exec(bundle);
|
|
11
|
+
assert(rowsMatch, 'SESSION_PICKER_RESUME_METRICS: sessionRowsForPicker is missing');
|
|
12
|
+
const sessionRowsForPicker = Function(
|
|
13
|
+
`return function sessionRowsForPicker(sessions, currentSessionId, currentSessionHasContent) {${rowsMatch[1]}\n};`,
|
|
14
|
+
)();
|
|
15
|
+
|
|
16
|
+
const rows = sessionRowsForPicker([{
|
|
17
|
+
id: 'session-a',
|
|
18
|
+
title: 'A',
|
|
19
|
+
lastPrompt: 'work',
|
|
20
|
+
workDir: 'C:/work',
|
|
21
|
+
updatedAt: 1,
|
|
22
|
+
metadata: {},
|
|
23
|
+
sessionDir: 'C:/state/session-a',
|
|
24
|
+
}], 'other', true);
|
|
25
|
+
assert.equal(rows[0].session_dir, 'C:/state/session-a');
|
|
26
|
+
|
|
27
|
+
const metricsMatch = /async function readSessionResumeMetrics\(session\) \{([\s\S]*?)\n\}\nasync function enrichSessionRowsForPicker/.exec(bundle);
|
|
28
|
+
assert(metricsMatch, 'SESSION_PICKER_RESUME_METRICS: readSessionResumeMetrics is missing');
|
|
29
|
+
|
|
30
|
+
function buildReader({ resumeStat, wireStat, checkpoint }) {
|
|
31
|
+
return Function(
|
|
32
|
+
'join',
|
|
33
|
+
'statSessionFile',
|
|
34
|
+
'loadResumeCheckpoint',
|
|
35
|
+
'SESSION_RESUME_STALE_AFTER_MS',
|
|
36
|
+
`return async function readSessionResumeMetrics(session) {${metricsMatch[1]}\n};`,
|
|
37
|
+
)(
|
|
38
|
+
path.join,
|
|
39
|
+
async (filePath) => filePath.endsWith('resume-checkpoint.json') ? resumeStat : wireStat,
|
|
40
|
+
async () => checkpoint,
|
|
41
|
+
60_000,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
(async () => {
|
|
46
|
+
const missing = await buildReader({ resumeStat: null, wireStat: null, checkpoint: null })({
|
|
47
|
+
session_dir: 'C:/state/session-a',
|
|
48
|
+
});
|
|
49
|
+
assert.deepEqual(missing, { status: 'missing' });
|
|
50
|
+
|
|
51
|
+
const invalid = await buildReader({
|
|
52
|
+
resumeStat: { size: 1_024, mtimeMs: 1_000 },
|
|
53
|
+
wireStat: { size: 2_048, mtimeMs: 1_000 },
|
|
54
|
+
checkpoint: { ok: false },
|
|
55
|
+
})({ session_dir: 'C:/state/session-a' });
|
|
56
|
+
assert.deepEqual(invalid, { status: 'invalid' });
|
|
57
|
+
|
|
58
|
+
const ok = await buildReader({
|
|
59
|
+
resumeStat: { size: 1_024, mtimeMs: 1_000 },
|
|
60
|
+
wireStat: { size: 2_048, mtimeMs: 1_500 },
|
|
61
|
+
checkpoint: {
|
|
62
|
+
ok: true,
|
|
63
|
+
offset: 2_048,
|
|
64
|
+
snapshot: { context: { history: [{}, {}, {}], tokenCount: 1_200 } },
|
|
65
|
+
},
|
|
66
|
+
})({ session_dir: 'C:/state/session-a' });
|
|
67
|
+
assert.deepEqual(ok, {
|
|
68
|
+
status: 'ok',
|
|
69
|
+
resumeBytes: 1_024,
|
|
70
|
+
wireBytes: 2_048,
|
|
71
|
+
messages: 3,
|
|
72
|
+
tokens: 1_200,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const stale = await buildReader({
|
|
76
|
+
resumeStat: { size: 1_024, mtimeMs: 1_000 },
|
|
77
|
+
wireStat: { size: 4_096, mtimeMs: 62_000 },
|
|
78
|
+
checkpoint: {
|
|
79
|
+
ok: true,
|
|
80
|
+
offset: 2_048,
|
|
81
|
+
snapshot: { context: { history: [{}], tokenCount: 500 } },
|
|
82
|
+
},
|
|
83
|
+
})({ session_dir: 'C:/state/session-a' });
|
|
84
|
+
assert.equal(stale.status, 'stale');
|
|
85
|
+
assert.equal(stale.wireBytes, 4_096);
|
|
86
|
+
|
|
87
|
+
const renderBlock = /const resume = session\.resume;([\s\S]*?)\n\t\treturn card;/.exec(bundle);
|
|
88
|
+
assert(renderBlock, 'SESSION_PICKER_RESUME_METRICS: resume metrics are not rendered');
|
|
89
|
+
assert.match(renderBlock[1], /formatByteSize\(resume\.resumeBytes\)/);
|
|
90
|
+
assert.match(renderBlock[1], /formatByteSize\(resume\.wireBytes\)/);
|
|
91
|
+
assert.match(renderBlock[1], /sessionPicker\.resume\.stale/);
|
|
92
|
+
|
|
93
|
+
console.log('session-picker-resume-metrics-regression PASS');
|
|
94
|
+
})().catch((error) => {
|
|
95
|
+
console.error(error);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const vm = require('node:vm');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
|
|
9
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
10
|
+
const bundlePath = process.argv[2] === undefined
|
|
11
|
+
? path.join(packageRoot, 'blun.mjs')
|
|
12
|
+
: path.resolve(process.argv[2]);
|
|
13
|
+
const bundle = fs.readFileSync(bundlePath, 'utf8');
|
|
14
|
+
const source = /function buildLocalSpawnOptions\(isWindows, cwd, env\) \{[\s\S]*?\n\}/.exec(bundle)?.[0];
|
|
15
|
+
|
|
16
|
+
assert(source, 'SHELL_TERMINAL_ISOLATION: buildLocalSpawnOptions is missing');
|
|
17
|
+
|
|
18
|
+
const context = {};
|
|
19
|
+
vm.runInNewContext(`${source}; globalThis.buildLocalSpawnOptions = buildLocalSpawnOptions;`, context);
|
|
20
|
+
const buildLocalSpawnOptions = context.buildLocalSpawnOptions;
|
|
21
|
+
const cwd = packageRoot;
|
|
22
|
+
const env = { ...process.env, BLUN_SHELL_ISOLATION_PROBE: '1' };
|
|
23
|
+
|
|
24
|
+
for (const isWindows of [false, true]) {
|
|
25
|
+
const options = buildLocalSpawnOptions(isWindows, cwd, env);
|
|
26
|
+
assert.deepEqual(Array.from(options.stdio), ['pipe', 'pipe', 'pipe']);
|
|
27
|
+
assert.equal(options.detached, true,
|
|
28
|
+
`local shell must start in a separate process group when isWindows=${String(isWindows)}`);
|
|
29
|
+
assert.equal(options.windowsHide, true);
|
|
30
|
+
assert.equal(options.cwd, cwd);
|
|
31
|
+
assert.equal(options.env, env);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runCapturedProcessProbe() {
|
|
35
|
+
const options = buildLocalSpawnOptions(process.platform === 'win32', cwd, env);
|
|
36
|
+
const child = spawn(process.execPath, [
|
|
37
|
+
'-e',
|
|
38
|
+
[
|
|
39
|
+
"process.stdin.setEncoding('utf8')",
|
|
40
|
+
"process.stdin.on('data', () => { process.exitCode = 90 })",
|
|
41
|
+
"process.stdin.on('end', () => {",
|
|
42
|
+
" process.stdout.write('stdout-ok')",
|
|
43
|
+
" process.stderr.write('stderr-ok')",
|
|
44
|
+
'})',
|
|
45
|
+
].join(';'),
|
|
46
|
+
], options);
|
|
47
|
+
|
|
48
|
+
let stdout = '';
|
|
49
|
+
let stderr = '';
|
|
50
|
+
child.stdout.setEncoding('utf8');
|
|
51
|
+
child.stderr.setEncoding('utf8');
|
|
52
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
53
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
54
|
+
child.stdin.end();
|
|
55
|
+
|
|
56
|
+
const exit = await new Promise((resolve, reject) => {
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
child.kill();
|
|
59
|
+
reject(new Error('captured child did not observe stdin EOF'));
|
|
60
|
+
}, 10_000);
|
|
61
|
+
child.once('error', (error) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
reject(error);
|
|
64
|
+
});
|
|
65
|
+
child.once('close', (code, signal) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
resolve({ code, signal });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(exit, { code: 0, signal: null });
|
|
72
|
+
assert.equal(stdout, 'stdout-ok');
|
|
73
|
+
assert.equal(stderr, 'stderr-ok');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
runCapturedProcessProbe().then(() => {
|
|
77
|
+
process.stdout.write('shell-terminal-isolation-regression PASS\n');
|
|
78
|
+
}).catch((error) => {
|
|
79
|
+
console.error(error);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const bundlePath = process.env.BLUN_BUNDLE_UNDER_TEST
|
|
6
|
+
? path.resolve(process.env.BLUN_BUNDLE_UNDER_TEST)
|
|
7
|
+
: path.join(__dirname, '..', 'blun.mjs');
|
|
8
|
+
const bundle = fs.readFileSync(bundlePath, 'utf8');
|
|
9
|
+
const match = /async function handleLoopCommand\(host, args\) \{([\s\S]*?)\n\}\nfunction hasRunnableSession/.exec(bundle);
|
|
10
|
+
assert(match, 'TELEGRAM_LOOP_EXACTLY_ONCE: handleLoopCommand is missing');
|
|
11
|
+
|
|
12
|
+
const handleLoopCommand = Function(
|
|
13
|
+
'parseLoopCommand',
|
|
14
|
+
'hasRunnableSession',
|
|
15
|
+
'llmNotSetMessage',
|
|
16
|
+
'isStreaming',
|
|
17
|
+
`return async function handleLoopCommand(host, args) {${match[1]}\n};`,
|
|
18
|
+
)(
|
|
19
|
+
() => ({ kind: 'create', interval: '*/5 * * * *', prompt: 'work once' }),
|
|
20
|
+
() => true,
|
|
21
|
+
() => 'missing model',
|
|
22
|
+
(host) => host.streaming,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function createHost({ telegram = false, streaming = true } = {}) {
|
|
26
|
+
const calls = { send: [], enqueue: [] };
|
|
27
|
+
const session = {
|
|
28
|
+
async createLoop({ interval, prompt }) {
|
|
29
|
+
return { interval, prompt, status: 'active' };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const host = {
|
|
33
|
+
streaming,
|
|
34
|
+
session,
|
|
35
|
+
telegramRemoteCommandContext: telegram ? { item: { telegramCommandName: 'loop' } } : undefined,
|
|
36
|
+
state: { appState: { isCompacting: false, model: 'blun/king' } },
|
|
37
|
+
requireSession() { return session; },
|
|
38
|
+
setAppState() {},
|
|
39
|
+
track() {},
|
|
40
|
+
showStatus() {},
|
|
41
|
+
showError(message) { throw new Error(message); },
|
|
42
|
+
sendNormalUserInput(text) { calls.send.push(text); },
|
|
43
|
+
enqueueNormalUserInput(text) { calls.enqueue.push(text); },
|
|
44
|
+
};
|
|
45
|
+
return { host, calls };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
(async () => {
|
|
49
|
+
const telegram = createHost({ telegram: true, streaming: true });
|
|
50
|
+
await handleLoopCommand(telegram.host, '5 min work once');
|
|
51
|
+
assert.deepEqual(telegram.calls.send, ['work once']);
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
telegram.calls.enqueue,
|
|
54
|
+
[],
|
|
55
|
+
'TELEGRAM_LOOP_EXACTLY_ONCE: Telegram loop payload must not survive in the normal queue',
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const local = createHost({ telegram: false, streaming: true });
|
|
59
|
+
await handleLoopCommand(local.host, '5 min work once');
|
|
60
|
+
assert.deepEqual(local.calls.send, []);
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
local.calls.enqueue,
|
|
63
|
+
['work once'],
|
|
64
|
+
'TELEGRAM_LOOP_EXACTLY_ONCE: local busy-loop behavior must stay queued',
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
console.log('telegram-loop-exactly-once-regression PASS');
|
|
68
|
+
})().catch((error) => {
|
|
69
|
+
console.error(error);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
});
|