@wolfx/opencode-magic-context 0.30.1 → 0.30.3
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/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/recursive-text-splitter.d.ts +36 -0
- package/dist/features/magic-context/recursive-text-splitter.d.ts.map +1 -0
- package/dist/index.js +368 -117
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/shared/announcement.d.ts +1 -1
- package/dist/shared/data-path.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts +8 -0
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-notifications.d.ts +28 -10
- package/dist/shared/rpc-notifications.d.ts.map +1 -1
- package/dist/shared/rpc-server.d.ts +22 -3
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts +4 -14
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui/data/notification-socket.d.ts +39 -0
- package/dist/tui/data/notification-socket.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/shared/announcement.ts +2 -2
- package/src/shared/data-path.test.ts +28 -0
- package/src/shared/data-path.ts +5 -0
- package/src/shared/rpc-client.ts +14 -0
- package/src/shared/rpc-notifications.test.ts +68 -11
- package/src/shared/rpc-notifications.ts +75 -36
- package/src/shared/rpc-server.ts +249 -150
- package/src/tui/data/context-db.ts +10 -64
- package/src/tui/data/notification-socket.ts +229 -0
- package/src/tui/index.tsx +68 -118
|
@@ -9,6 +9,14 @@ export declare class MagicContextRpcClient {
|
|
|
9
9
|
call<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
10
10
|
/** Check if the RPC server is reachable. */
|
|
11
11
|
isAvailable(): Promise<boolean>;
|
|
12
|
+
/** Resolve the live server's port + bearer token (for opening the WS push
|
|
13
|
+
* channel). Reuses the same health-checked port-file discovery as `call`,
|
|
14
|
+
* so the WS client and the HTTP client always agree on which server instance
|
|
15
|
+
* (and token) to use. Returns null when no live server is found. */
|
|
16
|
+
resolveEndpoint(): Promise<{
|
|
17
|
+
port: number;
|
|
18
|
+
token: string | null;
|
|
19
|
+
} | null>;
|
|
12
20
|
private resolvePort;
|
|
13
21
|
private readPortFile;
|
|
14
22
|
private healthCheck;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc-client.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-client.ts"],"names":[],"mappings":"AAiBA,qBAAa,qBAAqB;IAC9B,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,aAAa,CAAS;gBAElB,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAKjD,iFAAiF;IAC3E,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACrC,OAAO,CAAC,CAAC,CAAC;IAwDb,4CAA4C;IACtC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"rpc-client.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-client.ts"],"names":[],"mappings":"AAiBA,qBAAa,qBAAqB;IAC9B,OAAO,CAAC,IAAI,CAAuB;IACnC,OAAO,CAAC,KAAK,CAAuB;IACpC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,aAAa,CAAS;gBAElB,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAKjD,iFAAiF;IAC3E,IAAI,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,MAAM,EAAE,MAAM,EACd,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACrC,OAAO,CAAC,CAAC,CAAC;IAwDb,4CAA4C;IACtC,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IASrC;;;yEAGqE;IAC/D,eAAe,IAAI,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;YAUjE,WAAW;IAmCzB,OAAO,CAAC,YAAY;YA4BN,WAAW;YAWX,gBAAgB;IAU9B,KAAK,IAAI,IAAI;CAKhB"}
|
|
@@ -12,16 +12,33 @@ export interface RpcNotification {
|
|
|
12
12
|
payload: Record<string, unknown>;
|
|
13
13
|
sessionId?: string;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* A connected TUI notification sink — one per authenticated WebSocket. The RPC
|
|
17
|
+
* server registers a sink when a TUI socket authenticates (hello) and removes
|
|
18
|
+
* it on close. `send` is sink-agnostic (the server owns the actual WS socket)
|
|
19
|
+
* so this module stays free of Bun/WS types.
|
|
20
|
+
*/
|
|
21
|
+
export interface NotificationSink {
|
|
22
|
+
/** The TUI's active session at connect time (its hello scope). */
|
|
23
|
+
sessionId?: string;
|
|
24
|
+
/** Deliver one notification over this sink's live socket. */
|
|
25
|
+
send: (notification: RpcNotification) => void;
|
|
26
|
+
}
|
|
27
|
+
/** Register a live TUI sink. Returns an unregister fn (call on socket close). */
|
|
28
|
+
export declare function registerNotificationSink(sink: NotificationSink): () => void;
|
|
29
|
+
/** Push a notification to the TUI. Fans out to any live WS sink immediately and
|
|
30
|
+
* also enqueues it so a TUI that is momentarily disconnected (reconnecting, or
|
|
31
|
+
* not yet connected) still receives it on its next hello via the backlog drain.
|
|
32
|
+
* At-least-once: a live push that the socket drops is re-delivered from the
|
|
33
|
+
* queue on reconnect (pruned only when the client acks via `lastReceivedId`). */
|
|
16
34
|
export declare function pushNotification(type: string, payload: Record<string, unknown>, sessionId?: string): void;
|
|
17
35
|
/** Return pending notifications after acking the client's last received id.
|
|
18
|
-
* Updates lastDrainAt so isTuiConnected() reflects recent activity.
|
|
19
36
|
*
|
|
20
37
|
* Session scoping: when `sessionId` is provided, only notifications tagged for
|
|
21
38
|
* that session (or session-less/global ones) are returned and pruned — a
|
|
22
39
|
* notification tagged for a DIFFERENT session is never handed to this client
|
|
23
40
|
* and is never pruned by this client's ack. This matters because the in-memory
|
|
24
|
-
* queue is per-process but a TUI can end up
|
|
41
|
+
* queue is per-process but a TUI can end up bound to a process that also serves
|
|
25
42
|
* OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
|
|
26
43
|
* newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
|
|
27
44
|
* so a Desktop-session upgrade-dialog action would otherwise surface in an
|
|
@@ -31,16 +48,17 @@ export declare function pushNotification(type: string, payload: Record<string, u
|
|
|
31
48
|
*
|
|
32
49
|
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
|
|
33
50
|
* notification stays queued until a later call acks it via a higher
|
|
34
|
-
* `lastReceivedId`, so a
|
|
51
|
+
* `lastReceivedId`, so a dropped WS socket re-delivers the backlog on reconnect
|
|
52
|
+
* (the client sends its `lastReceivedId` in the hello). */
|
|
35
53
|
export declare function drainNotifications(lastReceivedId?: number, sessionId?: string): RpcNotification[];
|
|
36
|
-
/** Whether a TUI client is
|
|
37
|
-
*
|
|
54
|
+
/** Whether a TUI client is connected via a live notification socket.
|
|
55
|
+
* Now exact socket liveness (a registered WS sink), not a poll-drain timestamp.
|
|
38
56
|
*
|
|
39
|
-
* Pass `sessionId` (preferred) to ask whether a TUI is
|
|
57
|
+
* Pass `sessionId` (preferred) to ask whether a TUI is connected FOR THAT
|
|
40
58
|
* SESSION — this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
|
|
41
59
|
* reminder) must use to decide dialog-vs-message, so a TUI on a different
|
|
42
|
-
* session in the same process does not misroute their delivery.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
60
|
+
* session in the same process does not misroute their delivery. A session-less
|
|
61
|
+
* sink (legacy/global) counts for any session query. Omit `sessionId` only for
|
|
62
|
+
* callers with no session context; they get "any sink connected". */
|
|
45
63
|
export declare function isTuiConnected(sessionId?: string): boolean;
|
|
46
64
|
//# sourceMappingURL=rpc-notifications.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc-notifications.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-notifications.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,WAAW,eAAe;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"rpc-notifications.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-notifications.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,WAAW,eAAe;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAKD;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC7B,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,IAAI,EAAE,CAAC,YAAY,EAAE,eAAe,KAAK,IAAI,CAAC;CACjD;AAYD,iFAAiF;AACjF,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,IAAI,CAK3E;AAcD;;;;kFAIkF;AAClF,wBAAgB,gBAAgB,CAC5B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,SAAS,CAAC,EAAE,MAAM,GACnB,IAAI,CAoCN;AAED;;;;;;;;;;;;;;;;;4DAiB4D;AAC5D,wBAAgB,kBAAkB,CAAC,cAAc,SAAI,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,eAAe,EAAE,CAe5F;AAED;;;;;;;;sEAQsE;AACtE,wBAAgB,cAAc,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAO1D"}
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
2
|
+
/**
|
|
3
|
+
* Plugin-private localhost RPC server for TUI ↔ server-plugin communication.
|
|
4
|
+
*
|
|
5
|
+
* Runs on Bun (the OpenCode server runner is a Bun Worker), so it uses
|
|
6
|
+
* `Bun.serve` to host BOTH:
|
|
7
|
+
* - HTTP request/reply routes (`/health`, `/rpc/<method>`) — the TUI's snapshot
|
|
8
|
+
* and dialog-result calls, which are event-driven, not idle; and
|
|
9
|
+
* - a WebSocket endpoint (`/ws`) — a single persistent connection per TUI over
|
|
10
|
+
* which the server PUSHES notifications (dialog/toast actions). This replaces
|
|
11
|
+
* the old 500ms HTTP poll, whose new-connection-per-tick cost was the source
|
|
12
|
+
* of idle TUI CPU (#200). Pi never imports this module, so `Bun.serve` is safe.
|
|
13
|
+
*/
|
|
2
14
|
export declare class MagicContextRpcServer {
|
|
3
15
|
private server;
|
|
4
16
|
private port;
|
|
@@ -6,16 +18,23 @@ export declare class MagicContextRpcServer {
|
|
|
6
18
|
private portFilePath;
|
|
7
19
|
private portDir;
|
|
8
20
|
private startedAt;
|
|
21
|
+
/** Every authenticated WS socket, so dispose can close them all. */
|
|
22
|
+
private sockets;
|
|
9
23
|
private readonly token;
|
|
10
24
|
constructor(storageDir: string, directory: string);
|
|
11
25
|
/** Register an RPC method handler. */
|
|
12
26
|
handle(method: string, handler: RpcHandler): void;
|
|
13
27
|
/** Start the server on a random port, write port to disk. */
|
|
14
28
|
start(): Promise<number>;
|
|
15
|
-
|
|
16
|
-
/** Stop the server and clean up port file. */
|
|
29
|
+
/** Stop the server: close every socket, stop accepting, remove port file. */
|
|
17
30
|
stop(): void;
|
|
18
|
-
private
|
|
31
|
+
private warnIfOtherLiveInstance;
|
|
32
|
+
/** HTTP route handler (Bun fetch). Returns a Response, or undefined when the
|
|
33
|
+
* request was upgraded to a WebSocket. */
|
|
34
|
+
private handleFetch;
|
|
35
|
+
/** WS message handler: hello (auth + sink registration + backlog drain) and
|
|
36
|
+
* ack (cursor advance → queue prune). All other messages are ignored. */
|
|
37
|
+
private handleWsMessage;
|
|
19
38
|
}
|
|
20
39
|
export {};
|
|
21
40
|
//# sourceMappingURL=rpc-server.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rpc-server.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"rpc-server.d.ts","sourceRoot":"","sources":["../../src/shared/rpc-server.ts"],"names":[],"mappings":"AAqBA,KAAK,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAiCxF;;;;;;;;;;;GAWG;AACH,qBAAa,qBAAqB;IAC9B,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,IAAI,CAAK;IACjB,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAc;IAC/B,oEAAoE;IACpE,OAAO,CAAC,OAAO,CAAsC;IAMrD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmC;gBAE7C,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM;IAKjD,sCAAsC;IACtC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,GAAG,IAAI;IAIjD,6DAA6D;IACvD,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAmF9B,6EAA6E;IAC7E,IAAI,IAAI,IAAI;IAuBZ,OAAO,CAAC,uBAAuB;IAgB/B;+CAC2C;YAC7B,WAAW;IAuDzB;8EAC0E;IAC1E,OAAO,CAAC,eAAe;CA0D1B"}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { MagicContextRpcClient } from "../../shared/rpc-client";
|
|
1
2
|
import type { EmbedDetail, SidebarSnapshot, StatusDetail } from "../../shared/rpc-types";
|
|
2
3
|
export type { EmbedDetail, SidebarSnapshot, StatusDetail };
|
|
3
4
|
/** Initialize the RPC client. Call once on TUI startup. */
|
|
4
5
|
export declare function initRpcClient(directory: string): void;
|
|
5
6
|
export declare function getRpcGeneration(): number;
|
|
7
|
+
/** The live RPC client (for the WS notification socket's endpoint discovery).
|
|
8
|
+
* Null before init / after close. */
|
|
9
|
+
export declare function getRpcClient(): MagicContextRpcClient | null;
|
|
6
10
|
/** Clean up the RPC client. */
|
|
7
11
|
export declare function closeRpc(): void;
|
|
8
12
|
/** Fetch sidebar snapshot from the server via RPC. */
|
|
@@ -24,12 +28,6 @@ export declare function requestUpgrade(sessionId: string): Promise<boolean>;
|
|
|
24
28
|
export declare function dismissUpgradeReminder(sessionId: string): Promise<boolean>;
|
|
25
29
|
/** Resolve global toast duration from server config via RPC. */
|
|
26
30
|
export declare function loadToastDurationMs(): Promise<number>;
|
|
27
|
-
export interface TuiMessage {
|
|
28
|
-
id: number;
|
|
29
|
-
type: string;
|
|
30
|
-
payload: Record<string, unknown>;
|
|
31
|
-
sessionId?: string;
|
|
32
|
-
}
|
|
33
31
|
/**
|
|
34
32
|
* Fetch the current startup announcement from the server, if any.
|
|
35
33
|
* Returns `{show: false}` when there's nothing to announce or when the
|
|
@@ -44,12 +42,4 @@ export interface AnnouncementResponse {
|
|
|
44
42
|
export declare function getAnnouncement(): Promise<AnnouncementResponse>;
|
|
45
43
|
/** Mark the current ANNOUNCEMENT_VERSION as dismissed on the server. */
|
|
46
44
|
export declare function markAnnounced(): Promise<boolean>;
|
|
47
|
-
/** Poll for pending server→TUI notifications via RPC. */
|
|
48
|
-
export declare function consumeTuiMessages(sessionId: string): Promise<TuiMessage[]>;
|
|
49
|
-
/**
|
|
50
|
-
* Advance the delivered-message cursor for one active TUI session.
|
|
51
|
-
* Callers must pass only the contiguous handled prefix of the drained batch;
|
|
52
|
-
* this helper remains empty-safe and monotonic for that prefix.
|
|
53
|
-
*/
|
|
54
|
-
export declare function markTuiMessagesHandled(sessionId: string, messages: TuiMessage[]): void;
|
|
55
45
|
//# sourceMappingURL=context-db.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context-db.d.ts","sourceRoot":"","sources":["../../../src/tui/data/context-db.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"context-db.d.ts","sourceRoot":"","sources":["../../../src/tui/data/context-db.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,qBAAqB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEzF,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC;AAc3D,2DAA2D;AAC3D,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAOrD;AAED,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;sCACsC;AACtC,wBAAgB,YAAY,IAAI,qBAAqB,GAAG,IAAI,CAE3D;AAED,+BAA+B;AAC/B,wBAAgB,QAAQ,IAAI,IAAI,CAM/B;AA4FD,sDAAsD;AACtD,wBAAsB,mBAAmB,CACrC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,GAClB,OAAO,CAAC,eAAe,CAAC,CA4B1B;AAED,wDAAwD;AACxD,wBAAsB,gBAAgB,CAClC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,YAAY,CAAC,CA0CvB;AAYD,gEAAgE;AAChE,wBAAsB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAchG;AAED,qCAAqC;AACrC,wBAAsB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ5E;AAED,6CAA6C;AAC7C,wBAAsB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQvE;AAED;mFACmF;AACnF,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQxE;AAED;;iDAEiD;AACjD,wBAAsB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUhF;AAED,gEAAgE;AAChE,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CAQ3D;AAED;;;;GAIG;AACH,MAAM,WAAW,oBAAoB;IACjC,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,wBAAsB,eAAe,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAkBrE;AAED,wEAAwE;AACxE,wBAAsB,aAAa,IAAI,OAAO,CAAC,OAAO,CAAC,CAQtD"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent WebSocket to the server plugin's RPC server, replacing the old
|
|
3
|
+
* 500ms HTTP notification poll.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the TUI plugin and the server plugin run in separate Bun
|
|
6
|
+
* runners in the same process, so they bridge over a localhost socket. The old
|
|
7
|
+
* bridge polled `pending-notifications` over HTTP every 500ms — and each poll
|
|
8
|
+
* opened a NEW loopback TCP connection (Bun's fetch isn't pooled to our server),
|
|
9
|
+
* which was the entire source of idle TUI CPU (#200). A single long-lived WS
|
|
10
|
+
* carries server→TUI pushes with zero per-event connection cost, and the server
|
|
11
|
+
* pushes notifications the instant they're queued (no polling latency).
|
|
12
|
+
*
|
|
13
|
+
* Session scope: the socket carries the TUI's active session in its `hello` so
|
|
14
|
+
* the server delivers only that session's (plus global) notifications and its
|
|
15
|
+
* `isTuiConnected(session)` routing stays correct. The active session is tracked
|
|
16
|
+
* with a cheap watcher that only reads `api.route.current` (a property access,
|
|
17
|
+
* no IPC) and re-scopes the socket ONLY when the session actually changes — so
|
|
18
|
+
* unlike the old poll it does no network work at idle.
|
|
19
|
+
*/
|
|
20
|
+
export interface SocketNotification {
|
|
21
|
+
id: number;
|
|
22
|
+
type: string;
|
|
23
|
+
payload: Record<string, unknown>;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
}
|
|
26
|
+
interface NotificationSocketOptions {
|
|
27
|
+
/** Current active session id (re-read cheaply to follow session switches). */
|
|
28
|
+
getSessionId: () => string | null;
|
|
29
|
+
/** Handle one delivered notification. Returns true if it was consumed (so its
|
|
30
|
+
* id can advance the ack cursor). Async because dialog handlers await. */
|
|
31
|
+
onNotification: (notification: SocketNotification) => boolean | Promise<boolean>;
|
|
32
|
+
}
|
|
33
|
+
/** Open the persistent notification socket. Idempotent: a second call while open
|
|
34
|
+
* is a no-op. Reconnects on its own after any drop. */
|
|
35
|
+
export declare function startNotificationSocket(options: NotificationSocketOptions): void;
|
|
36
|
+
/** Close the socket and stop reconnecting. Call on TUI dispose. */
|
|
37
|
+
export declare function stopNotificationSocket(): void;
|
|
38
|
+
export {};
|
|
39
|
+
//# sourceMappingURL=notification-socket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification-socket.d.ts","sourceRoot":"","sources":["../../../src/tui/data/notification-socket.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH,MAAM,WAAW,kBAAkB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,UAAU,yBAAyB;IAC/B,8EAA8E;IAC9E,YAAY,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IAClC;+EAC2E;IAC3E,cAAc,EAAE,CAAC,YAAY,EAAE,kBAAkB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACpF;AAsBD;wDACwD;AACxD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,yBAAyB,GAAG,IAAI,CAQhF;AAED,mEAAmE;AACnE,wBAAgB,sBAAsB,IAAI,IAAI,CAkB7C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wolfx/opencode-magic-context",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode plugin for Magic Context — cross-session memory and context management",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"README.md"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external bun:sqlite --external node:sqlite && tsc --emitDeclarationOnly",
|
|
32
|
+
"build": "bun build src/index.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @huggingface/transformers --external onnxruntime-web --external bun:sqlite --external node:sqlite && tsc --emitDeclarationOnly",
|
|
33
33
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.scripts.json",
|
|
34
34
|
"test": "bun test",
|
|
35
35
|
"lint": "biome check .",
|
|
@@ -37,14 +37,14 @@ import { getMagicContextStorageDir } from "./data-path";
|
|
|
37
37
|
* Bump only when there are user-visible changes worth a startup dialog.
|
|
38
38
|
* Does NOT need to match the published package version.
|
|
39
39
|
*/
|
|
40
|
-
export const ANNOUNCEMENT_VERSION = "0.30.
|
|
40
|
+
export const ANNOUNCEMENT_VERSION = "0.30.2";
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
43
|
* Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
|
|
44
44
|
* TUI dialog renders cleanly without horizontal scroll on a typical terminal.
|
|
45
45
|
*/
|
|
46
46
|
export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
|
|
47
|
-
"
|
|
47
|
+
"Fixed high idle CPU from the TUI sidebar (#200): it now uses a single persistent connection to the plugin instead of polling, so an idle session no longer burns CPU.",
|
|
48
48
|
];
|
|
49
49
|
|
|
50
50
|
/**
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getCacheDir,
|
|
8
8
|
getDataDir,
|
|
9
9
|
getLegacyOpenCodeMagicContextStorageDir,
|
|
10
|
+
getMagicContextLogPath,
|
|
10
11
|
getMagicContextStorageDir,
|
|
11
12
|
getOpenCodeCacheDir,
|
|
12
13
|
getOpenCodeStorageDir,
|
|
@@ -18,6 +19,7 @@ const savedEnv = {
|
|
|
18
19
|
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
|
|
19
20
|
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
|
20
21
|
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
22
|
+
MAGIC_CONTEXT_LOG_PATH: process.env.MAGIC_CONTEXT_LOG_PATH,
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
describe("data-path", () => {
|
|
@@ -25,10 +27,12 @@ describe("data-path", () => {
|
|
|
25
27
|
process.env.XDG_CACHE_HOME = undefined;
|
|
26
28
|
process.env.XDG_DATA_HOME = undefined;
|
|
27
29
|
process.env.LOCALAPPDATA = undefined;
|
|
30
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = undefined;
|
|
28
31
|
// Bun's env handling: explicit delete for unset
|
|
29
32
|
delete process.env.XDG_CACHE_HOME;
|
|
30
33
|
delete process.env.XDG_DATA_HOME;
|
|
31
34
|
delete process.env.LOCALAPPDATA;
|
|
35
|
+
delete process.env.MAGIC_CONTEXT_LOG_PATH;
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
afterEach(() => {
|
|
@@ -37,6 +41,9 @@ describe("data-path", () => {
|
|
|
37
41
|
if (savedEnv.XDG_DATA_HOME !== undefined)
|
|
38
42
|
process.env.XDG_DATA_HOME = savedEnv.XDG_DATA_HOME;
|
|
39
43
|
if (savedEnv.LOCALAPPDATA !== undefined) process.env.LOCALAPPDATA = savedEnv.LOCALAPPDATA;
|
|
44
|
+
if (savedEnv.MAGIC_CONTEXT_LOG_PATH !== undefined)
|
|
45
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = savedEnv.MAGIC_CONTEXT_LOG_PATH;
|
|
46
|
+
else delete process.env.MAGIC_CONTEXT_LOG_PATH;
|
|
40
47
|
});
|
|
41
48
|
|
|
42
49
|
test("getCacheDir falls back to <homedir>/.cache when XDG_CACHE_HOME is unset (all platforms)", () => {
|
|
@@ -158,6 +165,27 @@ describe("data-path", () => {
|
|
|
158
165
|
path.join("/some/project/", ".cortexkit", "magic-context"),
|
|
159
166
|
);
|
|
160
167
|
});
|
|
168
|
+
|
|
169
|
+
test("getMagicContextLogPath falls back to the harness temp dir when the env override is unset", () => {
|
|
170
|
+
expect(getMagicContextLogPath("opencode")).toBe(
|
|
171
|
+
path.join(os.tmpdir(), "opencode", "magic-context", "magic-context.log"),
|
|
172
|
+
);
|
|
173
|
+
expect(getMagicContextLogPath("pi")).toBe(
|
|
174
|
+
path.join(os.tmpdir(), "pi", "magic-context", "magic-context.log"),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("getMagicContextLogPath honors MAGIC_CONTEXT_LOG_PATH", () => {
|
|
179
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = "/tmp/custom/magic-context.log";
|
|
180
|
+
expect(getMagicContextLogPath("pi")).toBe("/tmp/custom/magic-context.log");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("getMagicContextLogPath ignores a blank MAGIC_CONTEXT_LOG_PATH", () => {
|
|
184
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = " ";
|
|
185
|
+
expect(getMagicContextLogPath("pi")).toBe(
|
|
186
|
+
path.join(os.tmpdir(), "pi", "magic-context", "magic-context.log"),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
161
189
|
});
|
|
162
190
|
|
|
163
191
|
describe("ensureCortexKitArtifactGitignore", () => {
|
package/src/shared/data-path.ts
CHANGED
|
@@ -46,6 +46,11 @@ export function getMagicContextTempDir(harness: HarnessId = getHarness()): strin
|
|
|
46
46
|
* reflected in the next flush.
|
|
47
47
|
*/
|
|
48
48
|
export function getMagicContextLogPath(harness: HarnessId = getHarness()): string {
|
|
49
|
+
// An explicit override wins over the harness temp-dir default, so users on
|
|
50
|
+
// sandboxed/ephemeral setups (Docker, CI) can point the diagnostic log at a
|
|
51
|
+
// persistent or shared path. Blank/whitespace is treated as unset.
|
|
52
|
+
const envPath = process.env.MAGIC_CONTEXT_LOG_PATH?.trim();
|
|
53
|
+
if (envPath) return envPath;
|
|
49
54
|
return path.join(getMagicContextTempDir(harness), "magic-context.log");
|
|
50
55
|
}
|
|
51
56
|
|
package/src/shared/rpc-client.ts
CHANGED
|
@@ -97,6 +97,20 @@ export class MagicContextRpcClient {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Resolve the live server's port + bearer token (for opening the WS push
|
|
101
|
+
* channel). Reuses the same health-checked port-file discovery as `call`,
|
|
102
|
+
* so the WS client and the HTTP client always agree on which server instance
|
|
103
|
+
* (and token) to use. Returns null when no live server is found. */
|
|
104
|
+
async resolveEndpoint(): Promise<{ port: number; token: string | null } | null> {
|
|
105
|
+
try {
|
|
106
|
+
const port = await this.resolvePort();
|
|
107
|
+
if (port === null) return null;
|
|
108
|
+
return { port, token: this.token };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
private async resolvePort(): Promise<number | null> {
|
|
101
115
|
if (this.port && this.healthChecked) {
|
|
102
116
|
return this.port;
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
drainNotifications,
|
|
4
|
+
isTuiConnected,
|
|
5
|
+
type NotificationSink,
|
|
6
|
+
pushNotification,
|
|
7
|
+
registerNotificationSink,
|
|
8
|
+
} from "./rpc-notifications";
|
|
3
9
|
|
|
4
10
|
describe("rpc notifications", () => {
|
|
5
11
|
test("keeps messages queued until the client acks their id", () => {
|
|
@@ -45,17 +51,68 @@ describe("rpc notifications", () => {
|
|
|
45
51
|
expect(poll.map((m) => m.type).sort()).toEqual(["x", "y"]);
|
|
46
52
|
});
|
|
47
53
|
|
|
48
|
-
test("isTuiConnected
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
test("isTuiConnected reflects live WS sinks per-session", () => {
|
|
55
|
+
// No sinks → nothing connected.
|
|
56
|
+
expect(isTuiConnected("ses_anything")).toBe(false);
|
|
57
|
+
expect(isTuiConnected()).toBe(false);
|
|
58
|
+
|
|
59
|
+
// A live sink scoped to session A marks ONLY A connected (so B's producers
|
|
60
|
+
// don't misroute B's /ctx-status / upgrade reminder to the dialog path and
|
|
61
|
+
// lose it in an unrelated TUI), and the global query is also "connected".
|
|
62
|
+
const unregister = registerNotificationSink({ sessionId: "ses_A", send: () => {} });
|
|
63
|
+
expect(isTuiConnected("ses_A")).toBe(true);
|
|
64
|
+
expect(isTuiConnected("ses_B")).toBe(false);
|
|
58
65
|
expect(isTuiConnected()).toBe(true);
|
|
66
|
+
|
|
67
|
+
// Closing the socket removes the sink → disconnected again.
|
|
68
|
+
unregister();
|
|
69
|
+
expect(isTuiConnected("ses_A")).toBe(false);
|
|
70
|
+
expect(isTuiConnected()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a session-less sink counts as connected for any session query", () => {
|
|
74
|
+
const unregister = registerNotificationSink({ sessionId: undefined, send: () => {} });
|
|
75
|
+
expect(isTuiConnected("ses_whatever")).toBe(true);
|
|
76
|
+
expect(isTuiConnected()).toBe(true);
|
|
77
|
+
unregister();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("pushNotification fans out live to a matching sink and skips a foreign session", () => {
|
|
81
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
82
|
+
const received: string[] = [];
|
|
83
|
+
const sink: NotificationSink = {
|
|
84
|
+
sessionId: "ses_live",
|
|
85
|
+
send: (n) => received.push(n.type),
|
|
86
|
+
};
|
|
87
|
+
const unregister = registerNotificationSink(sink);
|
|
88
|
+
|
|
89
|
+
pushNotification("for-live", { action: "show-status-dialog" }, "ses_live");
|
|
90
|
+
pushNotification("for-other", { action: "show-status-dialog" }, "ses_other");
|
|
91
|
+
pushNotification("global", { action: "show-status-dialog" });
|
|
92
|
+
|
|
93
|
+
// The sink sees its own session + global, never the foreign session.
|
|
94
|
+
expect(received.sort()).toEqual(["for-live", "global"]);
|
|
95
|
+
unregister();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a dead sink (throwing send) does not block delivery to other sinks", () => {
|
|
99
|
+
drainNotifications(Number.MAX_SAFE_INTEGER);
|
|
100
|
+
const live: string[] = [];
|
|
101
|
+
const unregDead = registerNotificationSink({
|
|
102
|
+
sessionId: undefined,
|
|
103
|
+
send: () => {
|
|
104
|
+
throw new Error("socket dead");
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const unregLive = registerNotificationSink({
|
|
108
|
+
sessionId: undefined,
|
|
109
|
+
send: (n) => live.push(n.type),
|
|
110
|
+
});
|
|
111
|
+
// Must not throw, and the live sink still receives it.
|
|
112
|
+
expect(() => pushNotification("resilient", { ok: true })).not.toThrow();
|
|
113
|
+
expect(live).toEqual(["resilient"]);
|
|
114
|
+
unregDead();
|
|
115
|
+
unregLive();
|
|
59
116
|
});
|
|
60
117
|
|
|
61
118
|
test("queue-cap eviction is session-fair: a noisy session cannot evict another session's newest unseen item", () => {
|
|
@@ -16,31 +16,73 @@ export interface RpcNotification {
|
|
|
16
16
|
|
|
17
17
|
let queue: RpcNotification[] = [];
|
|
18
18
|
let nextNotificationId = 1;
|
|
19
|
-
// Timestamp of last drain — used to detect if a TUI is actively polling.
|
|
20
|
-
// The TUI polls every 500ms; we consider it connected if it polled within
|
|
21
|
-
// the last 3 seconds (6× the poll interval, tolerates transient delays).
|
|
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;
|
|
35
|
-
const TUI_CONNECTED_WINDOW_MS = 3_000;
|
|
36
19
|
|
|
37
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* A connected TUI notification sink — one per authenticated WebSocket. The RPC
|
|
22
|
+
* server registers a sink when a TUI socket authenticates (hello) and removes
|
|
23
|
+
* it on close. `send` is sink-agnostic (the server owns the actual WS socket)
|
|
24
|
+
* so this module stays free of Bun/WS types.
|
|
25
|
+
*/
|
|
26
|
+
export interface NotificationSink {
|
|
27
|
+
/** The TUI's active session at connect time (its hello scope). */
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
/** Deliver one notification over this sink's live socket. */
|
|
30
|
+
send: (notification: RpcNotification) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Live sinks replace the old poll-drain-timestamp inference. "TUI connected for
|
|
34
|
+
// a session" is now exact socket liveness — accurate and immediate — instead of
|
|
35
|
+
// "did a 500ms poll drain within the last 3s". Per-session scoping still matters:
|
|
36
|
+
// one process can serve MANY sessions (a TUI on session A plus an OpenCode
|
|
37
|
+
// Desktop opened on session B for the same project, whose newer RPC server this
|
|
38
|
+
// TUI's port discovery then selects). Each sink carries ITS session, so a
|
|
39
|
+
// B-scoped producer (`/ctx-status`, upgrade reminder) only sees B's TUI as
|
|
40
|
+
// connected and routes its dialog there, never to A.
|
|
41
|
+
const sinks = new Set<NotificationSink>();
|
|
42
|
+
|
|
43
|
+
/** Register a live TUI sink. Returns an unregister fn (call on socket close). */
|
|
44
|
+
export function registerNotificationSink(sink: NotificationSink): () => void {
|
|
45
|
+
sinks.add(sink);
|
|
46
|
+
return () => {
|
|
47
|
+
sinks.delete(sink);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a given notification may be delivered to a given sink. A global
|
|
52
|
+
* notification (no sessionId) reaches every sink; a session-scoped one reaches
|
|
53
|
+
* only sinks for that session (or session-less sinks). Mirrors the drain filter
|
|
54
|
+
* from the sink's perspective. */
|
|
55
|
+
function notificationMatchesSink(notification: RpcNotification, sink: NotificationSink): boolean {
|
|
56
|
+
return (
|
|
57
|
+
notification.sessionId === undefined ||
|
|
58
|
+
sink.sessionId === undefined ||
|
|
59
|
+
notification.sessionId === sink.sessionId
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Push a notification to the TUI. Fans out to any live WS sink immediately and
|
|
64
|
+
* also enqueues it so a TUI that is momentarily disconnected (reconnecting, or
|
|
65
|
+
* not yet connected) still receives it on its next hello via the backlog drain.
|
|
66
|
+
* At-least-once: a live push that the socket drops is re-delivered from the
|
|
67
|
+
* queue on reconnect (pruned only when the client acks via `lastReceivedId`). */
|
|
38
68
|
export function pushNotification(
|
|
39
69
|
type: string,
|
|
40
70
|
payload: Record<string, unknown>,
|
|
41
71
|
sessionId?: string,
|
|
42
72
|
): void {
|
|
43
|
-
|
|
73
|
+
const notification: RpcNotification = { id: nextNotificationId++, type, payload, sessionId };
|
|
74
|
+
queue.push(notification);
|
|
75
|
+
// Fan out to every live sink this notification is scoped to. A delivery throw
|
|
76
|
+
// (dead socket mid-send) must not block other sinks or the caller.
|
|
77
|
+
for (const sink of sinks) {
|
|
78
|
+
if (!notificationMatchesSink(notification, sink)) continue;
|
|
79
|
+
try {
|
|
80
|
+
sink.send(notification);
|
|
81
|
+
} catch {
|
|
82
|
+
// Socket died between liveness check and send; the close handler will
|
|
83
|
+
// unregister it, and the queue backlog re-delivers on reconnect.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
44
86
|
// Cap queue size to prevent unbounded growth if a TUI is not draining.
|
|
45
87
|
// Session-FAIR eviction: a naive `slice(-50)` drops the globally-oldest
|
|
46
88
|
// items, so a noisy session could evict ANOTHER session's single unseen
|
|
@@ -66,13 +108,12 @@ export function pushNotification(
|
|
|
66
108
|
}
|
|
67
109
|
|
|
68
110
|
/** Return pending notifications after acking the client's last received id.
|
|
69
|
-
* Updates lastDrainAt so isTuiConnected() reflects recent activity.
|
|
70
111
|
*
|
|
71
112
|
* Session scoping: when `sessionId` is provided, only notifications tagged for
|
|
72
113
|
* that session (or session-less/global ones) are returned and pruned — a
|
|
73
114
|
* notification tagged for a DIFFERENT session is never handed to this client
|
|
74
115
|
* 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
|
|
116
|
+
* queue is per-process but a TUI can end up bound to a process that also serves
|
|
76
117
|
* OTHER sessions: e.g. opening OpenCode Desktop on the same project starts a
|
|
77
118
|
* newer RPC server that the TUI's port discovery (newest-pid-wins) then selects,
|
|
78
119
|
* so a Desktop-session upgrade-dialog action would otherwise surface in an
|
|
@@ -82,11 +123,9 @@ export function pushNotification(
|
|
|
82
123
|
*
|
|
83
124
|
* Delivery is at-least-once (non-destructive return + prune-on-ack): a returned
|
|
84
125
|
* notification stays queued until a later call acks it via a higher
|
|
85
|
-
* `lastReceivedId`, so a
|
|
126
|
+
* `lastReceivedId`, so a dropped WS socket re-delivers the backlog on reconnect
|
|
127
|
+
* (the client sends its `lastReceivedId` in the hello). */
|
|
86
128
|
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
129
|
const matchesClient = (notification: RpcNotification): boolean =>
|
|
91
130
|
sessionId === undefined ||
|
|
92
131
|
notification.sessionId === undefined ||
|
|
@@ -103,20 +142,20 @@ export function drainNotifications(lastReceivedId = 0, sessionId?: string): RpcN
|
|
|
103
142
|
);
|
|
104
143
|
}
|
|
105
144
|
|
|
106
|
-
/** Whether a TUI client is
|
|
107
|
-
*
|
|
145
|
+
/** Whether a TUI client is connected via a live notification socket.
|
|
146
|
+
* Now exact socket liveness (a registered WS sink), not a poll-drain timestamp.
|
|
108
147
|
*
|
|
109
|
-
* Pass `sessionId` (preferred) to ask whether a TUI is
|
|
148
|
+
* Pass `sessionId` (preferred) to ask whether a TUI is connected FOR THAT
|
|
110
149
|
* SESSION — this is what producers (`/ctx-status`, `/ctx-recomp`, the upgrade
|
|
111
150
|
* 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.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
151
|
+
* session in the same process does not misroute their delivery. A session-less
|
|
152
|
+
* sink (legacy/global) counts for any session query. Omit `sessionId` only for
|
|
153
|
+
* callers with no session context; they get "any sink connected". */
|
|
115
154
|
export function isTuiConnected(sessionId?: string): boolean {
|
|
116
|
-
|
|
117
|
-
if (sessionId
|
|
118
|
-
|
|
119
|
-
|
|
155
|
+
if (sinks.size === 0) return false;
|
|
156
|
+
if (sessionId === undefined) return true;
|
|
157
|
+
for (const sink of sinks) {
|
|
158
|
+
if (sink.sessionId === undefined || sink.sessionId === sessionId) return true;
|
|
120
159
|
}
|
|
121
|
-
return
|
|
160
|
+
return false;
|
|
122
161
|
}
|