clankerbend 0.1.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.
@@ -0,0 +1,221 @@
1
+ export const VIM_NAV_APP_ID = "onewill.vim-nav";
2
+
3
+ export function createVimNavigatorApp(options = {}) {
4
+ return {
5
+ appId: VIM_NAV_APP_ID,
6
+ name: "Vim Navigator",
7
+ publicDir: options.publicDir,
8
+ contributes: {
9
+ panel: true,
10
+ annotations: true,
11
+ commands: true,
12
+ actions: true,
13
+ appState: true
14
+ },
15
+ permissions: {
16
+ transcriptRead: true,
17
+ transcriptAnnotate: true,
18
+ transcriptNavigate: true,
19
+ appServerRead: false,
20
+ appServerApprove: false,
21
+ appServerRollback: false
22
+ },
23
+ panel: {
24
+ title: "Vim Nav",
25
+ reloadPolicy: "preserve",
26
+ preferredWidth: 360
27
+ },
28
+
29
+ getManifest(context) {
30
+ return {
31
+ oneWhackVersion: context.protocolVersion,
32
+ appId: VIM_NAV_APP_ID,
33
+ name: "Vim Navigator",
34
+ entry: context.entry,
35
+ contributes: this.contributes,
36
+ permissions: this.permissions,
37
+ panel: this.panel
38
+ };
39
+ },
40
+
41
+ getState(context) {
42
+ const anchors = context.transcript.anchors || [];
43
+ return {
44
+ appId: VIM_NAV_APP_ID,
45
+ status: context.desktop.cdpStatus === "connected" ? "ready" : "degraded",
46
+ source: context.desktop.target?.type === "mock" ? "mock" : "dom",
47
+ connected: context.desktop.cdpStatus === "connected",
48
+ entries: anchors.slice(0, 300).map((anchor) => ({
49
+ entryId: entryId(anchor.anchorId),
50
+ appId: VIM_NAV_APP_ID,
51
+ anchorId: anchor.anchorId,
52
+ markerId: markerId(anchor.anchorId),
53
+ correlationId: anchor.appServer?.itemId || undefined,
54
+ title: `${anchorLabel(anchor)} ${anchor.inferredRole || anchor.kind}`,
55
+ summary: anchor.textPreview,
56
+ category: "navigation",
57
+ tone: anchor.anchorId === context.selection?.anchorId ? "accent" : anchor.visible ? "success" : "muted",
58
+ status: anchor.visible ? "visible" : "offscreen",
59
+ detail: {
60
+ order: anchor.order,
61
+ indexed: anchor.indexed !== false,
62
+ role: anchor.inferredRole || "unknown"
63
+ },
64
+ occurredAt: context.transcript.updatedAt
65
+ })),
66
+ annotations: anchors.slice(0, 300).map((anchor) => ({
67
+ appId: VIM_NAV_APP_ID,
68
+ anchorId: anchor.anchorId,
69
+ placement: "leading-rail",
70
+ priority: 50,
71
+ markers: [{
72
+ markerId: markerId(anchor.anchorId),
73
+ glyph: anchorLabel(anchor),
74
+ label: anchor.indexed === false ? "Transcript index pending" : `Jump to transcript item ${anchor.order}`,
75
+ tone: anchor.anchorId === context.selection?.anchorId ? "accent" : "muted",
76
+ shape: "circle",
77
+ entryId: entryId(anchor.anchorId),
78
+ action: {
79
+ type: "vim.jump",
80
+ payload: { anchorId: anchor.anchorId }
81
+ }
82
+ }]
83
+ })),
84
+ commands: [
85
+ command("vim.prev", "Previous transcript item", "vim.jumpRelative", "k", { delta: -1 }),
86
+ command("vim.next", "Next transcript item", "vim.jumpRelative", "j", { delta: 1 }),
87
+ command("vim.first", "First transcript item", "vim.jumpIndex", "gg", { index: 0 }),
88
+ command("vim.last", "Last transcript item", "vim.jumpIndex", "G", { index: -1 }),
89
+ command("vim.prevUser", "Previous user message", "vim.jumpRole", "{", { role: "user", direction: -1 }),
90
+ command("vim.nextUser", "Next user message", "vim.jumpRole", "}", { role: "user", direction: 1 }),
91
+ command("vim.latestAssistant", "Latest assistant message", "vim.latestRole", ":latest assistant", { role: "assistant" }),
92
+ command("vim.latestUser", "Latest user message", "vim.latestRole", ":latest user", { role: "user" }),
93
+ command("vim.search", "Search transcript text", "vim.search", "/term", { query: "" })
94
+ ],
95
+ updatedAt: context.state.generatedAt
96
+ };
97
+ },
98
+
99
+ async handleAction(action, context) {
100
+ if (action.type === "vim.jump") {
101
+ const anchorId = action.anchorId || action.payload?.anchorId;
102
+ return jump(action, context, anchorId);
103
+ }
104
+
105
+ if (action.type === "vim.jumpRelative") {
106
+ const current = context.selection?.anchorId ||
107
+ context.transcript.anchors.find((anchor) => anchor.visible)?.anchorId ||
108
+ context.transcript.anchors[0]?.anchorId;
109
+ const index = Math.max(0, context.anchorIndex(current));
110
+ const delta = Number(action.payload?.delta || 0);
111
+ const target = context.transcript.anchors[Math.max(0, Math.min(context.transcript.anchors.length - 1, index + delta))];
112
+ return jump(action, context, target?.anchorId);
113
+ }
114
+
115
+ if (action.type === "vim.jumpIndex") {
116
+ const rawIndex = Number(action.payload?.index || 0);
117
+ const index = rawIndex < 0 ? context.transcript.anchors.length - 1 : rawIndex;
118
+ return jump(action, context, context.transcript.anchors[index]?.anchorId);
119
+ }
120
+
121
+ if (action.type === "vim.jumpRole") {
122
+ const role = String(action.payload?.role || "");
123
+ const direction = Number(action.payload?.direction || 1) < 0 ? -1 : 1;
124
+ return jump(action, context, findRoleAnchor(context, role, direction)?.anchorId);
125
+ }
126
+
127
+ if (action.type === "vim.latestRole") {
128
+ const role = String(action.payload?.role || "");
129
+ return jump(action, context, latestRoleAnchor(context, role)?.anchorId);
130
+ }
131
+
132
+ if (action.type === "vim.search") {
133
+ const query = String(action.payload?.query || "").trim().toLowerCase();
134
+ const direction = Number(action.payload?.direction || 1) < 0 ? -1 : 1;
135
+ if (!query) throw context.httpError(400, "bad_request", "search query is required");
136
+ return jump(action, context, searchAnchor(context, query, direction)?.anchorId);
137
+ }
138
+
139
+ throw context.httpError(404, "not_found", `unknown action: ${action.type}`);
140
+ }
141
+ };
142
+ }
143
+
144
+ async function jump(action, context, anchorId) {
145
+ if (!context.anchorExists(anchorId)) throw context.httpError(404, "not_found", "anchor not found");
146
+ context.acceptSelection({
147
+ selectionId: `sel_${action.actionId}`,
148
+ source: "panel",
149
+ appId: VIM_NAV_APP_ID,
150
+ anchorId,
151
+ entryId: entryId(anchorId),
152
+ selectedAt: action.requestedAt || new Date().toISOString()
153
+ }, { broadcast: false });
154
+
155
+ const scroll = await context.scrollToAnchor(anchorId, action.payload || {});
156
+ if (!scroll?.ok) throw context.httpError(503, "adapter_unavailable", scroll?.error || "scroll failed");
157
+ await context.highlightAnchor(anchorId, { durationMs: action.payload?.durationMs || 1200 });
158
+ return applied(action, { anchorId });
159
+ }
160
+
161
+ function findRoleAnchor(context, role, direction) {
162
+ if (!isRole(role)) throw context.httpError(400, "bad_request", "role must be user, assistant, system, or tool");
163
+ const anchors = context.transcript.anchors || [];
164
+ const current = context.selection?.anchorId || anchors.find((anchor) => anchor.visible)?.anchorId || anchors[0]?.anchorId;
165
+ const start = Math.max(0, context.anchorIndex(current));
166
+ for (let index = start + direction; index >= 0 && index < anchors.length; index += direction) {
167
+ if (anchors[index].inferredRole === role) return anchors[index];
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function latestRoleAnchor(context, role) {
173
+ if (!isRole(role)) throw context.httpError(400, "bad_request", "role must be user, assistant, system, or tool");
174
+ const anchors = context.transcript.anchors || [];
175
+ for (let index = anchors.length - 1; index >= 0; index -= 1) {
176
+ if (anchors[index].inferredRole === role) return anchors[index];
177
+ }
178
+ return null;
179
+ }
180
+
181
+ function searchAnchor(context, query, direction) {
182
+ const anchors = context.transcript.anchors || [];
183
+ const current = context.selection?.anchorId || anchors.find((anchor) => anchor.visible)?.anchorId || anchors[0]?.anchorId;
184
+ const start = Math.max(0, context.anchorIndex(current));
185
+ for (let offset = 1; offset <= anchors.length; offset += 1) {
186
+ const index = (start + direction * offset + anchors.length) % anchors.length;
187
+ if ((anchors[index].textPreview || "").toLowerCase().includes(query)) return anchors[index];
188
+ }
189
+ return null;
190
+ }
191
+
192
+ function anchorLabel(anchor) {
193
+ return anchor.indexed === false ? "?" : String(anchor.order).padStart(2, "0");
194
+ }
195
+
196
+ function isRole(role) {
197
+ return ["user", "assistant", "system", "tool"].includes(role);
198
+ }
199
+
200
+ function applied(action, data) {
201
+ return {
202
+ actionId: action.actionId,
203
+ appId: VIM_NAV_APP_ID,
204
+ ok: true,
205
+ status: "applied",
206
+ data,
207
+ completedAt: new Date().toISOString()
208
+ };
209
+ }
210
+
211
+ function command(commandId, label, type, shortcut, payload = {}) {
212
+ return { commandId, appId: VIM_NAV_APP_ID, label, type, shortcut, enabled: true, payload };
213
+ }
214
+
215
+ function entryId(anchorId) {
216
+ return `nav:${anchorId}`;
217
+ }
218
+
219
+ function markerId(anchorId) {
220
+ return `ruler:${anchorId}`;
221
+ }
Binary file
package/cli.mjs ADDED
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { launchOneWhackCodex } from "./server.mjs";
6
+
7
+ const ROOT = dirname(fileURLToPath(import.meta.url));
8
+ const PACKAGE_JSON = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
9
+
10
+ try {
11
+ await main(process.argv.slice(2));
12
+ } catch (err) {
13
+ console.error(err.message);
14
+ process.exit(1);
15
+ }
16
+
17
+ async function main(argv) {
18
+ const [command, ...args] = argv;
19
+
20
+ if (!command || command === "help" || command === "--help" || command === "-h") {
21
+ printHelp();
22
+ return;
23
+ }
24
+
25
+ if (command === "version" || command === "--version" || command === "-v") {
26
+ console.log(PACKAGE_JSON.version);
27
+ return;
28
+ }
29
+
30
+ if (command === "codex") {
31
+ const options = parseCodexArgs(args);
32
+ await launchOneWhackCodex(options);
33
+ return;
34
+ }
35
+
36
+ console.error(`Unknown command: ${command}`);
37
+ printHelp();
38
+ process.exit(1);
39
+ }
40
+
41
+ function parseCodexArgs(args) {
42
+ const options = {
43
+ mock: false
44
+ };
45
+ for (let index = 0; index < args.length; index += 1) {
46
+ const arg = args[index];
47
+ if (arg === "--mock") {
48
+ options.mock = true;
49
+ } else if (arg === "--state-dir") {
50
+ options.stateDir = requiredArg(args[++index], "--state-dir value");
51
+ } else if (arg === "--help" || arg === "-h") {
52
+ printCodexHelp();
53
+ process.exit(0);
54
+ } else {
55
+ throw new Error(`unknown codex option: ${arg}`);
56
+ }
57
+ }
58
+ return options;
59
+ }
60
+
61
+ function printHelp() {
62
+ console.log(`OneWhack
63
+
64
+ Usage:
65
+ clankerbend codex [--mock] [--state-dir path]
66
+ clankerbend help
67
+ clankerbend --version
68
+
69
+ Environment:
70
+ ONEWILL_ONEWHACK_STATE_DIR Override OneWhack runtime state directory.
71
+ ONEWILL_ONEWHACK_TOKEN Use a specific bearer token for host endpoints.
72
+ ONEWILL_ONEWHACK_DISABLE_AUTH=1 Disable bearer auth for local development only.
73
+ `);
74
+ }
75
+
76
+ function printCodexHelp() {
77
+ console.log(`OneWhack Codex launcher
78
+
79
+ Usage:
80
+ clankerbend codex [--mock] [--state-dir path]
81
+
82
+ Options:
83
+ --mock Start against a mock transcript instead of Codex Desktop.
84
+ --state-dir path Override OneWhack runtime state directory.
85
+ `);
86
+ }
87
+
88
+ function requiredArg(value, label) {
89
+ if (!value) throw new Error(`${label} is required`);
90
+ return value;
91
+ }
@@ -0,0 +1,63 @@
1
+ # OneWhack App Lifecycle
2
+
3
+ OneWhack separates app installation from profile activation.
4
+
5
+ ## Install
6
+
7
+ Installing records a manifest in the runtime registry. The public `onewhack`
8
+ CLI intentionally does not expose app-management commands; app installation is
9
+ a product configuration concern for the running OneWhack experience.
10
+
11
+ The registry stores app id, version, manifest path, distribution metadata,
12
+ bundle kind, source path, and install time. The current host loads bundled
13
+ manifests and additional manifest paths from this registry; copying directories
14
+ or tarballs into a managed app store is reserved for installer tooling. The
15
+ runtime root defaults to:
16
+
17
+ - macOS: `~/Library/Application Support/OneWill/OneWhack`
18
+ - Linux: `~/.local/state/onewill/onewhack`
19
+
20
+ Set `ONEWILL_ONEWHACK_STATE_DIR` to override the root location.
21
+
22
+ `distribution.integrity` is required. Local development installs can use
23
+ `integrity: "dev-local"`. Downloaded packages should be installed with a real
24
+ `--integrity sha256-...` value supplied by the package index.
25
+
26
+ ## Enable And Disable
27
+
28
+ Profiles decide which installed apps are active.
29
+
30
+ Enablement is profile-scoped. A host can launch with the default profile or a
31
+ custom user profile while using the same app registry. The default registry
32
+ config path is `registry.json` under the runtime state directory, and launchers
33
+ may override it with `ONEWILL_ONEWHACK_REGISTRY_CONFIG`.
34
+
35
+ ## Start
36
+
37
+ At startup the launcher builds a profile from app manifests. The host loads app
38
+ factories, validates capabilities, injects configured renderer bridges, and
39
+ serves each panel under:
40
+
41
+ ```text
42
+ /apps/:appId/
43
+ ```
44
+
45
+ Apps receive only their declared context. For example, an app with
46
+ `transcriptRead: false` sees an empty transcript state.
47
+
48
+ ## Update
49
+
50
+ Manifest `distribution.update` is metadata for update tools. An updater should
51
+ revalidate a replacement manifest and update the installed app record while
52
+ preserving profile enablement.
53
+
54
+ A future updater can replace the app bundle, validate the new manifest, compare
55
+ `oneWhackVersion`, and update the registry atomically.
56
+
57
+ ## Remove
58
+
59
+ Removing an app deletes it from the installed app registry and disables it from
60
+ all profiles.
61
+
62
+ Remove does not delete arbitrary files from disk unless a future package manager
63
+ owns those files and the manifest lifecycle explicitly allows that behavior.
@@ -0,0 +1,130 @@
1
+ # OneWhack App Manifest
2
+
3
+ OneWhack apps are installed and launched from a `onewhack.manifest.json` file.
4
+ The manifest is the stable contract between the app bundle and the host. The
5
+ host loads local manifests through this schema. The schema also reserves npm
6
+ packages, tarballs, and binary-style bundles for installer tooling.
7
+
8
+ ## Required Fields
9
+
10
+ ```json
11
+ {
12
+ "oneWhackVersion": "0.1",
13
+ "appId": "onewill.example.app",
14
+ "version": "0.1.0",
15
+ "name": "Example App",
16
+ "description": "Short human-readable description.",
17
+ "distribution": {
18
+ "kind": "local",
19
+ "source": ".",
20
+ "integrity": "dev-local",
21
+ "update": {
22
+ "channel": "local"
23
+ }
24
+ },
25
+ "entrypoint": {
26
+ "kind": "module",
27
+ "module": "./src/example-app.js",
28
+ "factory": "createExampleApp",
29
+ "publicDir": "./public"
30
+ },
31
+ "platform": {
32
+ "os": ["darwin", "linux", "win32"],
33
+ "arch": ["any"]
34
+ },
35
+ "capabilities": {
36
+ "panel": true,
37
+ "annotations": true,
38
+ "commands": true,
39
+ "actions": true,
40
+ "appState": true,
41
+ "selectionActions": false,
42
+ "overlays": false,
43
+ "composerContext": false,
44
+ "composerDraft": false,
45
+ "rendererBridge": false
46
+ },
47
+ "permissions": {
48
+ "transcriptRead": true,
49
+ "transcriptAnnotate": true,
50
+ "transcriptNavigate": true,
51
+ "overlayWrite": false,
52
+ "composerWrite": false,
53
+ "appServerRead": false,
54
+ "appServerApprove": false,
55
+ "appServerRollback": false
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Distribution Kinds
61
+
62
+ - `local`: a manifest on disk, usually during development.
63
+ - `npm`: reserved for a package that contains a manifest and app entrypoint.
64
+ - `tarball`: reserved for a downloaded archive verified by `integrity`.
65
+ - `binary`: reserved for a package that runs an external app process and
66
+ registers through host lifecycle hooks.
67
+
68
+ Every app package must declare the same capabilities and permissions regardless
69
+ of distribution shape.
70
+
71
+ The current host accepts manifest paths. Future installers may accept an app
72
+ directory containing `onewhack.manifest.json`, a `.tgz`/`.tar.gz` bundle, or an
73
+ `npm:<package>` spec. Packaged sources should be verified with an external
74
+ `sha256-...` integrity value before extraction. The manifest still carries
75
+ `distribution.integrity` so registries and update tools can reason about the
76
+ package they installed.
77
+
78
+ ## Entrypoints
79
+
80
+ `module` entrypoints run in the host process and export the configured factory.
81
+ The factory receives `{ manifest, publicDir }` and returns an app object with
82
+ `getState` and optional `handleAction`.
83
+
84
+ `binary` entrypoints are for apps that run out of process. They must declare a
85
+ command and lifecycle hooks. The current host can register a binary app and show
86
+ its panel/static state, but it does not yet supervise a long-running binary
87
+ process.
88
+
89
+ `static` entrypoints are reserved for panel-only apps that render from public
90
+ host state and do not provide host-side action handlers.
91
+
92
+ ## Renderer Bridge
93
+
94
+ Most apps should not declare a renderer bridge. Apps should prefer host
95
+ capabilities such as transcript navigation, text ranges, overlays, and composer
96
+ drafts. The Navigate profile uses a host-owned Codex Desktop renderer bridge so
97
+ public apps can stay focused on app state and actions.
98
+
99
+ Adapter packages that intentionally provide host-level renderer behavior may
100
+ declare a bridge:
101
+
102
+ ```json
103
+ {
104
+ "rendererBridge": {
105
+ "script": "./adapter/renderer-bridge.js",
106
+ "primary": true,
107
+ "provides": [
108
+ "transcriptSnapshot",
109
+ "transcriptOrder",
110
+ "transcriptNavigation",
111
+ "transcriptHighlight"
112
+ ],
113
+ "methods": {
114
+ "openPanel": "openPanel",
115
+ "scroll": "scrollToAnchor",
116
+ "highlight": "highlightAnchor"
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ Only one bridge normally provides transcript snapshot/order/navigation for a
123
+ profile. Other apps can still contribute annotations and panels without owning
124
+ the transcript bridge.
125
+
126
+ ## Validation
127
+
128
+ Hosts should reject unknown capabilities and permissions so future protocol
129
+ growth is explicit: bump the protocol version or add a new host capability
130
+ before apps depend on new behavior.
@@ -0,0 +1,126 @@
1
+ # OneWhack App Author Guide
2
+
3
+ The public reference apps are `apps/vim-nav` and `apps/sticky-notes`. VimNav
4
+ demonstrates transcript navigation and annotations. Sticky Notes demonstrates
5
+ text-range selection, host-rendered overlays, composer context, and draft
6
+ insertion using only documented host APIs.
7
+
8
+ ## App Shape
9
+
10
+ A module app exports a factory:
11
+
12
+ ```js
13
+ export function createExampleApp(options = {}) {
14
+ return {
15
+ appId: "onewill.example.app",
16
+ name: "Example App",
17
+ publicDir: options.publicDir,
18
+ contributes: {
19
+ panel: true,
20
+ annotations: true,
21
+ actions: true,
22
+ appState: true,
23
+ selectionActions: false,
24
+ overlays: false,
25
+ composerContext: false,
26
+ composerDraft: false
27
+ },
28
+ permissions: {
29
+ transcriptRead: true,
30
+ transcriptAnnotate: true,
31
+ transcriptNavigate: true,
32
+ overlayWrite: false,
33
+ composerWrite: false,
34
+ appServerRead: false
35
+ },
36
+ getState(context) {
37
+ return {
38
+ appId: this.appId,
39
+ status: "ready",
40
+ source: "dom",
41
+ connected: context.desktop.cdpStatus === "connected",
42
+ entries: [],
43
+ annotations: [],
44
+ updatedAt: context.state.generatedAt
45
+ };
46
+ },
47
+ async handleAction(action, context) {
48
+ return {
49
+ actionId: action.actionId,
50
+ appId: this.appId,
51
+ ok: true,
52
+ status: "applied",
53
+ completedAt: new Date().toISOString()
54
+ };
55
+ }
56
+ };
57
+ }
58
+ ```
59
+
60
+ The host wraps app state and actions in OneWhack HTTP envelopes. The app should
61
+ throw `context.httpError(...)` for expected validation failures.
62
+
63
+ ## Transcript Annotations
64
+
65
+ Apps return annotation requests from `getState`. The host and renderer bridge
66
+ own final placement, sorting, and collision avoidance:
67
+
68
+ ```js
69
+ {
70
+ appId: "onewill.example.app",
71
+ anchorId: anchor.anchorId,
72
+ placement: "leading-rail",
73
+ priority: 80,
74
+ markers: [{
75
+ markerId: `example:${anchor.anchorId}`,
76
+ glyph: "E",
77
+ label: "Example marker",
78
+ tone: "muted",
79
+ shape: "circle",
80
+ entryId: `example:${anchor.anchorId}`,
81
+ action: {
82
+ type: "example.select",
83
+ payload: { anchorId: anchor.anchorId }
84
+ }
85
+ }]
86
+ }
87
+ ```
88
+
89
+ Apps should not mutate Codex Desktop DOM directly for placement. Renderer
90
+ bridges register through `window.__oneWhackRuntime` and call host-owned
91
+ annotation APIs.
92
+
93
+ ## Text Ranges And Composer Context
94
+
95
+ Apps that work with selected text should consume `context.selection.range` and
96
+ contribute `selectionActions` from app state. Apps request overlays through
97
+ `context.openOverlay(...)`, queue composer context through
98
+ `context.addComposerContext(...)`, and insert follow-up drafts through
99
+ `context.setComposerDraft(...)`.
100
+
101
+ Apps should not query Codex Desktop DOM, position floating menus, render
102
+ composer chips, or write into the composer directly. Those details belong in
103
+ the OneWhack host adapter and renderer runtime.
104
+
105
+ ## Actions
106
+
107
+ Actions are routed through:
108
+
109
+ ```text
110
+ POST /onewhack/apps/:appId/actions
111
+ ```
112
+
113
+ An action must include non-empty `appId`, `actionId`, and `type`. Action IDs are
114
+ idempotency keys; repeated requests return the original result.
115
+
116
+ ## E2E
117
+
118
+ Reference apps should run:
119
+
120
+ ```sh
121
+ npm test
122
+ npm run test:desktop-real:integration
123
+ ```
124
+
125
+ Desktop tests are explicit because they launch Codex Desktop with a disposable
126
+ profile and CDP port.
@@ -0,0 +1,50 @@
1
+ # OneWhack Launcher Profiles
2
+
3
+ A launcher profile is the host configuration for one Codex Desktop session. It
4
+ selects app manifests, chooses the default side-panel app, and decides which
5
+ renderer bridge provides transcript primitives.
6
+
7
+ ## Commands
8
+
9
+ ```sh
10
+ npx clankerbend codex
11
+ npx clankerbend codex --mock
12
+ ```
13
+
14
+ In this repository, `npm start` runs `clankerbend codex` through the local package.
15
+ `npm run start:mock` runs the same profile without Codex Desktop.
16
+
17
+ ## Profile Construction
18
+
19
+ Profiles are built from manifests with `loadProfileFromManifests`. Each loaded
20
+ app contributes:
21
+
22
+ - an app object for host state/actions
23
+ - an optional renderer bridge
24
+ - panel metadata
25
+ - capabilities and permissions
26
+
27
+ Provider selection comes from renderer bridge metadata. The first primary bridge
28
+ normally provides transcript snapshot, order, navigation, and highlight.
29
+
30
+ The built-in Navigate profile always includes VimNav and Sticky Notes.
31
+ Additional apps are product configuration owned by the running OneWhack
32
+ experience and stored in the runtime registry.
33
+
34
+ The launcher also reads `ONEWILL_ONEWHACK_REGISTRY_CONFIG`, defaulting to
35
+ `registry.json` under the runtime state directory, and merges enabled app
36
+ manifests from the selected registry profile. Set `ONEWILL_ONEWHACK_PROFILE` to
37
+ override the selected registry profile when building a custom launcher.
38
+
39
+ Runtime state defaults to:
40
+
41
+ - macOS: `~/Library/Application Support/OneWill/OneWhack`
42
+ - Linux: `~/.local/state/onewill/onewhack`
43
+
44
+ Set `ONEWILL_ONEWHACK_STATE_DIR` to override the root state directory.
45
+
46
+ ## Ports And Cleanup
47
+
48
+ The host and CDP adapter bind to `127.0.0.1` and use ephemeral ports. Launchers
49
+ must stop the host, CDP adapter, and launched Codex Desktop child process on
50
+ `SIGINT` or `SIGTERM`.