@cortexkit/opencode-magic-context 0.21.7 → 0.22.0
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 +116 -325
- 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/magic-context.d.ts +80 -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-local.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/sidekick/agent.d.ts.map +1 -1
- 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 +5 -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 +2 -1
- 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 +61 -0
- package/dist/features/magic-context/storage-subagent-invocations.d.ts.map +1 -0
- 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 +13 -3
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/subagent-token-capture.d.ts +33 -0
- package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -0
- 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 +79 -0
- package/dist/features/magic-context/work-metrics.d.ts.map +1 -0
- 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-historian.d.ts +2 -0
- package/dist/hooks/magic-context/compartment-runner-historian.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 +68 -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 +22111 -16352
- 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/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 +1 -1
- package/dist/shared/announcement.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 +32 -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/subagent-runner.d.ts +5 -0
- package/dist/shared/subagent-runner.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 +2 -4
- package/src/shared/announcement.ts +6 -7
- package/src/shared/rpc-client.test.ts +49 -2
- 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 +32 -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/subagent-runner.ts +14 -0
- package/src/shared/tag-transcript.test.ts +280 -0
- package/src/shared/tag-transcript.ts +162 -33
- package/src/tui/data/context-db.ts +77 -11
- package/src/tui/index.tsx +240 -57
- package/src/tui/slots/sidebar-content.tsx +415 -101
- 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
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
|
}
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import {
|
|
2
3
|
mkdirSync,
|
|
3
4
|
readdirSync,
|
|
@@ -20,6 +21,11 @@ export class MagicContextRpcServer {
|
|
|
20
21
|
private portFilePath: string;
|
|
21
22
|
private portDir: string;
|
|
22
23
|
private startedAt = Date.now();
|
|
24
|
+
// Unguessable per-process bearer token, published in the (user-private) port
|
|
25
|
+
// file and required on every non-health RPC call. Defends side-effecting
|
|
26
|
+
// endpoints (recomp/upgrade/dismiss) against any local process or
|
|
27
|
+
// browser-origin script that merely discovers/guesses the port.
|
|
28
|
+
private readonly token = randomBytes(32).toString("hex");
|
|
23
29
|
|
|
24
30
|
constructor(storageDir: string, directory: string) {
|
|
25
31
|
this.portFilePath = rpcPortFilePath(storageDir, directory);
|
|
@@ -57,7 +63,15 @@ export class MagicContextRpcServer {
|
|
|
57
63
|
try {
|
|
58
64
|
this.warnIfOtherLiveInstance();
|
|
59
65
|
const dir = dirname(this.portFilePath);
|
|
60
|
-
|
|
66
|
+
// The port file holds the per-process bearer token that
|
|
67
|
+
// gates side-effecting RPC endpoints (recomp/upgrade/
|
|
68
|
+
// dismiss). Under the default umask 0o022 a plain write
|
|
69
|
+
// lands at 0o644 in a 0o755 dir — world-readable, so any
|
|
70
|
+
// local UID could read the token and drive those endpoints,
|
|
71
|
+
// defeating the auth defense. Restrict both: dir 0o700,
|
|
72
|
+
// file 0o600. renameSync preserves the tmp file's mode, so
|
|
73
|
+
// the 0o600 on the write covers the final file.
|
|
74
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
75
|
const tmpPath = `${this.portFilePath}.tmp`;
|
|
62
76
|
writeFileSync(
|
|
63
77
|
tmpPath,
|
|
@@ -65,8 +79,9 @@ export class MagicContextRpcServer {
|
|
|
65
79
|
port: this.port,
|
|
66
80
|
pid: process.pid,
|
|
67
81
|
started_at: this.startedAt,
|
|
82
|
+
token: this.token,
|
|
68
83
|
}),
|
|
69
|
-
"utf-8",
|
|
84
|
+
{ encoding: "utf-8", mode: 0o600 },
|
|
70
85
|
);
|
|
71
86
|
renameSync(tmpPath, this.portFilePath);
|
|
72
87
|
log(`[rpc] server listening on 127.0.0.1:${this.port}`);
|
|
@@ -114,8 +129,10 @@ export class MagicContextRpcServer {
|
|
|
114
129
|
private dispatch(req: IncomingMessage, res: ServerResponse): void {
|
|
115
130
|
const url = req.url ?? "";
|
|
116
131
|
|
|
117
|
-
// CORS
|
|
118
|
-
|
|
132
|
+
// No wildcard CORS: the only legitimate client is the in-process TUI
|
|
133
|
+
// client, which is not a browser origin. Omitting
|
|
134
|
+
// Access-Control-Allow-Origin makes browsers refuse to read responses,
|
|
135
|
+
// closing the CSRF-style read path a malicious local page could use.
|
|
119
136
|
|
|
120
137
|
if (req.method === "GET" && url === "/health") {
|
|
121
138
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
@@ -129,6 +146,18 @@ export class MagicContextRpcServer {
|
|
|
129
146
|
return;
|
|
130
147
|
}
|
|
131
148
|
|
|
149
|
+
// Require the per-process bearer token on every side-effecting call.
|
|
150
|
+
// The legitimate TUI client reads it from the same port file it used to
|
|
151
|
+
// discover the port; a process that only guessed the port cannot.
|
|
152
|
+
const auth = req.headers.authorization;
|
|
153
|
+
const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
154
|
+
if (presented !== this.token) {
|
|
155
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
156
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
157
|
+
req.resume();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
132
161
|
const method = url.slice(5); // strip "/rpc/"
|
|
133
162
|
const handler = this.handlers.get(method);
|
|
134
163
|
if (!handler) {
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -24,10 +24,23 @@ export interface SidebarSnapshot {
|
|
|
24
24
|
compartmentTokens: number;
|
|
25
25
|
factTokens: number;
|
|
26
26
|
memoryTokens: number;
|
|
27
|
+
/**
|
|
28
|
+
* Token estimate of the injected <project-docs> block (root ARCHITECTURE.md
|
|
29
|
+
* + STRUCTURE.md) that lives in m[0] in v2. Part of the message stream, not
|
|
30
|
+
* conversation. Display layer shows this as "Docs".
|
|
31
|
+
*/
|
|
32
|
+
docsTokens: number;
|
|
33
|
+
/**
|
|
34
|
+
* Token estimate of the injected <user-profile> block (promoted user
|
|
35
|
+
* memories) that lives in m[0] in v2. Part of the message stream, not
|
|
36
|
+
* conversation. Display layer shows this as "Profile".
|
|
37
|
+
*/
|
|
38
|
+
profileTokens: number;
|
|
27
39
|
/**
|
|
28
40
|
* Token estimate of real user/assistant discussion (text + reasoning +
|
|
29
|
-
* image parts) inside messages, excluding injected <session-history
|
|
30
|
-
* blocks. Display layer shows this as
|
|
41
|
+
* image parts) inside messages, excluding injected <session-history>,
|
|
42
|
+
* <project-docs>, and <user-profile> blocks. Display layer shows this as
|
|
43
|
+
* "Conversation".
|
|
31
44
|
*/
|
|
32
45
|
conversationTokens: number;
|
|
33
46
|
/**
|
|
@@ -55,6 +68,23 @@ export interface SidebarSnapshot {
|
|
|
55
68
|
* scheduler and transform paths.
|
|
56
69
|
*/
|
|
57
70
|
executeThreshold: number;
|
|
71
|
+
newWorkTokens?: number | null;
|
|
72
|
+
totalInputTokens?: number | null;
|
|
73
|
+
/**
|
|
74
|
+
* Live recomp / session-upgrade progress for this session, or null when no
|
|
75
|
+
* recomp is running (and no recent terminal state is being shown). Drives the
|
|
76
|
+
* sidebar "Recomp"/"Upgrade" progress bar and the /ctx-status dialog. Mirrors
|
|
77
|
+
* the runtime `RecompProgress` shape from compartment-runner-types.ts.
|
|
78
|
+
*/
|
|
79
|
+
recompProgress?: {
|
|
80
|
+
phase: "recomp" | "migration" | "done" | "failed";
|
|
81
|
+
processedMessages: number;
|
|
82
|
+
totalMessages: number;
|
|
83
|
+
passCount: number;
|
|
84
|
+
compartmentsCreated: number;
|
|
85
|
+
message?: string;
|
|
86
|
+
note?: string;
|
|
87
|
+
} | null;
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
export interface StatusDetail extends SidebarSnapshot {
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -5,6 +5,15 @@ export interface RpcPortFileRecord {
|
|
|
5
5
|
port: number;
|
|
6
6
|
pid: number;
|
|
7
7
|
started_at: number;
|
|
8
|
+
/**
|
|
9
|
+
* Per-process bearer token. The server requires it on all non-health RPC
|
|
10
|
+
* calls so a random local process or browser-origin script that merely
|
|
11
|
+
* discovers/guesses the port cannot drive side-effecting endpoints
|
|
12
|
+
* (recomp/upgrade/dismiss). Optional in the type for forward/backward
|
|
13
|
+
* compatibility with port files written by older builds (treated as "no
|
|
14
|
+
* auth required" only when the server itself didn't set one).
|
|
15
|
+
*/
|
|
16
|
+
token?: string;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
19
|
/**
|
|
@@ -56,6 +65,7 @@ export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileR
|
|
|
56
65
|
port,
|
|
57
66
|
pid,
|
|
58
67
|
started_at: Number.isFinite(startedAt) ? startedAt : 0,
|
|
68
|
+
token: typeof parsed.token === "string" ? parsed.token : undefined,
|
|
59
69
|
};
|
|
60
70
|
} catch {
|
|
61
71
|
return null;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-runtime helpers that smooth over the small bun:sqlite ↔
|
|
3
|
-
* API differences without leaking either
|
|
2
|
+
* Cross-runtime helpers that smooth over the small bun:sqlite ↔ node:sqlite
|
|
3
|
+
* API differences without leaking either backend into call sites.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Database } from "./sqlite";
|
|
@@ -8,17 +8,17 @@ import type { Database } from "./sqlite";
|
|
|
8
8
|
/**
|
|
9
9
|
* Close a database, ignoring errors.
|
|
10
10
|
*
|
|
11
|
-
* bun:sqlite supports `db.close(throwOnError = false)`.
|
|
12
|
-
*
|
|
13
|
-
* mirrors the bun "swallow errors" semantics for both
|
|
14
|
-
* test teardown and `finally` blocks where the caller
|
|
15
|
-
* the close succeeded.
|
|
11
|
+
* bun:sqlite supports `db.close(throwOnError = false)`. node:sqlite has only
|
|
12
|
+
* `db.close()` and throws ("database is not open") on an already-closed
|
|
13
|
+
* handle. This helper mirrors the bun "swallow errors" semantics for both
|
|
14
|
+
* runtimes — useful in test teardown and `finally` blocks where the caller
|
|
15
|
+
* doesn't care whether the close succeeded.
|
|
16
16
|
*/
|
|
17
17
|
export function closeQuietly(db: Database | null | undefined): void {
|
|
18
18
|
if (!db) return;
|
|
19
19
|
// Just attempt close and swallow errors. bun:sqlite has no `open` property,
|
|
20
|
-
// and
|
|
21
|
-
//
|
|
20
|
+
// and node:sqlite throws on an already-closed handle — both are handled by
|
|
21
|
+
// the bare try/catch.
|
|
22
22
|
try {
|
|
23
23
|
db.close();
|
|
24
24
|
} catch {
|