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.
- package/DEVELOPERS.md +63 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/apps/README.md +11 -0
- package/apps/sticky-notes/README.md +11 -0
- package/apps/sticky-notes/onewhack.manifest.json +74 -0
- package/apps/sticky-notes/package.json +14 -0
- package/apps/sticky-notes/public/app.js +117 -0
- package/apps/sticky-notes/public/index.html +23 -0
- package/apps/sticky-notes/public/styles.css +84 -0
- package/apps/sticky-notes/src/sticky-notes-app.js +413 -0
- package/apps/vim-nav/README.md +126 -0
- package/apps/vim-nav/onewhack.manifest.json +69 -0
- package/apps/vim-nav/package.json +16 -0
- package/apps/vim-nav/public/app.js +276 -0
- package/apps/vim-nav/public/index.html +53 -0
- package/apps/vim-nav/public/styles.css +211 -0
- package/apps/vim-nav/server.mjs +10 -0
- package/apps/vim-nav/src/vim-nav-app.js +221 -0
- package/assets/onewhack.jpg +0 -0
- package/cli.mjs +91 -0
- package/docs/app-lifecycle.md +63 -0
- package/docs/app-manifest.md +130 -0
- package/docs/author-guide.md +126 -0
- package/docs/launcher-profiles.md +50 -0
- package/docs/protocol.md +1935 -0
- package/host/README.md +18 -0
- package/host/src/app-registry.js +315 -0
- package/host/src/codex-desktop-cdp-adapter.js +1826 -0
- package/host/src/codex-desktop-renderer-bridge.js +3536 -0
- package/host/src/index.js +1177 -0
- package/launch/profiles.mjs +93 -0
- package/launch/runtime-paths.mjs +21 -0
- package/package.json +66 -0
- package/scripts/release-npm.mjs +202 -0
- package/server.mjs +58 -0
|
@@ -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`.
|