Skip to content

Commit 0d09c1b

Browse files
author
DavidQ
committed
docs: add BUILD_PR tool host state handoff bundle
1 parent d37bbac commit 0d09c1b

File tree

10 files changed

+261
-13
lines changed

10 files changed

+261
-13
lines changed

docs/dev/CODEX_COMMANDS.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ MODEL: GPT-5.4
22
REASONING: high
33

44
COMMAND:
5-
Create BUILD_PR_TOOL_HOST_MULTI_SWITCH
5+
Create BUILD_PR_TOOL_HOST_STATE_HANDOFF
66

77
Scope:
8-
- add multi-tool switching to host
9-
- enforce lifecycle correctness
10-
- minimal UI
8+
- add shared context for tools
9+
- allow optional state passing
10+
- minimal changes
1111

1212
Validation:
1313
- npm run test:launch-smoke -- --tools
14-
- verify switching works
14+
- verify state handoff works
1515

1616
Output:
17-
<project>/tmp/BUILD_PR_TOOL_HOST_MULTI_SWITCH_delta.zip
17+
<project>/tmp/BUILD_PR_TOOL_HOST_STATE_HANDOFF_delta.zip

docs/dev/COMMIT_COMMENT.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
docs: add BUILD_PR tool host multi-switch bundle
1+
docs: add BUILD_PR tool host state handoff bundle
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# BUILD_PR_TOOL_HOST_STATE_HANDOFF Report
2+
3+
## Scope Outcome
4+
- Added shared host context infrastructure for hosted tools.
5+
- Added optional JSON state passing from Tool Host UI to mounted tools.
6+
- Kept changes minimal and host-focused.
7+
8+
## Implementation Summary
9+
- New shared context helper:
10+
- `tools/shared/toolHostSharedContext.js`
11+
- Writes/reads/removes per-mount host context records.
12+
- Supports resolving context from `hostContextId` in URL.
13+
- Host runtime updates:
14+
- `tools/shared/toolHostRuntime.js`
15+
- Writes host context on mount and passes `hostContextId` in hosted tool URL.
16+
- Excludes object payloads from query-string expansion.
17+
- Cleans host context on unmount.
18+
- Tool Host UI updates:
19+
- `tools/Tool Host/index.html`
20+
- Added optional state JSON textarea.
21+
- `tools/Tool Host/main.js`
22+
- Parses optional JSON state and passes it with shared context during mount.
23+
24+
## Validation
25+
- `npm run test:launch-smoke -- --tools`
26+
- PASS (`9/9` tools)
27+
- Host state handoff verification (CDP):
28+
- Mounted `asset-browser` with optional JSON state payload.
29+
- Verified hosted iframe URL included `hostContextId`.
30+
- Verified stored context record matched tool id, shared context, and optional state payload.
31+
- Verified context key cleanup after unmount.
32+
- Console/runtime errors: none.
33+
34+
## Files Changed
35+
- `tools/shared/toolHostSharedContext.js`
36+
- `tools/shared/toolHostRuntime.js`
37+
- `tools/Tool Host/index.html`
38+
- `tools/Tool Host/main.js`
39+
- `docs/dev/reports/launch_smoke_report.md`
40+
- `docs/dev/reports/BUILD_PR_TOOL_HOST_STATE_HANDOFF_report.md`

docs/dev/reports/launch_smoke_report.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Launch Smoke Report
22

3-
Generated: 2026-04-11T23:48:31.310Z
3+
Generated: 2026-04-11T23:53:57.877Z
44

55
Filters: games=false, samples=false, tools=true, sampleRange=all
66

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
STATE HANDOFF TARGETS
2+
3+
- shared context object
4+
- getState()/setState()
5+
- optional tool participation
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# BUILD_PR_TOOL_HOST_STATE_HANDOFF
2+
3+
## Purpose
4+
Enable optional state handoff between tools within the Tool Host so workflows can span multiple tools without reload.
5+
6+
## Goals
7+
- shared project context
8+
- controlled state passing between tools
9+
- no tight coupling between tools
10+
11+
## Scope
12+
- shared state container
13+
- handoff interface (getState / setState)
14+
- minimal adapter layer
15+
16+
## Out of Scope
17+
- persistent storage redesign
18+
- editor state rewrites
19+
- UI changes
20+
21+
## Strategy
22+
- introduce shared context object
23+
- pass into init(container, config, context)
24+
- tools opt-in only
25+
26+
## Validation
27+
- npm run test:launch-smoke -- --tools
28+
- switch tools and pass simple state
29+
- no console errors

tools/Tool Host/index.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ <h2>Tool Host Foundation</h2>
1919
<select data-tool-host-select></select>
2020
</label>
2121
</div>
22+
<div class="meta">
23+
<label class="field" style="width:100%;">
24+
Optional State JSON
25+
<textarea data-tool-host-state-input rows="5" style="width:100%;" placeholder='{"example":"value"}'></textarea>
26+
</label>
27+
</div>
2228
<div class="meta">
2329
<button type="button" data-tool-host-mount>Load Selected Tool</button>
2430
<button type="button" data-tool-host-prev>Previous Tool</button>

tools/Tool Host/main.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createToolHostRuntime } from "../shared/toolHostRuntime.js";
33

44
const refs = {
55
toolSelect: document.querySelector("[data-tool-host-select]"),
6+
stateInput: document.querySelector("[data-tool-host-state-input]"),
67
mountButton: document.querySelector("[data-tool-host-mount]"),
78
prevButton: document.querySelector("[data-tool-host-prev]"),
89
nextButton: document.querySelector("[data-tool-host-next]"),
@@ -126,12 +127,31 @@ function mountSelectedTool(source = "manual") {
126127
writeStatus("Select a tool to mount.");
127128
return;
128129
}
130+
let optionalState = null;
131+
if (refs.stateInput instanceof HTMLTextAreaElement) {
132+
const rawState = refs.stateInput.value.trim();
133+
if (rawState) {
134+
try {
135+
optionalState = JSON.parse(rawState);
136+
} catch {
137+
writeStatus("State JSON is invalid. Fix JSON or clear the state field.");
138+
return;
139+
}
140+
}
141+
}
129142
updateSwitchMeta();
130143
updateStandaloneHref(toolId);
131144
writeQueryToolId(toolId, source === "init");
145+
const previousMount = runtime.getCurrentMount();
132146
runtime.mountTool(toolId, {
133147
source,
134-
requestedAt: new Date().toISOString()
148+
requestedAt: new Date().toISOString(),
149+
sharedContext: {
150+
requestedToolId: toolId,
151+
previousToolId: previousMount?.tool?.id || "",
152+
switchPosition: `${Math.max(1, getSelectedToolIndex() + 1)}/${Math.max(1, toolIds.length)}`
153+
},
154+
state: optionalState
135155
});
136156
}
137157

tools/shared/toolHostRuntime.js

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import { getToolHostEntryById } from "./toolHostManifest.js";
2+
import {
3+
removeToolHostSharedContextById,
4+
writeToolHostSharedContext
5+
} from "./toolHostSharedContext.js";
26

37
function normalizeToolId(toolId) {
48
return typeof toolId === "string" ? toolId.trim() : "";
59
}
610

7-
function buildHostLaunchUrl(toolEntry, config = {}) {
11+
function buildHostLaunchUrl(toolEntry, config = {}, hostContextId = "") {
812
const url = new URL(toolEntry.launchPath, window.location.href);
913
url.searchParams.set("hosted", "1");
1014
url.searchParams.set("hostToolId", toolEntry.id);
15+
if (hostContextId) {
16+
url.searchParams.set("hostContextId", hostContextId);
17+
}
1118

1219
if (config && typeof config === "object") {
1320
Object.entries(config).forEach(([key, value]) => {
14-
if (value === undefined || value === null) {
21+
if (key === "state" || key === "sharedContext") {
22+
return;
23+
}
24+
if (value === undefined || value === null || typeof value === "object") {
1525
return;
1626
}
1727
url.searchParams.set(`hostConfig_${key}`, String(value));
@@ -92,6 +102,9 @@ export function createToolHostRuntime(options = {}) {
92102
previous.frame.removeAttribute("src");
93103
mountContainer.removeChild(previous.frame);
94104
}
105+
if (previous.hostContextId) {
106+
removeToolHostSharedContextById(previous.hostContextId);
107+
}
95108
onStatus(`Unmounted ${previous.tool.displayName} (${reason}, destroy=${destroyStatus}).`);
96109
onUnmounted(previous.tool, reason, destroyStatus);
97110
return true;
@@ -118,7 +131,16 @@ export function createToolHostRuntime(options = {}) {
118131

119132
mountSequence += 1;
120133
const sequenceId = mountSequence;
121-
const sourceUrl = buildHostLaunchUrl(toolEntry, config);
134+
const sharedContext = config.sharedContext && typeof config.sharedContext === "object" ? config.sharedContext : {};
135+
const hostContext = writeToolHostSharedContext({
136+
toolId: toolEntry.id,
137+
source: typeof config.source === "string" ? config.source : "",
138+
requestedAt: typeof config.requestedAt === "string" ? config.requestedAt : new Date().toISOString(),
139+
sharedContext,
140+
state: Object.prototype.hasOwnProperty.call(config, "state") ? config.state : null
141+
});
142+
const hostContextId = hostContext?.contextId || "";
143+
const sourceUrl = buildHostLaunchUrl(toolEntry, config, hostContextId);
122144
const frame = createHostFrame(toolEntry, sourceUrl);
123145
frame.addEventListener("load", () => {
124146
if (!currentMount || currentMount.mountSequence !== sequenceId) {
@@ -141,7 +163,8 @@ export function createToolHostRuntime(options = {}) {
141163
frame,
142164
sourceUrl,
143165
mountedAt: new Date().toISOString(),
144-
mountSequence: sequenceId
166+
mountSequence: sequenceId,
167+
hostContextId
145168
};
146169

147170
onStatus(`Mounting ${toolEntry.displayName}...`);
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
const TOOL_HOST_CONTEXT_KEY_PREFIX = "toolboxaid.toolHost.context.";
2+
3+
function getHostStorage() {
4+
try {
5+
if (globalThis.sessionStorage) {
6+
return globalThis.sessionStorage;
7+
}
8+
} catch {}
9+
10+
try {
11+
if (globalThis.localStorage) {
12+
return globalThis.localStorage;
13+
}
14+
} catch {}
15+
16+
return null;
17+
}
18+
19+
function safeParseJson(rawValue) {
20+
if (typeof rawValue !== "string" || !rawValue.trim()) {
21+
return null;
22+
}
23+
try {
24+
return JSON.parse(rawValue);
25+
} catch {
26+
return null;
27+
}
28+
}
29+
30+
function buildContextStorageKey(contextId) {
31+
return `${TOOL_HOST_CONTEXT_KEY_PREFIX}${contextId}`;
32+
}
33+
34+
function createContextId(toolId = "") {
35+
const suffix = Math.random().toString(36).slice(2, 10);
36+
return `${toolId || "tool"}-${Date.now().toString(36)}-${suffix}`;
37+
}
38+
39+
function sanitizeToolId(toolId) {
40+
return typeof toolId === "string" ? toolId.trim() : "";
41+
}
42+
43+
export function createToolHostSharedContext(payload = {}) {
44+
const toolId = sanitizeToolId(payload.toolId);
45+
const source = typeof payload.source === "string" ? payload.source : "";
46+
const sharedContext = payload.sharedContext && typeof payload.sharedContext === "object" ? payload.sharedContext : {};
47+
const contextId = typeof payload.contextId === "string" && payload.contextId.trim()
48+
? payload.contextId.trim()
49+
: createContextId(toolId);
50+
51+
return {
52+
schema: "tools.tool-host-context/1",
53+
contextId,
54+
toolId,
55+
source,
56+
hosted: true,
57+
requestedAt: typeof payload.requestedAt === "string" ? payload.requestedAt : new Date().toISOString(),
58+
sharedContext: { ...sharedContext },
59+
state: payload.state === undefined ? null : payload.state
60+
};
61+
}
62+
63+
export function writeToolHostSharedContext(payload = {}) {
64+
const storage = getHostStorage();
65+
if (!storage) {
66+
return null;
67+
}
68+
69+
const context = createToolHostSharedContext(payload);
70+
storage.setItem(buildContextStorageKey(context.contextId), JSON.stringify(context));
71+
return context;
72+
}
73+
74+
export function readToolHostSharedContextById(contextId) {
75+
const normalized = typeof contextId === "string" ? contextId.trim() : "";
76+
if (!normalized) {
77+
return null;
78+
}
79+
80+
const storage = getHostStorage();
81+
if (!storage) {
82+
return null;
83+
}
84+
85+
return safeParseJson(storage.getItem(buildContextStorageKey(normalized)));
86+
}
87+
88+
export function removeToolHostSharedContextById(contextId) {
89+
const normalized = typeof contextId === "string" ? contextId.trim() : "";
90+
if (!normalized) {
91+
return false;
92+
}
93+
94+
const storage = getHostStorage();
95+
if (!storage) {
96+
return false;
97+
}
98+
99+
storage.removeItem(buildContextStorageKey(normalized));
100+
return true;
101+
}
102+
103+
export function readToolHostSharedContextFromLocation(locationLike = null) {
104+
const rawHref = typeof locationLike === "string"
105+
? locationLike
106+
: (locationLike && typeof locationLike.href === "string" ? locationLike.href : (typeof window !== "undefined" ? window.location.href : ""));
107+
108+
if (!rawHref) {
109+
return null;
110+
}
111+
112+
let params = null;
113+
try {
114+
params = new URL(rawHref).searchParams;
115+
} catch {
116+
return null;
117+
}
118+
119+
const contextId = params.get("hostContextId") || "";
120+
if (!contextId) {
121+
return null;
122+
}
123+
124+
return readToolHostSharedContextById(contextId);
125+
}

0 commit comments

Comments
 (0)