@oh-my-pi/pi-coding-agent 16.2.3 → 16.2.4
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 +11 -0
- package/dist/cli.js +2671 -2660
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/status-line/component.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/session/session-manager.d.ts +2 -3
- package/package.json +12 -12
- package/src/autolearn/controller.ts +13 -22
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/provider-globals.ts +25 -0
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/memories/index.ts +115 -9
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/status-line/component.ts +6 -0
- package/src/modes/controllers/command-controller.ts +5 -35
- package/src/modes/controllers/event-controller.ts +7 -1
- package/src/modes/interactive-mode.ts +9 -20
- package/src/modes/types.ts +2 -1
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/session/agent-session.ts +32 -1
- package/src/session/session-manager.ts +2 -3
- package/src/slash-commands/builtin-registry.ts +7 -21
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -27,6 +27,21 @@ interface UpdateMethodResolutionOptions {
|
|
|
27
27
|
miseDataDir?: string;
|
|
28
28
|
}
|
|
29
29
|
export declare function resolveUpdateMethodForTest(ompPath: string, bunBinDir: string | undefined, options?: UpdateMethodResolutionOptions): UpdateMethod;
|
|
30
|
+
interface BunInstallCachePruneResult {
|
|
31
|
+
scannedPackages: number;
|
|
32
|
+
removedEntries: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Prune Bun's package cache so each package keeps only its newest cached version.
|
|
36
|
+
*
|
|
37
|
+
* Bun stores package cache entries as both a package marker directory
|
|
38
|
+
* (`react/19.2.6@@@1`) and a materialized package directory
|
|
39
|
+
* (`react@19.2.6@@@1`). Global `omp` updates can leave one full copy per
|
|
40
|
+
* release. The marker and materialized entries are removed together so the
|
|
41
|
+
* cache stays internally consistent.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pruneBunInstallCache(cacheDir: string, packageNames?: Set<string>): Promise<BunInstallCachePruneResult>;
|
|
44
|
+
export declare function resolveBunGlobalNodeModulesDirFromLocations(globalBinDir: string | undefined, cacheDir: string | undefined): string | undefined;
|
|
30
45
|
/**
|
|
31
46
|
* Best-effort removal of binary-update backups left by earlier runs.
|
|
32
47
|
*
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard-cap helper for host→guest collab frames.
|
|
3
|
+
*
|
|
4
|
+
* The host wraps every {@link CollabFrame} in an AES-GCM envelope and ships it
|
|
5
|
+
* through the relay's WebSocket. WebSocket servers enforce a per-frame
|
|
6
|
+
* `maxPayloadLength` (Bun's default is 16 MB; many proxies cap lower). A
|
|
7
|
+
* single oversized payload — typically a `read`/`bash`/`search` tool result
|
|
8
|
+
* captured as one multi-megabyte string, or a tool result whose `content`
|
|
9
|
+
* array holds thousands of small blocks — would otherwise ship as its own
|
|
10
|
+
* oversized frame and trip that limit, killing the host's WebSocket with
|
|
11
|
+
* `1006 Received too big message`. `CollabSocket` treats 1006 as transient
|
|
12
|
+
* and reconnects, the next guest hello triggers the same oversized send, and
|
|
13
|
+
* the loop never breaks (issue #3739).
|
|
14
|
+
*
|
|
15
|
+
* This helper bounds any JSON-serializable payload below
|
|
16
|
+
* {@link MAX_REPLICATED_PAYLOAD_BYTES}. Already-small payloads pass through
|
|
17
|
+
* untouched; oversized ones are returned as a deep-cloned shadow where long
|
|
18
|
+
* strings are head-truncated AND long arrays are head-clipped, with
|
|
19
|
+
* `[…N chars elided for collab session]` / `[…N items elided for collab
|
|
20
|
+
* session]` markers. Both axes are needed: string truncation alone leaves
|
|
21
|
+
* the cap unenforced for a payload built of many short strings, where no
|
|
22
|
+
* field exceeds the per-string floor.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Per-payload ceiling for host→guest frames. Bun's default WebSocket
|
|
26
|
+
* `maxPayloadLength` is 16 MB; we leave a generous margin so the AES-GCM
|
|
27
|
+
* envelope (+ IV + tag), the 4-byte peer header, and the outer wire wrapper
|
|
28
|
+
* fit comfortably under that on every reasonable relay.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MAX_REPLICATED_PAYLOAD_BYTES: number;
|
|
31
|
+
/**
|
|
32
|
+
* Return `value` unchanged when its JSON serialization already fits
|
|
33
|
+
* {@link MAX_REPLICATED_PAYLOAD_BYTES}; otherwise return a deep-cloned
|
|
34
|
+
* shadow shrunk along both string and array axes until the payload fits.
|
|
35
|
+
* The function is generic over `T` because the wire shape is preserved:
|
|
36
|
+
* only string leaves and array tails change; discriminator fields, ids, and
|
|
37
|
+
* other small metadata pass through untouched.
|
|
38
|
+
*/
|
|
39
|
+
export declare function shrinkForReplication<T>(value: T): T;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface ProviderGlobalSettings {
|
|
2
|
+
get(path: "providers.webSearchExclude"): unknown;
|
|
3
|
+
get(path: "providers.webSearch"): unknown;
|
|
4
|
+
get(path: "providers.image"): unknown;
|
|
5
|
+
}
|
|
6
|
+
export declare function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void;
|
|
7
|
+
export {};
|
|
@@ -14,10 +14,28 @@ export declare function startMemoryStartupTask(options: {
|
|
|
14
14
|
agentDir: string;
|
|
15
15
|
taskDepth: number;
|
|
16
16
|
}): void;
|
|
17
|
+
interface MemoryInstructionSession {
|
|
18
|
+
sessionManager: Pick<AgentSession["sessionManager"], "getSessionFile">;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Drop the per-session memory instruction snapshot after explicit memory state
|
|
22
|
+
* changes that must affect the active conversation immediately, such as
|
|
23
|
+
* `/memory clear`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function clearMemoryToolDeveloperInstructionsCache(session: MemoryInstructionSession | undefined): void;
|
|
26
|
+
/**
|
|
27
|
+
* Refresh the active session's consolidated-memory snapshot after startup maintenance.
|
|
28
|
+
*
|
|
29
|
+
* Startup may finish after the first prompt build and write `memory_summary.md`;
|
|
30
|
+
* the active session should see that summary. It must not reread `learned.md`,
|
|
31
|
+
* because a `learn` call racing with startup belongs to the next session's
|
|
32
|
+
* memory prompt, not the active prompt-cache prefix.
|
|
33
|
+
*/
|
|
34
|
+
export declare function refreshMemoryToolDeveloperInstructionsCacheAfterStartup(session: MemoryInstructionSession, agentDir: string, settings: Settings): Promise<void>;
|
|
17
35
|
/**
|
|
18
36
|
* Build memory usage instructions for prompt injection.
|
|
19
37
|
*/
|
|
20
|
-
export declare function buildMemoryToolDeveloperInstructions(agentDir: string, settings: Settings): Promise<string | undefined>;
|
|
38
|
+
export declare function buildMemoryToolDeveloperInstructions(agentDir: string, settings: Settings, session?: MemoryInstructionSession): Promise<string | undefined>;
|
|
21
39
|
/**
|
|
22
40
|
* Clear all persisted memory state and generated artifacts.
|
|
23
41
|
*/
|
|
@@ -33,3 +51,4 @@ export declare function getMemoryRoot(agentDir: string, cwd: string): string;
|
|
|
33
51
|
* tool when `memory.backend` is `local`.
|
|
34
52
|
*/
|
|
35
53
|
export declare function saveLearnedLesson(agentDir: string, cwd: string, input: MemoryBackendSaveInput): Promise<MemoryBackendSaveResult>;
|
|
54
|
+
export {};
|
|
@@ -14,6 +14,11 @@ export declare class StatusLineComponent implements Component {
|
|
|
14
14
|
getEffectiveSettingsForTest(): EffectiveStatusLineSettings;
|
|
15
15
|
setAutoCompactEnabled(enabled: boolean): void;
|
|
16
16
|
setSubagentCount(count: number): void;
|
|
17
|
+
/**
|
|
18
|
+
* Compatibility shim for callers predating the simplified subagent badge.
|
|
19
|
+
* The status line now intentionally shows only the active count.
|
|
20
|
+
*/
|
|
21
|
+
setSubagentHubHint(_hint: string | undefined): void;
|
|
17
22
|
/** Active subagent count as currently displayed (collab state mirroring). */
|
|
18
23
|
get subagentCount(): number;
|
|
19
24
|
/**
|
|
@@ -30,13 +30,13 @@ export declare class CommandController {
|
|
|
30
30
|
handleDropCommand(): Promise<void>;
|
|
31
31
|
handleForkCommand(): Promise<void>;
|
|
32
32
|
/**
|
|
33
|
-
* `/move` —
|
|
33
|
+
* `/move` — relocate the current session to a different directory.
|
|
34
34
|
*
|
|
35
35
|
* With no `targetPath` (TUI only), opens an autocomplete overlay so the user
|
|
36
36
|
* can pick or type a directory. With a `targetPath`, resolves it directly.
|
|
37
37
|
* If the target directory does not exist, the user is asked whether to create
|
|
38
|
-
* it.
|
|
39
|
-
*
|
|
38
|
+
* it. The active session file and artifacts are moved into the target
|
|
39
|
+
* directory's session bucket so `/resume` from that directory can find it.
|
|
40
40
|
*/
|
|
41
41
|
handleMoveCommand(targetPath?: string): Promise<void>;
|
|
42
42
|
handleRenameCommand(title: string): Promise<void>;
|
|
@@ -83,6 +83,7 @@ export declare class InteractiveMode implements InteractiveModeContext {
|
|
|
83
83
|
chatContainer: TranscriptContainer;
|
|
84
84
|
pendingMessagesContainer: Container;
|
|
85
85
|
statusContainer: Container;
|
|
86
|
+
todoReminderContainer: Container;
|
|
86
87
|
todoContainer: Container;
|
|
87
88
|
subagentContainer: Container;
|
|
88
89
|
btwContainer: Container;
|
|
@@ -81,6 +81,7 @@ export interface InteractiveModeContext {
|
|
|
81
81
|
chatContainer: TranscriptContainer;
|
|
82
82
|
pendingMessagesContainer: Container;
|
|
83
83
|
statusContainer: Container;
|
|
84
|
+
todoReminderContainer: Container;
|
|
84
85
|
todoContainer: Container;
|
|
85
86
|
subagentContainer: Container;
|
|
86
87
|
btwContainer: Container;
|
|
@@ -104,7 +105,7 @@ export interface InteractiveModeContext {
|
|
|
104
105
|
focusParentSession(): Promise<void>;
|
|
105
106
|
/** Return the view to the main session (delegates to SessionFocusController.unfocus). */
|
|
106
107
|
unfocusSession(): Promise<void>;
|
|
107
|
-
/** Clear loader,
|
|
108
|
+
/** Clear loader, transient HUD/pending containers, streaming state, and pending tools. */
|
|
108
109
|
clearTransientSessionUi(): void;
|
|
109
110
|
settings: Settings;
|
|
110
111
|
keybindings: KeybindingsManager;
|
|
@@ -242,9 +242,8 @@ export declare class SessionManager {
|
|
|
242
242
|
/**
|
|
243
243
|
* Create a fresh empty session file in the default session directory for
|
|
244
244
|
* `cwd`, writing only the session header. The returned path can be passed to
|
|
245
|
-
* `setSessionFile` / `AgentSession.switchSession`
|
|
246
|
-
*
|
|
247
|
-
* dragging the current conversation along.
|
|
245
|
+
* `setSessionFile` / `AgentSession.switchSession` when a caller explicitly
|
|
246
|
+
* needs a brand-new persisted session at a cwd-derived path.
|
|
248
247
|
*/
|
|
249
248
|
static createEmptySessionFile(cwd: string, storage?: SessionStorage): string;
|
|
250
249
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "16.2.
|
|
4
|
+
"version": "16.2.4",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -55,17 +55,17 @@
|
|
|
55
55
|
"@agentclientprotocol/sdk": "0.25.0",
|
|
56
56
|
"@babel/parser": "^7.29.7",
|
|
57
57
|
"@mozilla/readability": "^0.6.0",
|
|
58
|
-
"@oh-my-pi/hashline": "16.2.
|
|
59
|
-
"@oh-my-pi/omp-stats": "16.2.
|
|
60
|
-
"@oh-my-pi/pi-agent-core": "16.2.
|
|
61
|
-
"@oh-my-pi/pi-ai": "16.2.
|
|
62
|
-
"@oh-my-pi/pi-catalog": "16.2.
|
|
63
|
-
"@oh-my-pi/pi-mnemopi": "16.2.
|
|
64
|
-
"@oh-my-pi/pi-natives": "16.2.
|
|
65
|
-
"@oh-my-pi/pi-tui": "16.2.
|
|
66
|
-
"@oh-my-pi/pi-utils": "16.2.
|
|
67
|
-
"@oh-my-pi/pi-wire": "16.2.
|
|
68
|
-
"@oh-my-pi/snapcompact": "16.2.
|
|
58
|
+
"@oh-my-pi/hashline": "16.2.4",
|
|
59
|
+
"@oh-my-pi/omp-stats": "16.2.4",
|
|
60
|
+
"@oh-my-pi/pi-agent-core": "16.2.4",
|
|
61
|
+
"@oh-my-pi/pi-ai": "16.2.4",
|
|
62
|
+
"@oh-my-pi/pi-catalog": "16.2.4",
|
|
63
|
+
"@oh-my-pi/pi-mnemopi": "16.2.4",
|
|
64
|
+
"@oh-my-pi/pi-natives": "16.2.4",
|
|
65
|
+
"@oh-my-pi/pi-tui": "16.2.4",
|
|
66
|
+
"@oh-my-pi/pi-utils": "16.2.4",
|
|
67
|
+
"@oh-my-pi/pi-wire": "16.2.4",
|
|
68
|
+
"@oh-my-pi/snapcompact": "16.2.4",
|
|
69
69
|
"@opentelemetry/api": "^1.9.1",
|
|
70
70
|
"@opentelemetry/context-async-hooks": "^2.7.1",
|
|
71
71
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Auto-learn session controller (experimental).
|
|
3
3
|
*
|
|
4
4
|
* Subscribes to the session event stream and, after a substantive turn,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* optionally auto-runs a synthetic capture turn. Passive mode is intentionally
|
|
6
|
+
* prompt-cache neutral: the standing system guidance remains available, but no
|
|
7
|
+
* hidden mid-session reminder is inserted into the conversation.
|
|
8
8
|
*
|
|
9
9
|
* Installed once per top-level session (taskDepth 0). The subscription lives
|
|
10
10
|
* for the session's lifetime — `newSession` resets the session in place
|
|
@@ -14,11 +14,9 @@ import { logger } from "@oh-my-pi/pi-utils";
|
|
|
14
14
|
import type { Settings } from "../config/settings";
|
|
15
15
|
import autolearnGuidance from "../prompts/system/autolearn-guidance.md" with { type: "text" };
|
|
16
16
|
import autolearnGuidanceLearn from "../prompts/system/autolearn-guidance-learn.md" with { type: "text" };
|
|
17
|
-
import autolearnNudge from "../prompts/system/autolearn-nudge.md" with { type: "text" };
|
|
18
17
|
import autolearnNudgeAutoContinue from "../prompts/system/autolearn-nudge-autocontinue.md" with { type: "text" };
|
|
19
18
|
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
|
|
20
19
|
|
|
21
|
-
const AUTOLEARN_NUDGE_PASSIVE = autolearnNudge.trim();
|
|
22
20
|
const AUTOLEARN_NUDGE_AUTOCONTINUE = autolearnNudgeAutoContinue.trim();
|
|
23
21
|
const DEFAULT_MIN_TOOL_CALLS = 5;
|
|
24
22
|
|
|
@@ -110,28 +108,21 @@ export class AutoLearnController {
|
|
|
110
108
|
// auto-continue would compete with it.
|
|
111
109
|
if (startedInGoalMode || this.#session.getGoalModeState()?.enabled) return;
|
|
112
110
|
|
|
113
|
-
// Auto-run a capture turn only when explicitly enabled
|
|
114
|
-
// hidden
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
// user-role payload, so the prompt must be terminal — it tells the
|
|
119
|
-
// agent to capture and STOP, and must not be read as the user's
|
|
120
|
-
// reply to any pending question. Without that contract, the agent
|
|
121
|
-
// conflates the synthetic prompt with user approval and resumes
|
|
122
|
-
// prior work (e.g. commits/pushes an unanswered "want me to push?"
|
|
123
|
-
// question — #3504).
|
|
124
|
-
// - Passive mode appends the nudge to the user's real next message,
|
|
125
|
-
// so the agent must answer the user normally and treat the capture
|
|
126
|
-
// as additive — not as the whole turn's job.
|
|
111
|
+
// Auto-run a capture turn only when explicitly enabled. Passive mode used to
|
|
112
|
+
// queue a hidden custom message for the next real turn, but that mutates the
|
|
113
|
+
// persisted conversation prefix after providers have cached it. The standing
|
|
114
|
+
// auto-learn system guidance is stable; keep passive mode to that guidance
|
|
115
|
+
// so Anthropic prompt-cache prefixes survive long sessions.
|
|
127
116
|
const autoContinue = this.#settings.get("autolearn.autoContinue") === true;
|
|
128
|
-
|
|
117
|
+
if (!autoContinue) return;
|
|
118
|
+
|
|
119
|
+
const content = AUTOLEARN_NUDGE_AUTOCONTINUE;
|
|
129
120
|
// Arm suppression synchronously: the synthetic capture turn's agent_end
|
|
130
121
|
// fires inside sendCustomMessage (before it resolves), so the flag must be
|
|
131
122
|
// set before then. Disarm when no turn actually started — a deferred/queued
|
|
132
123
|
// dispatch or a failed send produces no agent_end, and a latched flag would
|
|
133
124
|
// otherwise swallow the next real stop.
|
|
134
|
-
|
|
125
|
+
this.#suppressNext = true;
|
|
135
126
|
|
|
136
127
|
this.#session
|
|
137
128
|
.sendCustomMessage(
|
|
@@ -141,7 +132,7 @@ export class AutoLearnController {
|
|
|
141
132
|
display: false,
|
|
142
133
|
attribution: "user",
|
|
143
134
|
},
|
|
144
|
-
{ deliverAs: "nextTurn", triggerTurn:
|
|
135
|
+
{ deliverAs: "nextTurn", triggerTurn: true },
|
|
145
136
|
)
|
|
146
137
|
.then(started => {
|
|
147
138
|
if (!started) this.#suppressNext = false;
|
package/src/cli/update-cli.ts
CHANGED
|
@@ -272,6 +272,252 @@ function compareVersions(a: string, b: string): number {
|
|
|
272
272
|
return 0;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
interface BunInstallCachePruneResult {
|
|
276
|
+
scannedPackages: number;
|
|
277
|
+
removedEntries: number;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
interface BunCachePackageGroup {
|
|
281
|
+
actualDirs: Map<string, string[]>;
|
|
282
|
+
markerDir?: string;
|
|
283
|
+
markerEntries: Map<string, string[]>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function stripBunCacheVersionSuffix(name: string): string {
|
|
287
|
+
const metadataIndex = name.indexOf("@@");
|
|
288
|
+
return metadataIndex === -1 ? name : name.slice(0, metadataIndex);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function compareSemverIdentifier(a: string, b: string): number {
|
|
292
|
+
const aNumber = /^\d+$/.test(a);
|
|
293
|
+
const bNumber = /^\d+$/.test(b);
|
|
294
|
+
if (aNumber && bNumber) return Number(a) - Number(b);
|
|
295
|
+
if (aNumber) return -1;
|
|
296
|
+
if (bNumber) return 1;
|
|
297
|
+
return a.localeCompare(b);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function compareSemverLikeVersions(a: string, b: string): number {
|
|
301
|
+
const [aCoreWithPrerelease] = a.split("+", 1);
|
|
302
|
+
const [bCoreWithPrerelease] = b.split("+", 1);
|
|
303
|
+
const [aCore, aPrerelease] = aCoreWithPrerelease.split("-", 2);
|
|
304
|
+
const [bCore, bPrerelease] = bCoreWithPrerelease.split("-", 2);
|
|
305
|
+
const aParts = aCore.split(".");
|
|
306
|
+
const bParts = bCore.split(".");
|
|
307
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
308
|
+
const diff = Number(aParts[i] ?? 0) - Number(bParts[i] ?? 0);
|
|
309
|
+
if (diff !== 0 && Number.isFinite(diff)) return diff;
|
|
310
|
+
}
|
|
311
|
+
if (!aPrerelease && !bPrerelease) return 0;
|
|
312
|
+
if (!aPrerelease) return 1;
|
|
313
|
+
if (!bPrerelease) return -1;
|
|
314
|
+
const aPrereleaseParts = aPrerelease.split(".");
|
|
315
|
+
const bPrereleaseParts = bPrerelease.split(".");
|
|
316
|
+
for (let i = 0; i < Math.max(aPrereleaseParts.length, bPrereleaseParts.length); i++) {
|
|
317
|
+
const aPart = aPrereleaseParts[i];
|
|
318
|
+
const bPart = bPrereleaseParts[i];
|
|
319
|
+
if (aPart === undefined) return -1;
|
|
320
|
+
if (bPart === undefined) return 1;
|
|
321
|
+
const diff = compareSemverIdentifier(aPart, bPart);
|
|
322
|
+
if (diff !== 0) return diff;
|
|
323
|
+
}
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function readdirIfExists(dir: string): Promise<fs.Dirent[]> {
|
|
328
|
+
try {
|
|
329
|
+
return await fs.promises.readdir(dir, { withFileTypes: true });
|
|
330
|
+
} catch (err) {
|
|
331
|
+
if (isEnoent(err)) return [];
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getBunCacheGroup(groups: Map<string, BunCachePackageGroup>, packageName: string): BunCachePackageGroup {
|
|
337
|
+
let group = groups.get(packageName);
|
|
338
|
+
if (!group) {
|
|
339
|
+
group = { actualDirs: new Map(), markerEntries: new Map() };
|
|
340
|
+
groups.set(packageName, group);
|
|
341
|
+
}
|
|
342
|
+
return group;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function addVersionPath(entries: Map<string, string[]>, version: string, entryPath: string): void {
|
|
346
|
+
const paths = entries.get(version);
|
|
347
|
+
if (paths) {
|
|
348
|
+
paths.push(entryPath);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
entries.set(version, [entryPath]);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function addBunCacheActualDir(
|
|
355
|
+
groups: Map<string, BunCachePackageGroup>,
|
|
356
|
+
dirPath: string,
|
|
357
|
+
packageNames: Set<string> | undefined,
|
|
358
|
+
): Promise<void> {
|
|
359
|
+
try {
|
|
360
|
+
const manifest = (await Bun.file(path.join(dirPath, "package.json")).json()) as Partial<
|
|
361
|
+
Record<"name" | "version", unknown>
|
|
362
|
+
>;
|
|
363
|
+
if (typeof manifest.name !== "string" || typeof manifest.version !== "string") return;
|
|
364
|
+
if (packageNames && !packageNames.has(manifest.name)) return;
|
|
365
|
+
const group = getBunCacheGroup(groups, manifest.name);
|
|
366
|
+
addVersionPath(group.actualDirs, manifest.version, dirPath);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (isEnoent(err)) return;
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function addBunCacheMarkerDir(
|
|
374
|
+
groups: Map<string, BunCachePackageGroup>,
|
|
375
|
+
packageName: string,
|
|
376
|
+
markerDir: string,
|
|
377
|
+
packageNames: Set<string> | undefined,
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
if (packageNames && !packageNames.has(packageName)) return;
|
|
380
|
+
const markerEntries = await readdirIfExists(markerDir);
|
|
381
|
+
const group = getBunCacheGroup(groups, packageName);
|
|
382
|
+
group.markerDir = markerDir;
|
|
383
|
+
for (const entry of markerEntries) {
|
|
384
|
+
const cacheVersion = stripBunCacheVersionSuffix(entry.name);
|
|
385
|
+
addVersionPath(group.markerEntries, cacheVersion, path.join(markerDir, entry.name));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function collectBunCacheGroups(
|
|
390
|
+
cacheDir: string,
|
|
391
|
+
packageNames: Set<string> | undefined,
|
|
392
|
+
): Promise<Map<string, BunCachePackageGroup>> {
|
|
393
|
+
const groups = new Map<string, BunCachePackageGroup>();
|
|
394
|
+
for (const entry of await readdirIfExists(cacheDir)) {
|
|
395
|
+
if (!entry.isDirectory()) continue;
|
|
396
|
+
const entryPath = path.join(cacheDir, entry.name);
|
|
397
|
+
if (entry.name.startsWith("@")) {
|
|
398
|
+
for (const scopedEntry of await readdirIfExists(entryPath)) {
|
|
399
|
+
if (!scopedEntry.isDirectory()) continue;
|
|
400
|
+
const scopedEntryPath = path.join(entryPath, scopedEntry.name);
|
|
401
|
+
const versionSeparator = scopedEntry.name.lastIndexOf("@");
|
|
402
|
+
if (versionSeparator === -1) {
|
|
403
|
+
await addBunCacheMarkerDir(groups, `${entry.name}/${scopedEntry.name}`, scopedEntryPath, packageNames);
|
|
404
|
+
} else {
|
|
405
|
+
await addBunCacheActualDir(groups, scopedEntryPath, packageNames);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const versionSeparator = entry.name.lastIndexOf("@");
|
|
411
|
+
if (versionSeparator === -1) {
|
|
412
|
+
await addBunCacheMarkerDir(groups, entry.name, entryPath, packageNames);
|
|
413
|
+
} else {
|
|
414
|
+
await addBunCacheActualDir(groups, entryPath, packageNames);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return groups;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function removeCacheEntries(paths: string[]): Promise<number> {
|
|
421
|
+
for (const entryPath of paths) {
|
|
422
|
+
await fs.promises.rm(entryPath, { recursive: true, force: true });
|
|
423
|
+
}
|
|
424
|
+
return paths.length;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Prune Bun's package cache so each package keeps only its newest cached version.
|
|
429
|
+
*
|
|
430
|
+
* Bun stores package cache entries as both a package marker directory
|
|
431
|
+
* (`react/19.2.6@@@1`) and a materialized package directory
|
|
432
|
+
* (`react@19.2.6@@@1`). Global `omp` updates can leave one full copy per
|
|
433
|
+
* release. The marker and materialized entries are removed together so the
|
|
434
|
+
* cache stays internally consistent.
|
|
435
|
+
*/
|
|
436
|
+
export async function pruneBunInstallCache(
|
|
437
|
+
cacheDir: string,
|
|
438
|
+
packageNames?: Set<string>,
|
|
439
|
+
): Promise<BunInstallCachePruneResult> {
|
|
440
|
+
const groups = await collectBunCacheGroups(cacheDir, packageNames);
|
|
441
|
+
let scannedPackages = 0;
|
|
442
|
+
let removedEntries = 0;
|
|
443
|
+
for (const group of groups.values()) {
|
|
444
|
+
if (group.actualDirs.size === 0) continue;
|
|
445
|
+
scannedPackages++;
|
|
446
|
+
let latestVersion: string | undefined;
|
|
447
|
+
for (const version of group.actualDirs.keys()) {
|
|
448
|
+
if (!latestVersion || compareSemverLikeVersions(version, latestVersion) > 0) latestVersion = version;
|
|
449
|
+
}
|
|
450
|
+
if (!latestVersion) continue;
|
|
451
|
+
for (const [version, paths] of group.actualDirs) {
|
|
452
|
+
if (version !== latestVersion) removedEntries += await removeCacheEntries(paths);
|
|
453
|
+
}
|
|
454
|
+
for (const [version, paths] of group.markerEntries) {
|
|
455
|
+
if (version !== latestVersion) removedEntries += await removeCacheEntries(paths);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return { scannedPackages, removedEntries };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function resolveBunInstallCacheDir(): Promise<string | undefined> {
|
|
462
|
+
try {
|
|
463
|
+
const result = await $`bun pm cache`.quiet().nothrow();
|
|
464
|
+
if (result.exitCode !== 0) return undefined;
|
|
465
|
+
const output = result.text().trim();
|
|
466
|
+
return output.length > 0 ? output : undefined;
|
|
467
|
+
} catch {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function resolveBunGlobalNodeModulesDirFromLocations(
|
|
473
|
+
globalBinDir: string | undefined,
|
|
474
|
+
cacheDir: string | undefined,
|
|
475
|
+
): string | undefined {
|
|
476
|
+
if (globalBinDir && globalBinDir.length > 0) {
|
|
477
|
+
return path.join(path.dirname(globalBinDir), "install", "global", "node_modules");
|
|
478
|
+
}
|
|
479
|
+
if (cacheDir && cacheDir.length > 0) {
|
|
480
|
+
return path.join(path.dirname(cacheDir), "global", "node_modules");
|
|
481
|
+
}
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function resolveBunGlobalNodeModulesDir(cacheDir: string): Promise<string | undefined> {
|
|
486
|
+
try {
|
|
487
|
+
const result = await $`bun pm bin -g`.quiet().nothrow();
|
|
488
|
+
const globalBinDir = result.exitCode === 0 ? result.text().trim() : undefined;
|
|
489
|
+
return resolveBunGlobalNodeModulesDirFromLocations(globalBinDir, cacheDir);
|
|
490
|
+
} catch {
|
|
491
|
+
return resolveBunGlobalNodeModulesDirFromLocations(undefined, cacheDir);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function collectInstalledPackageNames(nodeModulesDir: string): Promise<Set<string>> {
|
|
496
|
+
const packageNames = new Set<string>();
|
|
497
|
+
for (const entry of await readdirIfExists(nodeModulesDir)) {
|
|
498
|
+
if (!entry.isDirectory() || entry.name === ".bin") continue;
|
|
499
|
+
if (entry.name.startsWith("@")) {
|
|
500
|
+
for (const scopedEntry of await readdirIfExists(path.join(nodeModulesDir, entry.name))) {
|
|
501
|
+
if (scopedEntry.isDirectory()) packageNames.add(`${entry.name}/${scopedEntry.name}`);
|
|
502
|
+
}
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
packageNames.add(entry.name);
|
|
506
|
+
}
|
|
507
|
+
return packageNames;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function pruneBunCacheAfterGlobalInstall(): Promise<BunInstallCachePruneResult | undefined> {
|
|
511
|
+
const cacheDir = await resolveBunInstallCacheDir();
|
|
512
|
+
if (!cacheDir) return undefined;
|
|
513
|
+
const globalNodeModulesDir = await resolveBunGlobalNodeModulesDir(cacheDir);
|
|
514
|
+
const packageNames = globalNodeModulesDir
|
|
515
|
+
? await collectInstalledPackageNames(globalNodeModulesDir)
|
|
516
|
+
: new Set<string>();
|
|
517
|
+
if (packageNames.size === 0 && !path.basename(cacheDir).toLowerCase().includes("omp")) return undefined;
|
|
518
|
+
return await pruneBunInstallCache(cacheDir, packageNames.size === 0 ? undefined : packageNames);
|
|
519
|
+
}
|
|
520
|
+
|
|
275
521
|
/**
|
|
276
522
|
* Get the appropriate binary name for this platform.
|
|
277
523
|
*/
|
|
@@ -524,6 +770,14 @@ async function updateViaBun(expectedVersion: string): Promise<void> {
|
|
|
524
770
|
}
|
|
525
771
|
|
|
526
772
|
await printVerification(expectedVersion);
|
|
773
|
+
try {
|
|
774
|
+
const pruneResult = await pruneBunCacheAfterGlobalInstall();
|
|
775
|
+
if (pruneResult && pruneResult.removedEntries > 0) {
|
|
776
|
+
console.log(chalk.dim(`Pruned ${pruneResult.removedEntries} stale Bun cache entries`));
|
|
777
|
+
}
|
|
778
|
+
} catch (err) {
|
|
779
|
+
console.log(chalk.yellow(`Warning: could not prune stale Bun cache entries: ${err}`));
|
|
780
|
+
}
|
|
527
781
|
}
|
|
528
782
|
|
|
529
783
|
async function updateViaHomebrew(expectedVersion: string, force: boolean): Promise<void> {
|
package/src/collab/host.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
parseCollabLink,
|
|
38
38
|
} from "./protocol";
|
|
39
39
|
import { CollabSocket } from "./relay-client";
|
|
40
|
+
import { shrinkForReplication } from "./replication-shrink";
|
|
40
41
|
|
|
41
42
|
/** Events that change the footer state guests render. */
|
|
42
43
|
const STATE_TRIGGER_EVENTS: Record<string, true> = {
|
|
@@ -212,7 +213,7 @@ export class CollabHost {
|
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
this.#unsubscribe = this.#ctx.session.subscribe(event => {
|
|
215
|
-
if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event });
|
|
216
|
+
if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event: shrinkForReplication(event) });
|
|
216
217
|
this.#onEventForState(event);
|
|
217
218
|
});
|
|
218
219
|
const bus = this.#ctx.eventBus;
|
|
@@ -223,7 +224,7 @@ export class CollabHost {
|
|
|
223
224
|
}
|
|
224
225
|
this.#registryUnsubscribe = AgentRegistry.global().onChange(() => this.#scheduleAgentsBroadcast());
|
|
225
226
|
this.#ctx.sessionManager.onEntryAppended = entry => {
|
|
226
|
-
if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry });
|
|
227
|
+
if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry: shrinkForReplication(entry) });
|
|
227
228
|
// Model/thinking/title changes land as entries while idle; refresh
|
|
228
229
|
// guest state promptly (debounce + JSON diff dedupe).
|
|
229
230
|
this.#scheduleStateBroadcast();
|
|
@@ -358,10 +359,13 @@ export class CollabHost {
|
|
|
358
359
|
|
|
359
360
|
/**
|
|
360
361
|
* Slice {@link entries} into byte-bounded `snapshot-chunk` frames targeted
|
|
361
|
-
* at {@link fromPeer}.
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* `
|
|
362
|
+
* at {@link fromPeer}. Each entry is first run through
|
|
363
|
+
* {@link shrinkForReplication} so a single oversized tool-result entry
|
|
364
|
+
* cannot ship as an oversized chunk that trips the relay's per-frame
|
|
365
|
+
* `maxPayloadLength` (issue #3739). Every batch carries at least one
|
|
366
|
+
* entry, and the last batch is tagged `final: true` so the guest can
|
|
367
|
+
* finalize the replica. An empty snapshot still emits one `final` chunk
|
|
368
|
+
* so the guest never blocks on a missing terminator.
|
|
365
369
|
*/
|
|
366
370
|
#sendSnapshotChunks(entries: (StoredSessionEntry & WireSessionEntry)[], fromPeer: number): void {
|
|
367
371
|
const socket = this.#socket;
|
|
@@ -377,9 +381,10 @@ export class CollabHost {
|
|
|
377
381
|
while (i < entries.length) {
|
|
378
382
|
const entry = entries[i];
|
|
379
383
|
if (!entry) break;
|
|
380
|
-
const
|
|
384
|
+
const shrunk = shrinkForReplication(entry);
|
|
385
|
+
const entryBytes = JSON.stringify(shrunk).length;
|
|
381
386
|
if (batch.length > 0 && batchBytes + entryBytes > SNAPSHOT_CHUNK_BYTES) break;
|
|
382
|
-
batch.push(
|
|
387
|
+
batch.push(shrunk);
|
|
383
388
|
batchBytes += entryBytes;
|
|
384
389
|
i++;
|
|
385
390
|
}
|