@cortexkit/opencode-magic-context 0.21.8 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -323
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/agents/permissions.d.ts +29 -14
- package/dist/agents/permissions.d.ts.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/migrate-experimental.d.ts +29 -0
- package/dist/config/migrate-experimental.d.ts.map +1 -0
- package/dist/config/schema/agent-overrides.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +95 -104
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/features/builtin-commands/commands.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-embedding.d.ts +34 -0
- package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -0
- package/dist/features/magic-context/compartment-events.d.ts +50 -0
- package/dist/features/magic-context/compartment-events.d.ts.map +1 -0
- package/dist/features/magic-context/compartment-storage.d.ts +22 -0
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/queue.d.ts +13 -2
- package/dist/features/magic-context/dreamer/queue.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts +11 -0
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts +1 -1
- package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
- package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
- package/dist/features/magic-context/key-files/identify-key-files.d.ts +1 -1
- package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
- package/dist/features/magic-context/key-files/project-key-files.d.ts.map +1 -1
- package/dist/features/magic-context/key-files/read-stats.d.ts +1 -1
- package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
- package/dist/features/magic-context/memory/constants.d.ts +4 -0
- package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts +6 -0
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-probe.d.ts +5 -0
- package/dist/features/magic-context/memory/embedding-probe.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/index.d.ts +1 -1
- package/dist/features/magic-context/memory/index.d.ts.map +1 -1
- package/dist/features/magic-context/memory/memory-migration.d.ts +133 -0
- package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -0
- package/dist/features/magic-context/memory/project-identity.d.ts +38 -7
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/memory/storage-memory-fts.d.ts.map +1 -1
- package/dist/features/magic-context/memory/storage-memory.d.ts +15 -1
- package/dist/features/magic-context/memory/storage-memory.d.ts.map +1 -1
- package/dist/features/magic-context/memory/types.d.ts +3 -1
- package/dist/features/magic-context/memory/types.d.ts.map +1 -1
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts +7 -0
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/project-docs-hash.d.ts +6 -0
- package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -0
- package/dist/features/magic-context/project-identity.d.ts +2 -0
- package/dist/features/magic-context/project-identity.d.ts.map +1 -0
- package/dist/features/magic-context/storage-db.d.ts +51 -7
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-historian-runs.d.ts +73 -0
- package/dist/features/magic-context/storage-historian-runs.d.ts.map +1 -0
- package/dist/features/magic-context/storage-identity-rekey-map.d.ts +11 -0
- package/dist/features/magic-context/storage-identity-rekey-map.d.ts.map +1 -0
- package/dist/features/magic-context/storage-m0-mutation-log.d.ts +22 -0
- package/dist/features/magic-context/storage-m0-mutation-log.d.ts.map +1 -0
- package/dist/features/magic-context/storage-memory-mutation-log.d.ts +25 -0
- package/dist/features/magic-context/storage-memory-mutation-log.d.ts.map +1 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts +44 -0
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -0
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-project-state.d.ts +19 -0
- package/dist/features/magic-context/storage-project-state.d.ts.map +1 -0
- package/dist/features/magic-context/storage-subagent-invocations.d.ts +9 -0
- package/dist/features/magic-context/storage-subagent-invocations.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +21 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage-v22-backfill-failures.d.ts +24 -0
- package/dist/features/magic-context/storage-v22-backfill-failures.d.ts.map +1 -0
- package/dist/features/magic-context/storage.d.ts +12 -3
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/subagent-token-capture.d.ts +1 -1
- package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +15 -1
- package/dist/features/magic-context/tagger.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +21 -0
- package/dist/features/magic-context/types.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
- package/dist/features/magic-context/v22-deferred-backfill.d.ts +46 -0
- package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -0
- package/dist/features/magic-context/work-metrics.d.ts +66 -0
- package/dist/features/magic-context/work-metrics.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
- package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
- package/dist/hooks/magic-context/cache-busting-signals.d.ts +9 -0
- package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts +13 -1
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-parser.d.ts +25 -0
- package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-prompt.d.ts +27 -16
- package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-mapping.d.ts +6 -2
- package/dist/hooks/magic-context/compartment-runner-mapping.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts +9 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +67 -4
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/decay-curve.d.ts +78 -0
- package/dist/hooks/magic-context/decay-curve.d.ts.map +1 -0
- package/dist/hooks/magic-context/decay-render.d.ts +67 -0
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-resolvers.d.ts +17 -0
- package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts +2 -0
- package/dist/hooks/magic-context/historian-prompt.generated.d.ts.map +1 -0
- package/dist/hooks/magic-context/hook-handlers.d.ts +3 -0
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +9 -21
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +126 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/key-files-block.d.ts.map +1 -1
- package/dist/hooks/magic-context/live-session-state.d.ts +9 -0
- package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts +35 -0
- package/dist/hooks/magic-context/m0-token-breakdown.d.ts.map +1 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts +9 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-db.d.ts +7 -0
- package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts +104 -0
- package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -0
- package/dist/hooks/magic-context/reference-retrieval.d.ts +61 -0
- package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -0
- package/dist/hooks/magic-context/reference-seeds.generated.d.ts +8 -0
- package/dist/hooks/magic-context/reference-seeds.generated.d.ts.map +1 -0
- package/dist/hooks/magic-context/send-session-notification.d.ts +1 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +5 -6
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/tokenizer-calibration.d.ts +6 -0
- package/dist/hooks/magic-context/tokenizer-calibration.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +0 -7
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +18 -0
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +9 -7
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/upgrade-reminder.d.ts +73 -0
- package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9435 -4001
- package/dist/plugin/conflict-warning-hook.d.ts +13 -0
- package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
- package/dist/plugin/dream-timer.d.ts.map +1 -1
- package/dist/plugin/event.d.ts +10 -0
- package/dist/plugin/event.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/messages-transform.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/plugin/tool-registry.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +17 -1
- package/dist/shared/announcement.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +1 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +27 -5
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +1 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +30 -2
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +9 -0
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/sqlite-helpers.d.ts +7 -7
- package/dist/shared/sqlite.d.ts +23 -14
- package/dist/shared/sqlite.d.ts.map +1 -1
- package/dist/shared/tag-transcript.d.ts +10 -1
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/tools/ctx-expand/tools.d.ts +5 -1
- package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +16 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +5 -7
- package/src/shared/announcement.test.ts +23 -7
- package/src/shared/announcement.ts +30 -8
- package/src/shared/conflict-detector.test.ts +15 -2
- package/src/shared/conflict-fixer.test.ts +5 -1
- package/src/shared/models-dev-cache.test.ts +72 -4
- package/src/shared/models-dev-cache.ts +47 -8
- package/src/shared/opencode-compaction-detector.test.ts +10 -2
- package/src/shared/rpc-client.test.ts +54 -3
- package/src/shared/rpc-client.ts +19 -9
- package/src/shared/rpc-notifications.test.ts +54 -1
- package/src/shared/rpc-notifications.ts +82 -13
- package/src/shared/rpc-server.ts +33 -4
- package/src/shared/rpc-types.ts +30 -2
- package/src/shared/rpc-utils.ts +10 -0
- package/src/shared/sqlite-helpers.ts +9 -9
- package/src/shared/sqlite.ts +99 -80
- package/src/shared/tag-transcript.test.ts +280 -0
- package/src/shared/tag-transcript.ts +162 -33
- package/src/tui/data/context-db.ts +75 -11
- package/src/tui/index.tsx +223 -32
- package/src/tui/slots/sidebar-content.tsx +366 -34
- package/dist/hooks/magic-context/compartment-runner-compressor.d.ts +0 -87
- package/dist/hooks/magic-context/compartment-runner-compressor.d.ts.map +0 -1
- package/dist/shared/native-binding.d.ts +0 -87
- package/dist/shared/native-binding.d.ts.map +0 -1
- package/src/shared/native-binding.ts +0 -311
|
@@ -23,19 +23,18 @@ import { getMagicContextStorageDir } from "./data-path";
|
|
|
23
23
|
* Bump only when there are user-visible changes worth a startup dialog.
|
|
24
24
|
* Does NOT need to match the published package version.
|
|
25
25
|
*/
|
|
26
|
-
export const ANNOUNCEMENT_VERSION = "0.
|
|
26
|
+
export const ANNOUNCEMENT_VERSION = "0.22.0";
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
|
|
30
30
|
* TUI dialog renders cleanly without horizontal scroll on a typical terminal.
|
|
31
31
|
*/
|
|
32
32
|
export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"doctor --issue now caps GitHub issue bodies at ~60KB with a dedicated 'Recent errors' section so reports stay submittable.",
|
|
33
|
+
"NOW ON BY DEFAULT — Temporal awareness: the agent sees elapsed-time markers (e.g. +2h 15m) between messages and dated compartments, so it knows how long ago things happened. Opt out with temporal_awareness: false.",
|
|
34
|
+
"NOW ON BY DEFAULT — Auto-search hints: each turn a background ctx_search whispers a compact 'vague recall' when something relevant exists in your memories, past conversation, or git history. No full content injected. Opt out with memory.auto_search.enabled: false.",
|
|
35
|
+
"Experimental features graduated to stable config: temporal_awareness and caveman_text_compression are now top-level keys; auto_search and git_commit_indexing moved under memory.* . Run `doctor` to migrate old experimental.* settings (your opt-ins/opt-outs are preserved).",
|
|
36
|
+
"git_commit_indexing (make project history semantically searchable) stays opt-in — enable with memory.git_commit_indexing.enabled: true.",
|
|
37
|
+
"Audit hardening across both harnesses: memory config-bypass fix, supersede-delta cache-stability fixes, and dashboard correctness fixes.",
|
|
39
38
|
];
|
|
40
39
|
|
|
41
40
|
/**
|
|
@@ -90,8 +89,31 @@ export function markAnnouncementSeen(version: string): void {
|
|
|
90
89
|
* True when the configured `ANNOUNCEMENT_VERSION` has not yet been dismissed
|
|
91
90
|
* AND there is at least one feature to show. Used by both the TUI dialog path
|
|
92
91
|
* and the Desktop ignored-message fallback.
|
|
92
|
+
*
|
|
93
|
+
* First-run / sandbox handling: when NO state file exists yet, we seed it to the
|
|
94
|
+
* current `ANNOUNCEMENT_VERSION` and return false instead of announcing. This
|
|
95
|
+
* covers two cases that previously spammed the dialog (issue #99):
|
|
96
|
+
* - Fresh installs: a brand-new user shouldn't be shown a changelog of release
|
|
97
|
+
* bullets they have no context for — they need onboarding, not patch notes.
|
|
98
|
+
* - Ephemeral/sandbox environments (Docker, CI, disposable dev containers)
|
|
99
|
+
* where the storage dir is wiped between launches: without the seed, the
|
|
100
|
+
* missing file made the announcement re-show on every single startup.
|
|
101
|
+
* Real upgrades still announce exactly once: an existing user already has a
|
|
102
|
+
* state file at the prior version, so the version mismatch shows the dialog and
|
|
103
|
+
* dismissing it advances the file to the current version.
|
|
104
|
+
*
|
|
105
|
+
* The seed is a deliberate write side-effect on the "no file" branch — folding
|
|
106
|
+
* it here (rather than a separate startup call) makes every caller path (plugin
|
|
107
|
+
* startup, Pi startup, TUI rpc pull) consistent with no ordering dependency.
|
|
93
108
|
*/
|
|
94
109
|
export function shouldShowAnnouncement(): boolean {
|
|
95
110
|
if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) return false;
|
|
96
|
-
|
|
111
|
+
const lastVersion = readLastAnnouncedVersion();
|
|
112
|
+
if (!lastVersion) {
|
|
113
|
+
// No prior state: fresh install or wiped sandbox. Seed to current and
|
|
114
|
+
// skip the announcement so we never pester first-run / ephemeral envs.
|
|
115
|
+
markAnnouncementSeen(ANNOUNCEMENT_VERSION);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return lastVersion !== ANNOUNCEMENT_VERSION;
|
|
97
119
|
}
|
|
@@ -46,8 +46,21 @@ describe("detectConflicts", () => {
|
|
|
46
46
|
else process.env[k] = v;
|
|
47
47
|
}
|
|
48
48
|
// Test directories live under tmpdir(); cleanup is best-effort.
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
try {
|
|
50
|
+
rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
51
|
+
} catch {
|
|
52
|
+
/* Ignore EBUSY on Windows */
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
rmSync(userConfigDir, {
|
|
56
|
+
recursive: true,
|
|
57
|
+
force: true,
|
|
58
|
+
maxRetries: 10,
|
|
59
|
+
retryDelay: 100,
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
/* Ignore EBUSY on Windows */
|
|
63
|
+
}
|
|
51
64
|
});
|
|
52
65
|
|
|
53
66
|
function writeProjectConfig(plugins: Array<string | [string, unknown]>): void {
|
|
@@ -38,7 +38,11 @@ describe("fixConflicts", () => {
|
|
|
38
38
|
if (value === undefined) delete process.env[key];
|
|
39
39
|
else process.env[key] = value;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
try {
|
|
42
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
43
|
+
} catch {
|
|
44
|
+
/* Ignore EBUSY on Windows */
|
|
45
|
+
}
|
|
42
46
|
});
|
|
43
47
|
|
|
44
48
|
it("preserves JSONC comments and tuple plugin entries while removing canonical DCP", () => {
|
|
@@ -39,7 +39,11 @@ describe("models-dev-cache", () => {
|
|
|
39
39
|
if (v === undefined) delete process.env[k];
|
|
40
40
|
else process.env[k] = v;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
try {
|
|
43
|
+
rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
44
|
+
} catch {
|
|
45
|
+
/* Ignore EBUSY on Windows */
|
|
46
|
+
}
|
|
43
47
|
clearModelsDevCache();
|
|
44
48
|
});
|
|
45
49
|
|
|
@@ -234,7 +238,7 @@ describe("models-dev-cache", () => {
|
|
|
234
238
|
expect(getModelsDevContextLimit("anthropic", "claude-4")).toBeUndefined();
|
|
235
239
|
});
|
|
236
240
|
|
|
237
|
-
test("
|
|
241
|
+
test("takes the larger limit when both layers know the model (API larger)", async () => {
|
|
238
242
|
// Seed file layer with one value.
|
|
239
243
|
const opencodeDir = join(tempDir, "opencode");
|
|
240
244
|
mkdirSync(opencodeDir, { recursive: true });
|
|
@@ -248,7 +252,7 @@ describe("models-dev-cache", () => {
|
|
|
248
252
|
// Sanity: file layer returns 100000 before API refresh.
|
|
249
253
|
expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(100000);
|
|
250
254
|
|
|
251
|
-
// Mock client providing
|
|
255
|
+
// Mock client providing a LARGER value via API.
|
|
252
256
|
const mockClient = {
|
|
253
257
|
config: {
|
|
254
258
|
providers: async () => ({
|
|
@@ -267,7 +271,7 @@ describe("models-dev-cache", () => {
|
|
|
267
271
|
};
|
|
268
272
|
await refreshModelLimitsFromApi(mockClient);
|
|
269
273
|
|
|
270
|
-
// API value wins.
|
|
274
|
+
// Larger (API) value wins.
|
|
271
275
|
expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
|
|
272
276
|
|
|
273
277
|
const state = getModelsDevCacheState();
|
|
@@ -275,6 +279,70 @@ describe("models-dev-cache", () => {
|
|
|
275
279
|
expect(state.apiCount).toBe(1);
|
|
276
280
|
});
|
|
277
281
|
|
|
282
|
+
test("file value wins when the live API reports a smaller (wrong) limit (issue #117)", async () => {
|
|
283
|
+
// The ollama-cloud scenario: models.dev has the correct large window, but
|
|
284
|
+
// ollama reports its tiny default num_ctx via the live /config/providers
|
|
285
|
+
// API. The larger, correct file value must win so pressure isn't bogus.
|
|
286
|
+
const opencodeDir = join(tempDir, "opencode");
|
|
287
|
+
mkdirSync(opencodeDir, { recursive: true });
|
|
288
|
+
writeFileSync(
|
|
289
|
+
join(opencodeDir, "models.json"),
|
|
290
|
+
JSON.stringify({
|
|
291
|
+
"ollama-cloud": {
|
|
292
|
+
models: { "deepseek-v4-pro": { limit: { context: 1048576 } } },
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const mockClient = {
|
|
298
|
+
config: {
|
|
299
|
+
providers: async () => ({
|
|
300
|
+
data: {
|
|
301
|
+
providers: [
|
|
302
|
+
{
|
|
303
|
+
id: "ollama-cloud",
|
|
304
|
+
models: {
|
|
305
|
+
// Bogus tiny default num_ctx from ollama.
|
|
306
|
+
"deepseek-v4-pro": { limit: { context: 8192 } },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
}),
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
await refreshModelLimitsFromApi(mockClient);
|
|
315
|
+
|
|
316
|
+
// Larger (file/models.dev) value wins, not the tiny live-API value.
|
|
317
|
+
expect(getModelsDevContextLimit("ollama-cloud", "deepseek-v4-pro")).toBe(1048576);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("matches a tagged ollama model against its tag-less models.dev entry (issue #117)", () => {
|
|
321
|
+
// ollama invokes cloud models with a tag (deepseek-v4-pro:cloud) while
|
|
322
|
+
// models.dev stores them tag-less (deepseek-v4-pro).
|
|
323
|
+
const opencodeDir = join(tempDir, "opencode");
|
|
324
|
+
mkdirSync(opencodeDir, { recursive: true });
|
|
325
|
+
writeFileSync(
|
|
326
|
+
join(opencodeDir, "models.json"),
|
|
327
|
+
JSON.stringify({
|
|
328
|
+
"ollama-cloud": {
|
|
329
|
+
models: {
|
|
330
|
+
"deepseek-v4-pro": { limit: { context: 1048576 } },
|
|
331
|
+
// A legitimately-tagged model must still match exactly.
|
|
332
|
+
"gemma3:27b": { limit: { context: 131072 } },
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
// Tagged invocation falls back to the tag-less entry.
|
|
339
|
+
expect(getModelsDevContextLimit("ollama-cloud", "deepseek-v4-pro:cloud")).toBe(1048576);
|
|
340
|
+
// Exact tagged match still wins (no wrongful collapse).
|
|
341
|
+
expect(getModelsDevContextLimit("ollama-cloud", "gemma3:27b")).toBe(131072);
|
|
342
|
+
// Unknown tagged model with no tag-less base stays undefined.
|
|
343
|
+
expect(getModelsDevContextLimit("ollama-cloud", "nonexistent:cloud")).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
278
346
|
test("refreshModelLimitsFromApi tolerates empty/malformed responses", async () => {
|
|
279
347
|
// Undefined data.
|
|
280
348
|
await refreshModelLimitsFromApi({
|
|
@@ -298,19 +298,58 @@ export async function refreshModelLimitsFromApi(client: OpencodeClientLike): Pro
|
|
|
298
298
|
* Returns `undefined` if neither layer knows the model.
|
|
299
299
|
*/
|
|
300
300
|
export function getModelsDevContextLimit(providerID: string, modelID: string): number | undefined {
|
|
301
|
-
const key = `${providerID}/${modelID}`;
|
|
302
|
-
|
|
303
|
-
if (apiCache) {
|
|
304
|
-
const fromApi = apiCache.get(key)?.limit;
|
|
305
|
-
if (typeof fromApi === "number") return fromApi;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
301
|
const now = Date.now();
|
|
309
302
|
if (!fileCache || now - fileLastAttempt > RELOAD_INTERVAL_MS) {
|
|
310
303
|
fileLastAttempt = now;
|
|
311
304
|
fileCache = loadModelsDevMetadataFromFile();
|
|
312
305
|
}
|
|
313
|
-
|
|
306
|
+
|
|
307
|
+
const fromApi = lookupLimitWithTagFallback(apiCache, providerID, modelID);
|
|
308
|
+
const fromFile = lookupLimitWithTagFallback(fileCache, providerID, modelID);
|
|
309
|
+
|
|
310
|
+
// When BOTH layers know the model, take the LARGER limit. Providers never
|
|
311
|
+
// under-report their real window, so a suspiciously small value — e.g.
|
|
312
|
+
// ollama reporting its default `num_ctx` (4k/8k) for a cloud model via the
|
|
313
|
+
// live `/config/providers` API — must not override the correct, larger
|
|
314
|
+
// models.dev value. A genuinely smaller real limit (provider actually
|
|
315
|
+
// rejects at N) is captured separately via the overflow-detection path
|
|
316
|
+
// (detectedContextLimit), not here. (issue #117)
|
|
317
|
+
if (typeof fromApi === "number" && typeof fromFile === "number") {
|
|
318
|
+
return Math.max(fromApi, fromFile);
|
|
319
|
+
}
|
|
320
|
+
return fromApi ?? fromFile;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Look up a model's limit in one cache layer, with an ollama-style tag-suffix
|
|
325
|
+
* fallback.
|
|
326
|
+
*
|
|
327
|
+
* models.dev stores some models WITH a colon tag (e.g. `gemma3:27b`,
|
|
328
|
+
* `deepseek-v3.1:671b`) and ollama-cloud base models WITHOUT one
|
|
329
|
+
* (`deepseek-v4-pro`). But ollama invokes cloud models with a tag at runtime
|
|
330
|
+
* (`deepseek-v4-pro:cloud`), so OpenCode reports the tagged id. An exact-only
|
|
331
|
+
* match therefore misses → falls back to the 128k default → wrong pressure
|
|
332
|
+
* denominator (issue #117).
|
|
333
|
+
*
|
|
334
|
+
* Strategy: exact match first (never collapses a legitimately-tagged model),
|
|
335
|
+
* then retry once with the last `:tag` segment stripped.
|
|
336
|
+
*/
|
|
337
|
+
function lookupLimitWithTagFallback(
|
|
338
|
+
cache: Map<string, CachedModelMetadata> | null,
|
|
339
|
+
providerID: string,
|
|
340
|
+
modelID: string,
|
|
341
|
+
): number | undefined {
|
|
342
|
+
if (!cache) return undefined;
|
|
343
|
+
const exact = cache.get(`${providerID}/${modelID}`)?.limit;
|
|
344
|
+
if (typeof exact === "number") return exact;
|
|
345
|
+
|
|
346
|
+
const colonIdx = modelID.lastIndexOf(":");
|
|
347
|
+
if (colonIdx > 0) {
|
|
348
|
+
const baseModel = modelID.slice(0, colonIdx);
|
|
349
|
+
const fallback = cache.get(`${providerID}/${baseModel}`)?.limit;
|
|
350
|
+
if (typeof fallback === "number") return fallback;
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
314
353
|
}
|
|
315
354
|
|
|
316
355
|
/** Clear in-memory caches (for testing). */
|
|
@@ -18,7 +18,11 @@ describe("opencode-compaction-detector", () => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
afterEach(() => {
|
|
21
|
-
|
|
21
|
+
try {
|
|
22
|
+
rmSync(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
23
|
+
} catch {
|
|
24
|
+
/* Ignore EBUSY on Windows */
|
|
25
|
+
}
|
|
22
26
|
delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
|
|
23
27
|
});
|
|
24
28
|
|
|
@@ -30,7 +34,11 @@ describe("opencode-compaction-detector", () => {
|
|
|
30
34
|
const result = isOpenCodeAutoCompactionEnabled(emptyDir);
|
|
31
35
|
|
|
32
36
|
expect(result).toBe(true);
|
|
33
|
-
|
|
37
|
+
try {
|
|
38
|
+
rmSync(emptyDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
39
|
+
} catch {
|
|
40
|
+
/* Ignore EBUSY on Windows */
|
|
41
|
+
}
|
|
34
42
|
});
|
|
35
43
|
});
|
|
36
44
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { MagicContextRpcClient } from "./rpc-client";
|
|
7
|
-
import {
|
|
7
|
+
import { MagicContextRpcServer } from "./rpc-server";
|
|
8
|
+
import { parseRpcPortFile, rpcPortFilePath } from "./rpc-utils";
|
|
8
9
|
|
|
9
10
|
interface TestServer {
|
|
10
11
|
port: number;
|
|
@@ -19,7 +20,11 @@ afterEach(async () => {
|
|
|
19
20
|
await server.close();
|
|
20
21
|
}
|
|
21
22
|
for (const dir of tempDirs.splice(0)) {
|
|
22
|
-
|
|
23
|
+
try {
|
|
24
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
25
|
+
} catch {
|
|
26
|
+
/* Ignore EBUSY on Windows */
|
|
27
|
+
}
|
|
23
28
|
}
|
|
24
29
|
});
|
|
25
30
|
|
|
@@ -116,6 +121,52 @@ describe("MagicContextRpcClient", () => {
|
|
|
116
121
|
expect(await client.call<{ value: string }>("value")).toEqual({ value: "second" });
|
|
117
122
|
});
|
|
118
123
|
|
|
124
|
+
test("authenticates against a real server with the published token", async () => {
|
|
125
|
+
const storageDir = makeTempDir();
|
|
126
|
+
const directory = "/repo-auth";
|
|
127
|
+
const server = new MagicContextRpcServer(storageDir, directory);
|
|
128
|
+
server.handle("ping", async () => ({ pong: true }));
|
|
129
|
+
await server.start();
|
|
130
|
+
try {
|
|
131
|
+
const client = new MagicContextRpcClient(storageDir, directory);
|
|
132
|
+
// Real round-trip: client must read the token from the port file and
|
|
133
|
+
// send it as Bearer auth, or the server returns 401.
|
|
134
|
+
expect(await client.call<{ pong: boolean }>("ping")).toEqual({ pong: true });
|
|
135
|
+
} finally {
|
|
136
|
+
server.stop();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a request without the token is rejected 401 by the server", async () => {
|
|
141
|
+
const storageDir = makeTempDir();
|
|
142
|
+
const directory = "/repo-noauth";
|
|
143
|
+
const server = new MagicContextRpcServer(storageDir, directory);
|
|
144
|
+
server.handle("ping", async () => ({ pong: true }));
|
|
145
|
+
const port = await server.start();
|
|
146
|
+
try {
|
|
147
|
+
// Sanity: the port file carries a non-empty token.
|
|
148
|
+
const record = parseRpcPortFile(
|
|
149
|
+
readFileSync(rpcPortFilePath(storageDir, directory), "utf-8"),
|
|
150
|
+
);
|
|
151
|
+
expect(typeof record?.token).toBe("string");
|
|
152
|
+
expect((record?.token ?? "").length).toBeGreaterThan(0);
|
|
153
|
+
|
|
154
|
+
// A raw fetch with no Authorization header must be rejected.
|
|
155
|
+
const res = await fetch(`http://127.0.0.1:${port}/rpc/ping`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "Content-Type": "application/json" },
|
|
158
|
+
body: "{}",
|
|
159
|
+
});
|
|
160
|
+
expect(res.status).toBe(401);
|
|
161
|
+
|
|
162
|
+
// Health stays open (no token required) for discovery.
|
|
163
|
+
const health = await fetch(`http://127.0.0.1:${port}/health`);
|
|
164
|
+
expect(health.status).toBe(200);
|
|
165
|
+
} finally {
|
|
166
|
+
server.stop();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
119
170
|
test("gives up when the port file points at a dead server", async () => {
|
|
120
171
|
const storageDir = makeTempDir();
|
|
121
172
|
const directory = "/repo";
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -17,6 +17,7 @@ type NonRetryableRpcError = Error & { [NON_RETRYABLE_RPC_ERROR]: true };
|
|
|
17
17
|
|
|
18
18
|
export class MagicContextRpcClient {
|
|
19
19
|
private port: number | null = null;
|
|
20
|
+
private token: string | null = null;
|
|
20
21
|
private portDir: string;
|
|
21
22
|
private legacyPortFilePath: string;
|
|
22
23
|
private healthChecked = false;
|
|
@@ -46,7 +47,14 @@ export class MagicContextRpcClient {
|
|
|
46
47
|
`http://127.0.0.1:${port}/rpc/${method}`,
|
|
47
48
|
{
|
|
48
49
|
method: "POST",
|
|
49
|
-
headers: {
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
// The server requires this per-process token on all
|
|
53
|
+
// non-health calls; read from the same port file used
|
|
54
|
+
// for discovery. Older servers wrote no token — send
|
|
55
|
+
// nothing then (they also require nothing).
|
|
56
|
+
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
|
57
|
+
},
|
|
50
58
|
body: JSON.stringify(params),
|
|
51
59
|
},
|
|
52
60
|
);
|
|
@@ -105,13 +113,14 @@ export class MagicContextRpcClient {
|
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
const alive = await this.healthCheck(port);
|
|
116
|
+
const record = this.readPortFile();
|
|
117
|
+
if (record) {
|
|
118
|
+
const alive = await this.healthCheck(record.port);
|
|
111
119
|
if (alive) {
|
|
112
|
-
this.port = port;
|
|
120
|
+
this.port = record.port;
|
|
121
|
+
this.token = record.token ?? null;
|
|
113
122
|
this.healthChecked = true;
|
|
114
|
-
return port;
|
|
123
|
+
return record.port;
|
|
115
124
|
}
|
|
116
125
|
}
|
|
117
126
|
|
|
@@ -123,7 +132,7 @@ export class MagicContextRpcClient {
|
|
|
123
132
|
return null;
|
|
124
133
|
}
|
|
125
134
|
|
|
126
|
-
private readPortFile():
|
|
135
|
+
private readPortFile(): RpcPortFileRecord | null {
|
|
127
136
|
const records: RpcPortFileRecord[] = [];
|
|
128
137
|
|
|
129
138
|
try {
|
|
@@ -139,13 +148,13 @@ export class MagicContextRpcClient {
|
|
|
139
148
|
|
|
140
149
|
if (records.length > 0) {
|
|
141
150
|
records.sort((a, b) => b.started_at - a.started_at);
|
|
142
|
-
return records[0]
|
|
151
|
+
return records[0];
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
try {
|
|
146
155
|
const record = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
|
|
147
156
|
if (record?.pid && !isPidAlive(record.pid)) return null;
|
|
148
|
-
return record
|
|
157
|
+
return record;
|
|
149
158
|
} catch {
|
|
150
159
|
return null;
|
|
151
160
|
}
|
|
@@ -174,6 +183,7 @@ export class MagicContextRpcClient {
|
|
|
174
183
|
|
|
175
184
|
reset(): void {
|
|
176
185
|
this.port = null;
|
|
186
|
+
this.token = null;
|
|
177
187
|
this.healthChecked = false;
|
|
178
188
|
}
|
|
179
189
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { drainNotifications, pushNotification } from "./rpc-notifications";
|
|
2
|
+
import { drainNotifications, isTuiConnected, pushNotification } from "./rpc-notifications";
|
|
3
3
|
|
|
4
4
|
describe("rpc notifications", () => {
|
|
5
5
|
test("keeps messages queued until the client acks their id", () => {
|
|
@@ -17,4 +17,57 @@ describe("rpc notifications", () => {
|
|
|
17
17
|
const lastReceivedId = Math.max(...firstPoll.map((m) => m.id));
|
|
18
18
|
expect(drainNotifications(lastReceivedId)).toEqual([]);
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
test("scopes drain to the requesting session; other sessions' items survive", () => {
|
|
22
|
+
// drain everything left from prior tests
|
|
23
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
24
|
+
|
|
25
|
+
pushNotification("for-a", { action: "show-upgrade-dialog" }, "ses_A");
|
|
26
|
+
pushNotification("for-b", { action: "show-upgrade-dialog" }, "ses_B");
|
|
27
|
+
pushNotification("global", { action: "show-status-dialog" });
|
|
28
|
+
|
|
29
|
+
// Session A sees only its own item + the global one, never ses_B's.
|
|
30
|
+
const aPoll = drainNotifications(0, "ses_A");
|
|
31
|
+
expect(aPoll.map((m) => m.type).sort()).toEqual(["for-a", "global"]);
|
|
32
|
+
|
|
33
|
+
// Acking session A must NOT prune session B's still-unseen notification.
|
|
34
|
+
const ackId = Math.max(...aPoll.map((m) => m.id));
|
|
35
|
+
drainNotifications(ackId, "ses_A");
|
|
36
|
+
const bPoll = drainNotifications(0, "ses_B");
|
|
37
|
+
expect(bPoll.map((m) => m.type)).toContain("for-b");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("session-less drain (legacy client) still receives all items", () => {
|
|
41
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
42
|
+
pushNotification("x", { ok: true }, "ses_1");
|
|
43
|
+
pushNotification("y", { ok: true }, "ses_2");
|
|
44
|
+
const poll = drainNotifications(0);
|
|
45
|
+
expect(poll.map((m) => m.type).sort()).toEqual(["x", "y"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("isTuiConnected is per-session: a TUI on session A does not mark session B connected", () => {
|
|
49
|
+
// A TUI draining for tuiA must not make tuiB's producers think a TUI is
|
|
50
|
+
// polling for tuiB (which would route tuiB's /ctx-status, upgrade
|
|
51
|
+
// reminder, etc. to the dialog path and lose them in the unrelated TUI).
|
|
52
|
+
// Use ids no other test drains so the per-session window is unambiguous.
|
|
53
|
+
drainNotifications(0, "ses_tuiA_only");
|
|
54
|
+
expect(isTuiConnected("ses_tuiA_only")).toBe(true);
|
|
55
|
+
expect(isTuiConnected("ses_tuiB_never_drained")).toBe(false);
|
|
56
|
+
// The session-less (global) query still reports recent activity for the
|
|
57
|
+
// legacy callers that have no session context.
|
|
58
|
+
expect(isTuiConnected()).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
|
|
62
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
63
|
+
// One quiet session with a single pending dialog.
|
|
64
|
+
pushNotification("quiet-dialog", { action: "show-upgrade-dialog" }, "ses_quiet");
|
|
65
|
+
// A noisy session floods well past the 100 cap.
|
|
66
|
+
for (let i = 0; i < 200; i += 1) {
|
|
67
|
+
pushNotification("noise", { i }, "ses_noisy");
|
|
68
|
+
}
|
|
69
|
+
// The quiet session's newest item must survive the eviction.
|
|
70
|
+
const quietPoll = drainNotifications(0, "ses_quiet");
|
|
71
|
+
expect(quietPoll.some((m) => m.type === "quiet-dialog")).toBe(true);
|
|
72
|
+
});
|
|
20
73
|
});
|
|
@@ -16,10 +16,22 @@ export interface RpcNotification {
|
|
|
16
16
|
|
|
17
17
|
let queue: RpcNotification[] = [];
|
|
18
18
|
let nextNotificationId = 1;
|
|
19
|
-
// Timestamp of last drain — used to detect if TUI is actively polling.
|
|
19
|
+
// Timestamp of last drain — used to detect if a TUI is actively polling.
|
|
20
20
|
// The TUI polls every 500ms; we consider it connected if it polled within
|
|
21
21
|
// the last 3 seconds (6× the poll interval, tolerates transient delays).
|
|
22
|
-
|
|
22
|
+
//
|
|
23
|
+
// PER-SESSION: a single server process can serve MANY sessions (e.g. a TUI on
|
|
24
|
+
// session A plus an OpenCode Desktop opened on session B for the same project,
|
|
25
|
+
// whose newer RPC server this TUI's port discovery then selects). The TUI
|
|
26
|
+
// poller drains with ITS active session id, so a session is "TUI-connected"
|
|
27
|
+
// only if a TUI recently drained FOR THAT session. A process-global timestamp
|
|
28
|
+
// would make session B's producers (`/ctx-status`, upgrade reminder) take the
|
|
29
|
+
// TUI-dialog path because session A's TUI is polling — queuing a B-scoped
|
|
30
|
+
// dialog action that A's poller correctly refuses to show, so B's notice is
|
|
31
|
+
// lost (it also suppressed B's non-TUI fallback). Tracking drains per session
|
|
32
|
+
// routes each producer to the right delivery path.
|
|
33
|
+
const lastDrainAtBySession = new Map<string, number>();
|
|
34
|
+
let lastDrainAtAny = 0;
|
|
23
35
|
const TUI_CONNECTED_WINDOW_MS = 3_000;
|
|
24
36
|
|
|
25
37
|
/** Push a notification for TUI to pick up via polling. */
|
|
@@ -29,25 +41,82 @@ export function pushNotification(
|
|
|
29
41
|
sessionId?: string,
|
|
30
42
|
): void {
|
|
31
43
|
queue.push({ id: nextNotificationId++, type, payload, sessionId });
|
|
32
|
-
// Cap queue size to prevent unbounded growth if TUI is not
|
|
44
|
+
// Cap queue size to prevent unbounded growth if a TUI is not draining.
|
|
45
|
+
// Session-FAIR eviction: a naive `slice(-50)` drops the globally-oldest
|
|
46
|
+
// items, so a noisy session could evict ANOTHER session's single unseen
|
|
47
|
+
// notification. Instead, always retain each session's newest item, then
|
|
48
|
+
// fill the rest of the budget with the newest overall — no session can
|
|
49
|
+
// starve another's pending dialog out of the window.
|
|
33
50
|
if (queue.length > 100) {
|
|
34
|
-
|
|
51
|
+
const newestPerSession = new Map<string | undefined, number>();
|
|
52
|
+
for (const n of queue) {
|
|
53
|
+
const prev = newestPerSession.get(n.sessionId);
|
|
54
|
+
if (prev === undefined || n.id > prev) {
|
|
55
|
+
newestPerSession.set(n.sessionId, n.id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const mustKeep = new Set(newestPerSession.values());
|
|
59
|
+
const byNewest = [...queue].sort((a, b) => b.id - a.id);
|
|
60
|
+
const kept: RpcNotification[] = [];
|
|
61
|
+
for (const n of byNewest) {
|
|
62
|
+
if (kept.length < 50 || mustKeep.has(n.id)) kept.push(n);
|
|
63
|
+
}
|
|
64
|
+
queue = kept.sort((a, b) => a.id - b.id);
|
|
35
65
|
}
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
/** Return pending notifications after acking the client's last received id.
|
|
39
|
-
* Updates lastDrainAt so isTuiConnected() reflects recent activity.
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
* Updates lastDrainAt so isTuiConnected() reflects recent activity.
|
|
70
|
+
*
|
|
71
|
+
* Session scoping: when `sessionId` is provided, only notifications tagged for
|
|
72
|
+
* that session (or session-less/global ones) are returned and pruned — a
|
|
73
|
+
* notification tagged for a DIFFERENT session is never handed to this client
|
|
74
|
+
* and is never pruned by this client's ack. This matters because the in-memory
|
|
75
|
+
* queue is per-process but a TUI can end up draining a process that also serves
|
|
76
|
+
* OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
|
|
77
|
+
* newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
|
|
78
|
+
* so a Desktop-session upgrade-dialog action would otherwise surface in an
|
|
79
|
+
* unrelated TUI session. Each client also tracks its own `lastReceivedId`, so a
|
|
80
|
+
* global watermark prune would let session A's ack drop session B's still-unseen
|
|
81
|
+
* notification — scoping the prune to the acking session prevents that too.
|
|
82
|
+
*
|
|
83
|
+
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
|
|
84
|
+
* notification stays queued until a later call acks it via a higher
|
|
85
|
+
* `lastReceivedId`, so a lost poll response re-delivers on the next poll. */
|
|
86
|
+
export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcNotification[] {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
lastDrainAtAny = now;
|
|
89
|
+
if (sessionId !== undefined) lastDrainAtBySession.set(sessionId, now);
|
|
90
|
+
const matchesClient = (notification: RpcNotification): boolean =>
|
|
91
|
+
sessionId === undefined ||
|
|
92
|
+
notification.sessionId === undefined ||
|
|
93
|
+
notification.sessionId === sessionId;
|
|
42
94
|
if (lastReceivedId > 0) {
|
|
43
|
-
|
|
95
|
+
// Prune only notifications THIS client both owns (session-matched) and has
|
|
96
|
+
// acked (id <= lastReceivedId). Other sessions' notifications survive.
|
|
97
|
+
queue = queue.filter(
|
|
98
|
+
(notification) => !(notification.id <= lastReceivedId && matchesClient(notification)),
|
|
99
|
+
);
|
|
44
100
|
}
|
|
45
|
-
return
|
|
101
|
+
return queue.filter(
|
|
102
|
+
(notification) => notification.id > lastReceivedId && matchesClient(notification),
|
|
103
|
+
);
|
|
46
104
|
}
|
|
47
105
|
|
|
48
106
|
/** Whether a TUI client is actively polling for notifications.
|
|
49
|
-
* Returns true only if
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
107
|
+
* Returns true only if a TUI has drained within the last 3 seconds.
|
|
108
|
+
*
|
|
109
|
+
* Pass `sessionId` (preferred) to ask whether a TUI is polling FOR THAT
|
|
110
|
+
* SESSION — this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
|
|
111
|
+
* reminder) must use to decide dialog-vs-message, so a TUI on a different
|
|
112
|
+
* session in the same process does not misroute their delivery. Omit it only
|
|
113
|
+
* for legacy/global callers that genuinely have no session context; they fall
|
|
114
|
+
* back to "any session recently drained" (the pre-per-session behavior). */
|
|
115
|
+
export function isTuiConnected(sessionId?: string): boolean {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
if (sessionId !== undefined) {
|
|
118
|
+
const at = lastDrainAtBySession.get(sessionId) ?? 0;
|
|
119
|
+
return at > 0 && now - at < TUI_CONNECTED_WINDOW_MS;
|
|
120
|
+
}
|
|
121
|
+
return lastDrainAtAny > 0 && now - lastDrainAtAny < TUI_CONNECTED_WINDOW_MS;
|
|
53
122
|
}
|