@wolfx/opencode-magic-context 0.22.3 → 0.23.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/dist/agents/magic-context-prompt.d.ts +1 -1
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/agents/permissions.d.ts +4 -4
- package/dist/agents/permissions.d.ts.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +30 -0
- package/dist/config/project-security.d.ts.map +1 -0
- package/dist/config/prune-config-leaf.d.ts +27 -0
- package/dist/config/prune-config-leaf.d.ts.map +1 -0
- package/dist/config/schema/magic-context.d.ts +7 -13
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/config/variable.d.ts.map +1 -1
- package/dist/features/magic-context/compartment-storage.d.ts +9 -6
- package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/scheduler.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/key-files/aft-availability.d.ts.map +1 -1
- package/dist/features/magic-context/key-files/identify-key-files.d.ts.map +1 -1
- package/dist/features/magic-context/key-files/read-stats.d.ts +0 -2
- package/dist/features/magic-context/key-files/read-stats.d.ts.map +1 -1
- package/dist/features/magic-context/memory/constants.d.ts +7 -0
- package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-ssrf.d.ts +29 -0
- package/dist/features/magic-context/memory/embedding-ssrf.d.ts.map +1 -0
- package/dist/features/magic-context/memory/memory-migration.d.ts.map +1 -1
- package/dist/features/magic-context/memory/project-identity.d.ts +10 -0
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/project-docs-hash.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/range-parser.d.ts +6 -0
- package/dist/features/magic-context/range-parser.d.ts.map +1 -1
- package/dist/features/magic-context/sidekick/agent.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +1 -1
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +124 -16
- 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 +15 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
- package/dist/features/magic-context/storage-tags.d.ts +118 -1
- package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +3 -3
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/features/magic-context/tagger.d.ts +12 -2
- package/dist/features/magic-context/tagger.d.ts.map +1 -1
- package/dist/features/magic-context/tool-definition-tokens.d.ts +21 -0
- package/dist/features/magic-context/tool-definition-tokens.d.ts.map +1 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts +2 -1
- package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
- package/dist/features/magic-context/types.d.ts +12 -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/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
- package/dist/hooks/magic-context/channel2-delivery.d.ts +22 -0
- package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -0
- package/dist/hooks/magic-context/command-handler.d.ts +1 -7
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts +1 -1
- package/dist/hooks/magic-context/compaction-marker-manager.d.ts.map +1 -1
- 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-partial-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-types.d.ts +5 -0
- package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-runner-validation.d.ts +25 -0
- 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/compartment-trigger.d.ts +47 -2
- package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +117 -0
- package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -0
- package/dist/hooks/magic-context/decay-render.d.ts.map +1 -1
- package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts +36 -1
- package/dist/hooks/magic-context/drop-stale-reduce-calls.d.ts.map +1 -1
- package/dist/hooks/magic-context/emergency-drop.d.ts +86 -0
- package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -0
- package/dist/hooks/magic-context/event-handler.d.ts +6 -4
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-flush.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts +10 -3
- package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts +3 -9
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +3 -5
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts +41 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/note-visibility.d.ts +1 -1
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts +132 -0
- package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts +55 -0
- package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-raw.d.ts +91 -0
- package/dist/hooks/magic-context/read-session-raw.d.ts.map +1 -1
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts +70 -0
- package/dist/hooks/magic-context/read-session-true-raw-tokens.d.ts.map +1 -0
- package/dist/hooks/magic-context/reference-retrieval.d.ts.map +1 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts +2 -1
- package/dist/hooks/magic-context/send-session-notification.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts +0 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-messages.d.ts +3 -0
- package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
- package/dist/hooks/magic-context/todo-view.d.ts +1 -1
- package/dist/hooks/magic-context/tool-drop-target.d.ts +9 -0
- package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts +15 -0
- package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +9 -11
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +22 -9
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/upgrade-reminder.d.ts +2 -1
- package/dist/hooks/magic-context/upgrade-reminder.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5915 -1336
- package/dist/plugin/conflict-warning-hook.d.ts.map +1 -1
- package/dist/plugin/embedding-bootstrap-helpers.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts +1 -1
- package/dist/plugin/hooks/create-session-hooks.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 +4 -6
- package/dist/shared/announcement.d.ts.map +1 -1
- package/dist/shared/keep-subagents.d.ts +7 -0
- package/dist/shared/keep-subagents.d.ts.map +1 -0
- package/dist/shared/live-server-client.d.ts +50 -0
- package/dist/shared/live-server-client.d.ts.map +1 -0
- package/dist/shared/prompt-context.d.ts +31 -0
- package/dist/shared/prompt-context.d.ts.map +1 -0
- package/dist/shared/rpc-server.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +0 -3
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/safe-notification-target.d.ts +23 -0
- package/dist/shared/safe-notification-target.d.ts.map +1 -0
- package/dist/shared/tag-transcript.d.ts.map +1 -1
- package/dist/shared/transcript-opencode.d.ts.map +1 -1
- package/dist/shared/transcript.d.ts +15 -1
- package/dist/shared/transcript.d.ts.map +1 -1
- package/dist/tools/ctx-expand/constants.d.ts +1 -1
- package/dist/tools/ctx-expand/constants.d.ts.map +1 -1
- package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
- package/dist/tools/ctx-memory/constants.d.ts +1 -1
- package/dist/tools/ctx-memory/constants.d.ts.map +1 -1
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-memory/types.d.ts +7 -3
- package/dist/tools/ctx-memory/types.d.ts.map +1 -1
- package/dist/tools/ctx-note/constants.d.ts +1 -1
- package/dist/tools/ctx-note/constants.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tools/ctx-note/types.d.ts +4 -0
- package/dist/tools/ctx-note/types.d.ts.map +1 -1
- package/dist/tools/ctx-search/constants.d.ts +1 -1
- package/dist/tools/ctx-search/constants.d.ts.map +1 -1
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/package.json +3 -1
- package/src/shared/announcement.test.ts +18 -0
- package/src/shared/announcement.ts +35 -20
- package/src/shared/keep-subagents.test.ts +39 -0
- package/src/shared/keep-subagents.ts +33 -0
- package/src/shared/live-server-client.ts +152 -0
- package/src/shared/prompt-context.ts +135 -0
- package/src/shared/rpc-server.ts +18 -2
- package/src/shared/rpc-types.ts +0 -3
- package/src/shared/safe-notification-target.test.ts +97 -0
- package/src/shared/safe-notification-target.ts +102 -0
- package/src/shared/tag-transcript.test.ts +34 -8
- package/src/shared/tag-transcript.ts +110 -8
- package/src/shared/transcript-opencode.ts +15 -5
- package/src/shared/transcript.ts +20 -2
- package/src/tui/data/context-db.ts +0 -3
- package/src/tui/index.tsx +11 -10
- package/src/tui/slots/sidebar-content.tsx +1 -26
- package/dist/hooks/magic-context/apply-context-nudge.d.ts +0 -5
- package/dist/hooks/magic-context/apply-context-nudge.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudge-bands.d.ts +0 -6
- package/dist/hooks/magic-context/nudge-bands.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudge-injection.d.ts +0 -7
- package/dist/hooks/magic-context/nudge-injection.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudge-placement-store.d.ts +0 -15
- package/dist/hooks/magic-context/nudge-placement-store.d.ts.map +0 -1
- package/dist/hooks/magic-context/nudger.d.ts +0 -21
- package/dist/hooks/magic-context/nudger.d.ts.map +0 -1
|
@@ -23,18 +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.23.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
|
-
"
|
|
33
|
+
"Smarter context nudges: gentle <system-reminder> notes on tool outputs replace the old chat nudges — quieter, cache-safe, and they now also help subagents manage their own context.",
|
|
34
|
+
"The agent can now maintain its own project memories: update, archive (batch), and merge the memories it sees — not just write new ones. Memory categories are schema-enforced.",
|
|
35
|
+
"Big-session performance: historian trigger and token math moved off the database hot path — multi-second stalls on large sessions are gone (measured 250ms → 2.4ms per pass).",
|
|
36
|
+
"History compaction unstuck for sparse sessions: the protected-tail boundary is now size-based, so sessions with few user turns compact reliably instead of growing forever (issue #132).",
|
|
37
|
+
"Fixed: session titles no longer fail to generate in fresh directories (issue #129), plus 20+ correctness fixes from three audit rounds.",
|
|
38
38
|
];
|
|
39
39
|
|
|
40
40
|
/**
|
|
@@ -52,23 +52,33 @@ function getStateFilePath(): string {
|
|
|
52
52
|
return path.join(getMagicContextStorageDir(), STATE_FILENAME);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*/
|
|
62
|
-
export function readLastAnnouncedVersion(): string {
|
|
55
|
+
type AnnouncementStateRead =
|
|
56
|
+
| { status: "missing" }
|
|
57
|
+
| { status: "valid"; version: string }
|
|
58
|
+
| { status: "error" };
|
|
59
|
+
|
|
60
|
+
function readAnnouncementState(): AnnouncementStateRead {
|
|
63
61
|
try {
|
|
64
62
|
const file = getStateFilePath();
|
|
65
|
-
if (!fs.existsSync(file)) return "";
|
|
66
|
-
|
|
63
|
+
if (!fs.existsSync(file)) return { status: "missing" };
|
|
64
|
+
const version = fs.readFileSync(file, "utf-8").trim();
|
|
65
|
+
if (!version) return { status: "error" };
|
|
66
|
+
return { status: "valid", version };
|
|
67
67
|
} catch {
|
|
68
|
-
return "";
|
|
68
|
+
return { status: "error" };
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Read the most recently dismissed announcement version, or `""` if none can be
|
|
74
|
+
* returned. Callers that need to distinguish first-run from read/corruption
|
|
75
|
+
* failures should use the internal tri-state path in `shouldShowAnnouncement`.
|
|
76
|
+
*/
|
|
77
|
+
export function readLastAnnouncedVersion(): string {
|
|
78
|
+
const state = readAnnouncementState();
|
|
79
|
+
return state.status === "valid" ? state.version : "";
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
/**
|
|
73
83
|
* Persist `version` as the most recently dismissed announcement. Best-effort:
|
|
74
84
|
* write failures are swallowed so dialog-confirm flows never throw on storage
|
|
@@ -108,12 +118,17 @@ export function markAnnouncementSeen(version: string): void {
|
|
|
108
118
|
*/
|
|
109
119
|
export function shouldShowAnnouncement(): boolean {
|
|
110
120
|
if (!ANNOUNCEMENT_VERSION || ANNOUNCEMENT_FEATURES.length === 0) return false;
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
121
|
+
const state = readAnnouncementState();
|
|
122
|
+
if (state.status === "missing") {
|
|
113
123
|
// No prior state: fresh install or wiped sandbox. Seed to current and
|
|
114
124
|
// skip the announcement so we never pester first-run / ephemeral envs.
|
|
115
125
|
markAnnouncementSeen(ANNOUNCEMENT_VERSION);
|
|
116
126
|
return false;
|
|
117
127
|
}
|
|
118
|
-
|
|
128
|
+
if (state.status === "error") {
|
|
129
|
+
// A corrupt or temporarily unreadable existing state file is not first-run.
|
|
130
|
+
// Do not advance the version; a later successful boot can still show it.
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
return state.version !== ANNOUNCEMENT_VERSION;
|
|
119
134
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
_resetKeepSubagentsForTesting,
|
|
4
|
+
setKeepSubagents,
|
|
5
|
+
shouldKeepSubagents,
|
|
6
|
+
} from "./keep-subagents";
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
_resetKeepSubagentsForTesting();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("keep-subagents flag", () => {
|
|
13
|
+
it("#given default #then subagent sessions are NOT kept (deleted on success)", () => {
|
|
14
|
+
expect(shouldKeepSubagents()).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("#given setKeepSubagents(true) #then sessions are kept", () => {
|
|
18
|
+
setKeepSubagents(true);
|
|
19
|
+
expect(shouldKeepSubagents()).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("#given setKeepSubagents(false) #then sessions are not kept", () => {
|
|
23
|
+
setKeepSubagents(true);
|
|
24
|
+
setKeepSubagents(false);
|
|
25
|
+
expect(shouldKeepSubagents()).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("#given a non-true value #then coerces to false (only strict true keeps)", () => {
|
|
29
|
+
// boot wiring passes `config.keep_subagents === true`, but guard anyway.
|
|
30
|
+
setKeepSubagents(undefined as unknown as boolean);
|
|
31
|
+
expect(shouldKeepSubagents()).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("#given reset helper #then returns to default false", () => {
|
|
35
|
+
setKeepSubagents(true);
|
|
36
|
+
_resetKeepSubagentsForTesting();
|
|
37
|
+
expect(shouldKeepSubagents()).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug / data-collection switch: when enabled, Magic Context does NOT delete
|
|
3
|
+
* the child sessions it spawns for its own subagents (historian, dreamer,
|
|
4
|
+
* sidekick, memory-migration, key-files, user-memory review, recomp).
|
|
5
|
+
*
|
|
6
|
+
* By default these child sessions are deleted on success (only FAILED ones are
|
|
7
|
+
* kept for debugging). With `keep_subagents: true` ALL of them are retained, so
|
|
8
|
+
* their full transcript — prompt, tool calls, token usage, model output — stays
|
|
9
|
+
* inspectable in OpenCode's session store / the dashboard. Intended for
|
|
10
|
+
* short-term data collection (e.g. profiling what the dreamer actually does)
|
|
11
|
+
* before the dreamer v2 overhaul, NOT for steady-state use — kept sessions
|
|
12
|
+
* accumulate in the host's session DB until manually cleared.
|
|
13
|
+
*
|
|
14
|
+
* Process-global, set once at boot from config (mirrors `harness.ts`). A
|
|
15
|
+
* config change requires a restart to take effect. NEVER thread this through
|
|
16
|
+
* per-call args — it's a coarse, boot-time debug toggle.
|
|
17
|
+
*/
|
|
18
|
+
let keepSubagents = false;
|
|
19
|
+
|
|
20
|
+
/** Set at plugin boot from `keep_subagents` config. */
|
|
21
|
+
export function setKeepSubagents(value: boolean): void {
|
|
22
|
+
keepSubagents = value === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when subagent child sessions should be retained (not deleted). */
|
|
26
|
+
export function shouldKeepSubagents(): boolean {
|
|
27
|
+
return keepSubagents;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Test-only reset. Do NOT call from production paths. */
|
|
31
|
+
export function _resetKeepSubagentsForTesting(): void {
|
|
32
|
+
keepSubagents = false;
|
|
33
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-server client for the Channel 2 ctx_reduce ceiling nudge (a synthetic
|
|
3
|
+
* user `<system-reminder>` delivered via `promptAsync`).
|
|
4
|
+
*
|
|
5
|
+
* WHY a separate client instead of the plugin-provided `input.client`:
|
|
6
|
+
* OpenCode's plugin `input.client` routes through `Server.Default().app.fetch`,
|
|
7
|
+
* which uses a SEPARATE Effect `memoMap` from the live HTTP listener the UI
|
|
8
|
+
* uses. `SessionRunState` lives per-memoMap, so a plugin-origin `promptAsync`
|
|
9
|
+
* observes an "idle" runner while the live turn is still running, `ensureRunning`
|
|
10
|
+
* fails to coalesce, and OpenCode persists duplicate assistant children
|
|
11
|
+
* (upstream bug anomalyco/opencode#28202). Building a `createOpencodeClient`
|
|
12
|
+
* aimed at `input.serverUrl` via `globalThis.fetch` enters the SAME live
|
|
13
|
+
* listener, so `ensureRunning` sees the real run and coalesces — the synthetic
|
|
14
|
+
* message lands at the tail after the current assistant step.
|
|
15
|
+
*
|
|
16
|
+
* The live listener is only reachable on OpenCode Desktop (Electron+Node) and
|
|
17
|
+
* TUI launched with `--port 0`; plain TUI binds an internal listener that 404s
|
|
18
|
+
* `/session/*`. We probe once at init and cache per `serverUrl`. When
|
|
19
|
+
* unreachable, Channel 2 is DISABLED (Channel 1 + 85% force-materialization
|
|
20
|
+
* remain the backstop) — MC deliberately does NOT fall back to the in-process
|
|
21
|
+
* client because that would knowingly trigger #28202.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createOpencodeClient } from "@opencode-ai/sdk";
|
|
25
|
+
|
|
26
|
+
export type LiveServerClient = ReturnType<typeof createOpencodeClient>;
|
|
27
|
+
|
|
28
|
+
const clientCache = new Map<string, LiveServerClient>();
|
|
29
|
+
|
|
30
|
+
function cacheKey(serverUrl: string, directory: string): string {
|
|
31
|
+
return `${serverUrl}|${directory}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeServerUrl(serverUrl: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(serverUrl).toString();
|
|
37
|
+
} catch {
|
|
38
|
+
return serverUrl;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Basic-auth header OpenCode expects when `OPENCODE_SERVER_PASSWORD` is set. */
|
|
43
|
+
function serverAuthHeaders(): Record<string, string> | undefined {
|
|
44
|
+
const password = process.env.OPENCODE_SERVER_PASSWORD;
|
|
45
|
+
if (!password) return undefined;
|
|
46
|
+
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
|
|
47
|
+
return {
|
|
48
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cached `createOpencodeClient` aimed at the live HTTP listener for the given
|
|
54
|
+
* `(serverUrl, directory)`. One client is reused across deliveries.
|
|
55
|
+
*/
|
|
56
|
+
export function getLiveServerClient(serverUrl: string, directory: string): LiveServerClient {
|
|
57
|
+
const key = cacheKey(serverUrl, directory);
|
|
58
|
+
const cached = clientCache.get(key);
|
|
59
|
+
if (cached) return cached;
|
|
60
|
+
const client = createOpencodeClient({
|
|
61
|
+
baseUrl: serverUrl,
|
|
62
|
+
directory,
|
|
63
|
+
headers: serverAuthHeaders(),
|
|
64
|
+
fetch: globalThis.fetch,
|
|
65
|
+
});
|
|
66
|
+
clientCache.set(key, client);
|
|
67
|
+
return client;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Per-serverUrl wake decision + probe TTL. One plugin process can host multiple
|
|
71
|
+
// OpenCode windows with different listener URLs, so the decision must be keyed.
|
|
72
|
+
interface ProbeDecision {
|
|
73
|
+
reachable: boolean;
|
|
74
|
+
probedAt: number;
|
|
75
|
+
}
|
|
76
|
+
const wakeDecisionByServerUrl = new Map<string, ProbeDecision>();
|
|
77
|
+
|
|
78
|
+
// Re-probe window: a transient 404/timeout shouldn't permanently disable
|
|
79
|
+
// Channel 2 for the whole session lifetime (per council-r3).
|
|
80
|
+
const PROBE_TTL_MS = 10 * 60_000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Probe whether `serverUrl` serves OpenCode's HTTP API within `timeoutMs`.
|
|
84
|
+
* `true` only when `/session` proves the API is usable: any 2xx, or 401/403
|
|
85
|
+
* (auth-protected listener still exists). `false` for 404 (plain TUI internal
|
|
86
|
+
* listener), 5xx, connection refused, DNS failure, timeout, or malformed URL.
|
|
87
|
+
* Records the result + timestamp in the per-serverUrl cache.
|
|
88
|
+
*/
|
|
89
|
+
export async function probeServerReachable(
|
|
90
|
+
serverUrl: string | undefined,
|
|
91
|
+
timeoutMs = 1500,
|
|
92
|
+
): Promise<boolean> {
|
|
93
|
+
if (!serverUrl) return false;
|
|
94
|
+
const normalized = normalizeServerUrl(serverUrl);
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
97
|
+
let reachable = false;
|
|
98
|
+
try {
|
|
99
|
+
const probeUrl = new URL("/session", serverUrl).toString();
|
|
100
|
+
const res = await globalThis.fetch(probeUrl, {
|
|
101
|
+
method: "GET",
|
|
102
|
+
headers: serverAuthHeaders(),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
reachable = res.ok || res.status === 401 || res.status === 403;
|
|
106
|
+
} catch {
|
|
107
|
+
reachable = false;
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
wakeDecisionByServerUrl.set(normalized, { reachable, probedAt: Date.now() });
|
|
111
|
+
}
|
|
112
|
+
return reachable;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Record a probe result directly (test helper / explicit override). */
|
|
116
|
+
export function setLiveServerWakeAvailable(
|
|
117
|
+
serverUrl: string | undefined,
|
|
118
|
+
available: boolean,
|
|
119
|
+
): void {
|
|
120
|
+
if (!serverUrl) return;
|
|
121
|
+
wakeDecisionByServerUrl.set(normalizeServerUrl(serverUrl), {
|
|
122
|
+
reachable: available,
|
|
123
|
+
probedAt: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Should Channel 2 deliver through the live-server client for `serverUrl`?
|
|
129
|
+
* Returns false when never probed or the last probe failed. A stale decision
|
|
130
|
+
* (older than the TTL) returns false so the caller re-probes before delivering.
|
|
131
|
+
*/
|
|
132
|
+
export function useLiveServerWake(serverUrl?: string): boolean {
|
|
133
|
+
if (!serverUrl) return false;
|
|
134
|
+
const decision = wakeDecisionByServerUrl.get(normalizeServerUrl(serverUrl));
|
|
135
|
+
if (!decision) return false;
|
|
136
|
+
if (Date.now() - decision.probedAt > PROBE_TTL_MS) return false;
|
|
137
|
+
return decision.reachable;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when a usable (non-stale) probe decision exists, regardless of outcome. */
|
|
141
|
+
export function hasFreshProbe(serverUrl?: string): boolean {
|
|
142
|
+
if (!serverUrl) return false;
|
|
143
|
+
const decision = wakeDecisionByServerUrl.get(normalizeServerUrl(serverUrl));
|
|
144
|
+
if (!decision) return false;
|
|
145
|
+
return Date.now() - decision.probedAt <= PROBE_TTL_MS;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Test helper — reset both caches between cases. */
|
|
149
|
+
export function __resetLiveServerClientForTests(): void {
|
|
150
|
+
clientCache.clear();
|
|
151
|
+
wakeDecisionByServerUrl.clear();
|
|
152
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the newest effective prompt context (agent + model + variant) for a
|
|
3
|
+
* session by reading recent messages from the OpenCode HTTP API.
|
|
4
|
+
*
|
|
5
|
+
* WHY: a Channel 2 ceiling nudge sends a synthetic user message via
|
|
6
|
+
* `promptAsync` with `noReply:false` (it DOES trigger an assistant turn).
|
|
7
|
+
* OpenCode's `createUserMessage` resolves variant relative to the chosen
|
|
8
|
+
* agent; passing model alone makes OpenCode pick the default agent whose model
|
|
9
|
+
* check then fails, bypassing the active variant and busting the provider
|
|
10
|
+
* prefix cache the prior turn warmed. So we pass agent + model + variant
|
|
11
|
+
* explicitly, mirroring the resolution AFT/opencode-xtra use for their wake
|
|
12
|
+
* notifications.
|
|
13
|
+
*
|
|
14
|
+
* Walk newest→oldest and merge field-by-field so the newest context-bearing
|
|
15
|
+
* message wins while older messages only fill fields it did not provide. Read
|
|
16
|
+
* BOTH the flat shape (`info.providerID`) used by AssistantMessage and the
|
|
17
|
+
* nested shape (`info.model.providerID`) used by UserMessage.
|
|
18
|
+
*
|
|
19
|
+
* Bounded via `query.limit` — the legacy `/session/{id}/message` endpoint
|
|
20
|
+
* hydrates the ENTIRE session without it (30k-45k messages on large sessions).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ResolvedPromptContext {
|
|
24
|
+
agent?: string;
|
|
25
|
+
model?: { providerID: string; modelID: string };
|
|
26
|
+
variant?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RawInfo {
|
|
30
|
+
role?: string;
|
|
31
|
+
agent?: string;
|
|
32
|
+
variant?: string;
|
|
33
|
+
providerID?: string;
|
|
34
|
+
modelID?: string;
|
|
35
|
+
model?: { providerID?: string; modelID?: string; variant?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
39
|
+
return typeof value === "object" && value !== null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractMessages(response: unknown): unknown[] {
|
|
43
|
+
if (Array.isArray(response)) return response;
|
|
44
|
+
if (isRecord(response) && Array.isArray(response.data)) return response.data;
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function extractFromMessage(message: unknown): ResolvedPromptContext | null {
|
|
49
|
+
if (!isRecord(message) || !isRecord(message.info)) return null;
|
|
50
|
+
const info = message.info as RawInfo;
|
|
51
|
+
const modelInfo = isRecord(info.model) ? info.model : undefined;
|
|
52
|
+
|
|
53
|
+
const agent = typeof info.agent === "string" ? info.agent : undefined;
|
|
54
|
+
const providerID =
|
|
55
|
+
typeof modelInfo?.providerID === "string"
|
|
56
|
+
? modelInfo.providerID
|
|
57
|
+
: typeof info.providerID === "string"
|
|
58
|
+
? info.providerID
|
|
59
|
+
: undefined;
|
|
60
|
+
const modelID =
|
|
61
|
+
typeof modelInfo?.modelID === "string"
|
|
62
|
+
? modelInfo.modelID
|
|
63
|
+
: typeof info.modelID === "string"
|
|
64
|
+
? info.modelID
|
|
65
|
+
: undefined;
|
|
66
|
+
const variant =
|
|
67
|
+
typeof modelInfo?.variant === "string"
|
|
68
|
+
? modelInfo.variant
|
|
69
|
+
: typeof info.variant === "string"
|
|
70
|
+
? info.variant
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
73
|
+
if (!agent && (!providerID || !modelID) && !variant) return null;
|
|
74
|
+
const out: ResolvedPromptContext = {};
|
|
75
|
+
if (agent) out.agent = agent;
|
|
76
|
+
if (providerID && modelID) out.model = { providerID, modelID };
|
|
77
|
+
if (variant) out.variant = variant;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function mergeContexts(
|
|
82
|
+
base: ResolvedPromptContext,
|
|
83
|
+
patch: ResolvedPromptContext,
|
|
84
|
+
): ResolvedPromptContext {
|
|
85
|
+
return {
|
|
86
|
+
agent: base.agent ?? patch.agent,
|
|
87
|
+
model: base.model ?? patch.model,
|
|
88
|
+
variant: base.variant ?? patch.variant,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isComplete(ctx: ResolvedPromptContext): boolean {
|
|
93
|
+
return Boolean(ctx.agent && ctx.model && ctx.variant);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PROMPT_CONTEXT_MESSAGE_LIMIT = 50;
|
|
97
|
+
|
|
98
|
+
export async function resolvePromptContext(
|
|
99
|
+
client: unknown,
|
|
100
|
+
sessionId: string,
|
|
101
|
+
): Promise<ResolvedPromptContext | null> {
|
|
102
|
+
if (!client || !sessionId) return null;
|
|
103
|
+
const c = client as {
|
|
104
|
+
session?: {
|
|
105
|
+
messages?: (input: {
|
|
106
|
+
path: { id: string };
|
|
107
|
+
query?: { limit?: number };
|
|
108
|
+
}) => Promise<{ data?: unknown[] } | unknown[]>;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
if (typeof c.session?.messages !== "function") return null;
|
|
112
|
+
|
|
113
|
+
let messages: unknown[] = [];
|
|
114
|
+
try {
|
|
115
|
+
const response = await c.session.messages({
|
|
116
|
+
path: { id: sessionId },
|
|
117
|
+
query: { limit: PROMPT_CONTEXT_MESSAGE_LIMIT },
|
|
118
|
+
});
|
|
119
|
+
messages = extractMessages(response);
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
if (messages.length === 0) return null;
|
|
124
|
+
|
|
125
|
+
let result: ResolvedPromptContext = {};
|
|
126
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
127
|
+
const ctx = extractFromMessage(messages[i]);
|
|
128
|
+
if (!ctx) continue;
|
|
129
|
+
result = mergeContexts(result, ctx);
|
|
130
|
+
if (isComplete(result)) return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!result.agent && !result.model && !result.variant) return null;
|
|
134
|
+
return result;
|
|
135
|
+
}
|
package/src/shared/rpc-server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
mkdirSync,
|
|
4
4
|
readdirSync,
|
|
@@ -14,6 +14,19 @@ import { isPidAlive, parseRpcPortFile, rpcPortDir, rpcPortFilePath } from "./rpc
|
|
|
14
14
|
|
|
15
15
|
type RpcHandler = (params: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Constant-time bearer-token comparison. `timingSafeEqual` throws on
|
|
19
|
+
* length-mismatched buffers, so guard on length first (the length itself is not
|
|
20
|
+
* secret — the token bytes are). Avoids leaking the token via response-timing on
|
|
21
|
+
* the loopback auth check.
|
|
22
|
+
*/
|
|
23
|
+
function tokensMatch(presented: string, expected: string): boolean {
|
|
24
|
+
const a = Buffer.from(presented, "utf8");
|
|
25
|
+
const b = Buffer.from(expected, "utf8");
|
|
26
|
+
if (a.length !== b.length) return false;
|
|
27
|
+
return timingSafeEqual(a, b);
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
export class MagicContextRpcServer {
|
|
18
31
|
private server: Server | null = null;
|
|
19
32
|
private port = 0;
|
|
@@ -149,9 +162,12 @@ export class MagicContextRpcServer {
|
|
|
149
162
|
// Require the per-process bearer token on every side-effecting call.
|
|
150
163
|
// The legitimate TUI client reads it from the same port file it used to
|
|
151
164
|
// discover the port; a process that only guessed the port cannot.
|
|
165
|
+
// Constant-time compare so a local attacker can't byte-probe the token
|
|
166
|
+
// via response-timing (length-guard first, since timingSafeEqual throws
|
|
167
|
+
// on length mismatch).
|
|
152
168
|
const auth = req.headers.authorization;
|
|
153
169
|
const presented = typeof auth === "string" ? auth.replace(/^Bearer\s+/i, "") : "";
|
|
154
|
-
if (presented
|
|
170
|
+
if (!tokensMatch(presented, this.token)) {
|
|
155
171
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
156
172
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
157
173
|
req.resume();
|
package/src/shared/rpc-types.ts
CHANGED
|
@@ -97,7 +97,6 @@ export interface StatusDetail extends SidebarSnapshot {
|
|
|
97
97
|
activeBytes: number;
|
|
98
98
|
lastResponseTime: number;
|
|
99
99
|
lastNudgeTokens: number;
|
|
100
|
-
lastNudgeBand: string;
|
|
101
100
|
lastTransformError: string | null;
|
|
102
101
|
isSubagent: boolean;
|
|
103
102
|
pendingOps: Array<{ tagId: number; operation: string }>;
|
|
@@ -118,9 +117,7 @@ export interface StatusDetail extends SidebarSnapshot {
|
|
|
118
117
|
*/
|
|
119
118
|
executeThresholdTokens?: number;
|
|
120
119
|
protectedTagCount: number;
|
|
121
|
-
nudgeInterval: number;
|
|
122
120
|
historyBudgetPercentage: number;
|
|
123
|
-
nextNudgeAfter: number;
|
|
124
121
|
historyBlockTokens: number;
|
|
125
122
|
compressionBudget: number | null;
|
|
126
123
|
compressionUsage: string | null;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { isDefaultSessionTitle, waitForSafeNotificationTarget } from "./safe-notification-target";
|
|
3
|
+
|
|
4
|
+
function clientWithTitle(title: string | undefined, calls?: { count: number }) {
|
|
5
|
+
return {
|
|
6
|
+
session: {
|
|
7
|
+
get: async (_input: unknown) => {
|
|
8
|
+
if (calls) calls.count += 1;
|
|
9
|
+
return { data: { title } };
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("isDefaultSessionTitle", () => {
|
|
16
|
+
it("matches OpenCode default titles for parent and child sessions", () => {
|
|
17
|
+
expect(isDefaultSessionTitle("New session - 2026-06-10T15:33:11.538Z")).toBe(true);
|
|
18
|
+
expect(isDefaultSessionTitle("Child session - 2026-01-02T03:04:05.678Z")).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("does not match real titles", () => {
|
|
22
|
+
expect(isDefaultSessionTitle("Quick test")).toBe(false);
|
|
23
|
+
expect(isDefaultSessionTitle("New session - notes")).toBe(false);
|
|
24
|
+
// Prefix alone isn't enough — the timestamp must match exactly,
|
|
25
|
+
// mirroring OpenCode's Session.isDefaultTitle.
|
|
26
|
+
expect(isDefaultSessionTitle("New session - 2026-06-10")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("waitForSafeNotificationTarget", () => {
|
|
31
|
+
it("returns safe immediately for a titled session", async () => {
|
|
32
|
+
const calls = { count: 0 };
|
|
33
|
+
const result = await waitForSafeNotificationTarget(
|
|
34
|
+
clientWithTitle("Fix tagger collision", calls),
|
|
35
|
+
"ses-titled",
|
|
36
|
+
{ attempts: 4, delayMs: 1 },
|
|
37
|
+
);
|
|
38
|
+
expect(result).toBe("safe");
|
|
39
|
+
expect(calls.count).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns skip after exhausting attempts on a default-titled session", async () => {
|
|
43
|
+
const calls = { count: 0 };
|
|
44
|
+
const result = await waitForSafeNotificationTarget(
|
|
45
|
+
clientWithTitle("New session - 2026-06-10T15:33:11.538Z", calls),
|
|
46
|
+
"ses-fresh",
|
|
47
|
+
{ attempts: 3, delayMs: 1 },
|
|
48
|
+
);
|
|
49
|
+
expect(result).toBe("skip");
|
|
50
|
+
expect(calls.count).toBe(3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns safe once the title flips to a real one mid-retry", async () => {
|
|
54
|
+
let call = 0;
|
|
55
|
+
const client = {
|
|
56
|
+
session: {
|
|
57
|
+
get: async () => {
|
|
58
|
+
call += 1;
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
title: call < 2 ? "New session - 2026-06-10T15:33:11.538Z" : "Greeting",
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const result = await waitForSafeNotificationTarget(client, "ses-flip", {
|
|
68
|
+
attempts: 4,
|
|
69
|
+
delayMs: 1,
|
|
70
|
+
});
|
|
71
|
+
expect(result).toBe("safe");
|
|
72
|
+
expect(call).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fails open when the client cannot report a title", async () => {
|
|
76
|
+
expect(
|
|
77
|
+
await waitForSafeNotificationTarget({}, "ses-no-api", { attempts: 2, delayMs: 1 }),
|
|
78
|
+
).toBe("safe");
|
|
79
|
+
const throwing = {
|
|
80
|
+
session: {
|
|
81
|
+
get: async () => {
|
|
82
|
+
throw new Error("transport down");
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
expect(
|
|
87
|
+
await waitForSafeNotificationTarget(throwing, "ses-throw", { attempts: 2, delayMs: 1 }),
|
|
88
|
+
).toBe("safe");
|
|
89
|
+
// Direct-shape response (no `.data` wrapper) is also recognized.
|
|
90
|
+
const direct = {
|
|
91
|
+
session: { get: async () => ({ title: "Real title" }) },
|
|
92
|
+
};
|
|
93
|
+
expect(
|
|
94
|
+
await waitForSafeNotificationTarget(direct, "ses-direct", { attempts: 2, delayMs: 1 }),
|
|
95
|
+
).toBe("safe");
|
|
96
|
+
});
|
|
97
|
+
});
|