clankerbend 0.1.0 → 0.1.2

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.
Files changed (38) hide show
  1. package/DEVELOPERS.md +6 -6
  2. package/README.md +31 -14
  3. package/apps/README.md +3 -3
  4. package/apps/sticky-notes/README.md +2 -2
  5. package/apps/sticky-notes/{onewhack.manifest.json → clankerbend.manifest.json} +1 -1
  6. package/apps/sticky-notes/package.json +3 -3
  7. package/apps/sticky-notes/public/app.js +42 -9
  8. package/apps/sticky-notes/src/sticky-notes-app.js +17 -21
  9. package/apps/vim-nav/README.md +22 -22
  10. package/apps/vim-nav/{onewhack.manifest.json → clankerbend.manifest.json} +4 -4
  11. package/apps/vim-nav/package.json +3 -3
  12. package/apps/vim-nav/public/app.js +44 -11
  13. package/apps/vim-nav/public/index.html +2 -2
  14. package/apps/vim-nav/server.mjs +2 -2
  15. package/apps/vim-nav/src/vim-nav-app.js +5 -5
  16. package/assets/clankerbend-banner.webp +0 -0
  17. package/assets/clankerbend-demo-thumbnail.webp +0 -0
  18. package/assets/clankerbend.svg +26 -0
  19. package/cli.mjs +22 -11
  20. package/docs/app-lifecycle.md +9 -9
  21. package/docs/app-manifest.md +4 -4
  22. package/docs/author-guide.md +5 -5
  23. package/docs/launcher-profiles.md +45 -10
  24. package/docs/protocol.md +248 -241
  25. package/host/README.md +4 -4
  26. package/host/src/app-registry.js +8 -6
  27. package/host/src/codex-desktop-cdp-adapter.js +85 -65
  28. package/host/src/codex-desktop-renderer-bridge.js +839 -270
  29. package/host/src/index.js +186 -34
  30. package/launch/accounts.mjs +360 -0
  31. package/launch/codex-mux-adapter.mjs +175 -0
  32. package/launch/profiles.mjs +40 -16
  33. package/launch/runtime-paths.mjs +9 -6
  34. package/launch/test-accounts.mjs +71 -0
  35. package/package.json +12 -10
  36. package/scripts/release-npm.mjs +1 -2
  37. package/server.mjs +21 -14
  38. package/assets/onewhack.jpg +0 -0
package/host/src/index.js CHANGED
@@ -3,9 +3,9 @@ import http from "node:http";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { extname, join, relative, resolve } from "node:path";
5
5
 
6
- export const ONEWHACK_VERSION = "0.1";
6
+ export const CLANKERBEND_VERSION = "0.1";
7
7
 
8
- export class OneWhackHttpError extends Error {
8
+ export class ClankerBendHttpError extends Error {
9
9
  constructor(status, code, message, detail) {
10
10
  super(message);
11
11
  this.status = status;
@@ -15,7 +15,7 @@ export class OneWhackHttpError extends Error {
15
15
  }
16
16
 
17
17
  export function httpError(status, code, message, detail) {
18
- return new OneWhackHttpError(status, code, message, detail);
18
+ return new ClankerBendHttpError(status, code, message, detail);
19
19
  }
20
20
 
21
21
  export function createMockTranscriptAdapter(options = {}) {
@@ -91,15 +91,16 @@ export function mockAnchor(anchorId, order, inferredRole, textPreview) {
91
91
  };
92
92
  }
93
93
 
94
- export class OneWhackHost {
94
+ export class ClankerBendHost {
95
95
  constructor(options = {}) {
96
- this.protocolVersion = options.protocolVersion || ONEWHACK_VERSION;
97
- this.hostId = options.hostId || "onewill.onewhack.host";
98
- this.hostName = options.hostName || "OneWhack Host";
96
+ this.protocolVersion = options.protocolVersion || CLANKERBEND_VERSION;
97
+ this.hostId = options.hostId || "onewill.clankerbend.host";
98
+ this.hostName = options.hostName || "ClankerBend Host";
99
99
  this.localDevInsecure = options.localDevInsecure === true;
100
100
  this.token = this.localDevInsecure ? "" : (isNonEmptyString(options.token) ? options.token : createSessionToken());
101
101
  this.runDir = options.runDir || null;
102
102
  this.transcriptAdapter = options.transcriptAdapter || createMockTranscriptAdapter();
103
+ this.accountRegistry = options.accountRegistry || null;
103
104
  this.apps = new Map();
104
105
  this.sseClients = new Set();
105
106
  this.actionResults = new Map();
@@ -157,6 +158,15 @@ export class OneWhackHost {
157
158
  version: null,
158
159
  error: "not started"
159
160
  },
161
+ codexAccounts: this.accountRegistry
162
+ ? {
163
+ available: true,
164
+ activeAccountId: null,
165
+ maxRunningDesktopInstances: 1,
166
+ switching: false,
167
+ ...this.accountRegistry.list()
168
+ }
169
+ : null,
160
170
  lastAction: null
161
171
  };
162
172
  }
@@ -266,6 +276,11 @@ export class OneWhackHost {
266
276
  correlateItems: false,
267
277
  approvals: false,
268
278
  rollback: false
279
+ },
280
+ codexAccounts: {
281
+ available: Boolean(this.accountRegistry),
282
+ switch: Boolean(this.transcriptAdapter.switchTo),
283
+ adoptPrimary: Boolean(this.transcriptAdapter.adoptAsPrimary)
269
284
  }
270
285
  };
271
286
  }
@@ -274,12 +289,12 @@ export class OneWhackHost {
274
289
  const app = this.apps.get(appId);
275
290
  if (!app || !this.state.host.url) return null;
276
291
  const base = `${this.state.host.url.replace(/\/$/, "")}/apps/${encodeURIComponent(app.appId)}/`;
277
- return this.token ? `${base}#onewhack_token=${encodeURIComponent(this.token)}` : base;
292
+ return this.token ? `${base}#clankerbend_token=${encodeURIComponent(this.token)}` : base;
278
293
  }
279
294
 
280
295
  hostManifest() {
281
296
  return {
282
- oneWhackVersion: this.protocolVersion,
297
+ clankerbendVersion: this.protocolVersion,
283
298
  hostId: this.hostId,
284
299
  hostName: this.hostName,
285
300
  capabilities: this.capabilities(),
@@ -307,7 +322,7 @@ export class OneWhackHost {
307
322
  }
308
323
  return cleanObject({
309
324
  ...loadedManifest,
310
- oneWhackVersion: this.protocolVersion,
325
+ clankerbendVersion: this.protocolVersion,
311
326
  appId: app.appId,
312
327
  name: app.name || app.appId,
313
328
  version: app.version || loadedManifest.version,
@@ -323,8 +338,9 @@ export class OneWhackHost {
323
338
  }
324
339
 
325
340
  publicState() {
341
+ if (this.accountRegistry) this.state.codexAccounts = this.accountState();
326
342
  return {
327
- protocolName: "onewhack",
343
+ protocolName: "clankerbend",
328
344
  protocolVersion: this.protocolVersion,
329
345
  sequence: this.state.sequence,
330
346
  generatedAt: this.state.generatedAt,
@@ -361,6 +377,7 @@ export class OneWhackHost {
361
377
  ...this.appState(app.appId)
362
378
  })),
363
379
  appServer: this.state.appServer,
380
+ codexAccounts: this.state.codexAccounts || undefined,
364
381
  lastAction: this.state.lastAction || undefined
365
382
  };
366
383
  }
@@ -481,26 +498,39 @@ export class OneWhackHost {
481
498
  res.writeHead(204).end();
482
499
  return;
483
500
  }
484
- if (url.pathname.startsWith("/onewhack/") && !this.authorized(req)) {
485
- this.fail(res, 401, "unauthorized", "missing or invalid OneWhack token");
501
+ if (url.pathname.startsWith("/clankerbend/") && !this.authorized(req)) {
502
+ this.fail(res, 401, "unauthorized", "missing or invalid ClankerBend token");
486
503
  return;
487
504
  }
488
505
 
489
- if (url.pathname === "/onewhack/manifest" && req.method === "GET") return this.ok(res, this.hostManifest());
490
- if (url.pathname === "/onewhack/state" && req.method === "GET") return this.ok(res, this.publicState());
491
- if (url.pathname === "/onewhack/events" && req.method === "GET") return this.handleEvents(req, res);
492
- if (url.pathname === "/onewhack/apps" && req.method === "GET") return this.ok(res, { apps: this.appSummaries() });
493
- if (url.pathname === "/onewhack/panel/open" && req.method === "POST") return this.openPanelEndpoint(req, res);
494
- if (url.pathname === "/onewhack/transcript/scroll" && req.method === "POST") return this.scrollEndpoint(req, res);
495
- if (url.pathname === "/onewhack/transcript/highlight" && req.method === "POST") return this.highlightEndpoint(req, res);
496
- if (url.pathname === "/onewhack/transcript/highlight-range" && req.method === "POST") return this.highlightRangeEndpoint(req, res);
497
- if (url.pathname === "/onewhack/selection" && req.method === "POST") return this.selectionEndpoint(req, res);
498
- if (url.pathname === "/onewhack/overlay/open" && req.method === "POST") return this.overlayOpenEndpoint(req, res);
499
- if (url.pathname === "/onewhack/overlay/close" && req.method === "POST") return this.overlayCloseEndpoint(req, res);
500
- if (url.pathname === "/onewhack/composer/context" && req.method === "POST") return this.composerContextEndpoint(req, res);
501
- if (url.pathname === "/onewhack/composer/context/remove" && req.method === "POST") return this.composerContextRemoveEndpoint(req, res);
502
- if (url.pathname === "/onewhack/composer/draft" && req.method === "POST") return this.composerDraftEndpoint(req, res);
503
- if (url.pathname === "/onewhack/composer/submit" && req.method === "POST") return this.composerSubmitEndpoint(req, res);
506
+ if (url.pathname === "/clankerbend/manifest" && req.method === "GET") return this.ok(res, this.hostManifest());
507
+ if (url.pathname === "/clankerbend/state" && req.method === "GET") return this.ok(res, this.publicState());
508
+ if (url.pathname === "/clankerbend/events" && req.method === "GET") return this.handleEvents(req, res);
509
+ if (url.pathname === "/clankerbend/apps" && req.method === "GET") return this.ok(res, { apps: this.appSummaries() });
510
+ if (url.pathname === "/clankerbend/panel/open" && req.method === "POST") return this.openPanelEndpoint(req, res);
511
+ if (url.pathname === "/clankerbend/transcript/scroll" && req.method === "POST") return this.scrollEndpoint(req, res);
512
+ if (url.pathname === "/clankerbend/transcript/highlight" && req.method === "POST") return this.highlightEndpoint(req, res);
513
+ if (url.pathname === "/clankerbend/transcript/highlight-range" && req.method === "POST") return this.highlightRangeEndpoint(req, res);
514
+ if (url.pathname === "/clankerbend/selection" && req.method === "POST") return this.selectionEndpoint(req, res);
515
+ if (url.pathname === "/clankerbend/overlay/open" && req.method === "POST") return this.overlayOpenEndpoint(req, res);
516
+ if (url.pathname === "/clankerbend/overlay/close" && req.method === "POST") return this.overlayCloseEndpoint(req, res);
517
+ if (url.pathname === "/clankerbend/composer/context" && req.method === "POST") return this.composerContextEndpoint(req, res);
518
+ if (url.pathname === "/clankerbend/composer/context/remove" && req.method === "POST") return this.composerContextRemoveEndpoint(req, res);
519
+ if (url.pathname === "/clankerbend/composer/draft" && req.method === "POST") return this.composerDraftEndpoint(req, res);
520
+ if (url.pathname === "/clankerbend/composer/submit" && req.method === "POST") return this.composerSubmitEndpoint(req, res);
521
+ if (url.pathname === "/clankerbend/codex/accounts" && req.method === "GET") return this.codexAccountsListEndpoint(res);
522
+ if (url.pathname === "/clankerbend/codex/accounts" && req.method === "POST") return this.codexAccountsCreateEndpoint(req, res);
523
+ if (url.pathname === "/clankerbend/codex/accounts/default" && req.method === "POST") return this.codexAccountsDefaultEndpoint(req, res);
524
+ if (url.pathname === "/clankerbend/codex/accounts/switch" && req.method === "POST") return this.codexAccountsSwitchEndpoint(req, res);
525
+ if (url.pathname === "/clankerbend/codex/primary/rollback" && req.method === "POST") return this.codexPrimaryRollbackEndpoint(req, res);
526
+ const accountRoute = this.parseCodexAccountRoute(url.pathname);
527
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/switch") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
528
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/start") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
529
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/focus") return this.codexAccountSwitchEndpoint(accountRoute.accountId, res);
530
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/set-default") return this.codexAccountSetDefaultEndpoint(accountRoute.accountId, res);
531
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/adopt-as-primary") return this.codexAccountAdoptEndpoint(accountRoute.accountId, res);
532
+ if (accountRoute && req.method === "DELETE") return this.codexAccountDeleteEndpoint(accountRoute.accountId, res);
533
+ if (accountRoute && req.method === "POST" && accountRoute.tail === "/delete") return this.codexAccountDeleteEndpoint(accountRoute.accountId, res);
504
534
 
505
535
  const appRoute = this.parseAppRoute(url.pathname);
506
536
  if (appRoute && req.method === "GET" && appRoute.tail === "/manifest") return this.ok(res, this.appManifest(appRoute.appId));
@@ -524,7 +554,7 @@ export class OneWhackHost {
524
554
  }
525
555
 
526
556
  parseAppRoute(pathname) {
527
- if (!pathname.startsWith("/onewhack/apps/")) return null;
557
+ if (!pathname.startsWith("/clankerbend/apps/")) return null;
528
558
  const parts = pathname.split("/");
529
559
  const appId = decodeURIComponent(parts[3] || "");
530
560
  if (!appId) return null;
@@ -532,6 +562,107 @@ export class OneWhackHost {
532
562
  return { appId, tail: `/${parts.slice(4).join("/")}` };
533
563
  }
534
564
 
565
+ parseCodexAccountRoute(pathname) {
566
+ if (!pathname.startsWith("/clankerbend/codex/accounts/")) return null;
567
+ const parts = pathname.split("/");
568
+ const accountId = decodeURIComponent(parts[4] || "");
569
+ if (!accountId) return null;
570
+ return { accountId, tail: `/${parts.slice(5).join("/")}` };
571
+ }
572
+
573
+ requireAccountRegistry() {
574
+ if (!this.accountRegistry) throw httpError(404, "not_found", "Codex account registry is unavailable");
575
+ return this.accountRegistry;
576
+ }
577
+
578
+ accountState() {
579
+ if (typeof this.transcriptAdapter.accountState === "function") return this.transcriptAdapter.accountState();
580
+ return this.accountRegistry ? {
581
+ available: true,
582
+ activeAccountId: null,
583
+ maxRunningDesktopInstances: 1,
584
+ switching: false,
585
+ ...this.accountRegistry.list()
586
+ } : null;
587
+ }
588
+
589
+ codexAccountsListEndpoint(res) {
590
+ this.requireAccountRegistry();
591
+ this.state.codexAccounts = this.accountState();
592
+ return this.ok(res, this.state.codexAccounts);
593
+ }
594
+
595
+ async codexAccountsCreateEndpoint(req, res) {
596
+ this.requireAccountRegistry();
597
+ const body = await readJsonObject(req, "account create request body");
598
+ const result = typeof this.transcriptAdapter.createAccount === "function"
599
+ ? await this.transcriptAdapter.createAccount(body)
600
+ : this.accountRegistry.createManaged(body);
601
+ this.state.codexAccounts = this.accountState();
602
+ return this.ok(res, result, 201);
603
+ }
604
+
605
+ async codexAccountsDefaultEndpoint(req, res) {
606
+ const body = await readJsonObject(req, "account default request body");
607
+ if (!isNonEmptyString(body.accountId)) throw httpError(400, "bad_request", "accountId is required");
608
+ return this.codexAccountSetDefaultEndpoint(body.accountId, res);
609
+ }
610
+
611
+ async codexAccountsSwitchEndpoint(req, res) {
612
+ const body = await readJsonObject(req, "account switch request body");
613
+ if (!isNonEmptyString(body.accountId)) throw httpError(400, "bad_request", "accountId is required");
614
+ return this.codexAccountSwitchEndpoint(body.accountId, res);
615
+ }
616
+
617
+ async codexAccountSwitchEndpoint(accountId, res) {
618
+ this.requireAccountRegistry();
619
+ if (typeof this.transcriptAdapter.switchTo !== "function") {
620
+ throw httpError(409, "unsupported", "active adapter does not support Codex account switching");
621
+ }
622
+ const result = await this.transcriptAdapter.switchTo(accountId);
623
+ this.state.codexAccounts = this.accountState();
624
+ return this.ok(res, result);
625
+ }
626
+
627
+ async codexAccountSetDefaultEndpoint(accountId, res) {
628
+ this.requireAccountRegistry();
629
+ const result = typeof this.transcriptAdapter.setDefault === "function"
630
+ ? await this.transcriptAdapter.setDefault(accountId)
631
+ : this.accountRegistry.setDefault(accountId);
632
+ this.state.codexAccounts = this.accountState();
633
+ return this.ok(res, result);
634
+ }
635
+
636
+ async codexAccountAdoptEndpoint(accountId, res) {
637
+ this.requireAccountRegistry();
638
+ if (typeof this.transcriptAdapter.adoptAsPrimary !== "function") {
639
+ throw httpError(409, "unsupported", "active adapter does not support primary adoption");
640
+ }
641
+ const result = await this.transcriptAdapter.adoptAsPrimary(accountId);
642
+ this.state.codexAccounts = this.accountState();
643
+ return this.ok(res, result);
644
+ }
645
+
646
+ async codexPrimaryRollbackEndpoint(req, res) {
647
+ this.requireAccountRegistry();
648
+ const body = await readJsonObject(req, "primary rollback request body");
649
+ if (typeof this.transcriptAdapter.rollbackPrimary !== "function") {
650
+ throw httpError(409, "unsupported", "active adapter does not support primary rollback");
651
+ }
652
+ const result = await this.transcriptAdapter.rollbackPrimary(body);
653
+ this.state.codexAccounts = this.accountState();
654
+ return this.ok(res, result);
655
+ }
656
+
657
+ async codexAccountDeleteEndpoint(accountId, res) {
658
+ this.requireAccountRegistry();
659
+ const result = typeof this.transcriptAdapter.deleteAccount === "function"
660
+ ? await this.transcriptAdapter.deleteAccount(accountId)
661
+ : this.accountRegistry.deleteManaged(accountId);
662
+ this.state.codexAccounts = this.accountState();
663
+ return this.ok(res, result);
664
+ }
665
+
535
666
  async actionEndpoint(appId, req, res) {
536
667
  const app = this.requireApp(appId);
537
668
  const body = await readJsonObject(req, "action request body");
@@ -594,7 +725,7 @@ export class OneWhackHost {
594
725
  this.state.panel.error = null;
595
726
  } else {
596
727
  this.state.panel.status = "waiting";
597
- this.state.panel.error = result?.error || "Open a Codex thread, then try opening the OneWhack panel again.";
728
+ this.state.panel.error = result?.error || "Open a Codex thread, then try opening the ClankerBend panel again.";
598
729
  }
599
730
  this.touchAndBroadcast();
600
731
  return this.ok(res, result || { ok: false, error: "panel unavailable" });
@@ -973,10 +1104,17 @@ export class OneWhackHost {
973
1104
  res.writeHead(404).end("not found");
974
1105
  return;
975
1106
  }
976
- res.writeHead(200, {
977
- "content-type": mimeType(filePath),
1107
+ const contentType = mimeType(filePath);
1108
+ const headers = {
1109
+ "content-type": contentType,
978
1110
  "cache-control": "no-store"
979
- });
1111
+ };
1112
+ if (this.token && contentType.startsWith("text/html")) {
1113
+ res.writeHead(200, headers);
1114
+ res.end(injectAppToken(readFileSync(filePath, "utf8"), this.token));
1115
+ return;
1116
+ }
1117
+ res.writeHead(200, headers);
980
1118
  createReadStream(filePath).pipe(res);
981
1119
  }
982
1120
 
@@ -1023,7 +1161,7 @@ export class OneWhackHost {
1023
1161
 
1024
1162
  applyCors(res) {
1025
1163
  res.setHeader("access-control-allow-origin", "*");
1026
- res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
1164
+ res.setHeader("access-control-allow-methods", "GET,POST,DELETE,OPTIONS");
1027
1165
  res.setHeader("access-control-allow-headers", "authorization,content-type");
1028
1166
  }
1029
1167
  }
@@ -1134,6 +1272,20 @@ export function createSessionToken() {
1134
1272
  return randomBytes(32).toString("base64url");
1135
1273
  }
1136
1274
 
1275
+ function injectAppToken(html, token) {
1276
+ const script = `<meta name="clankerbend-token" content="${escapeHtmlAttribute(token)}">\n <script>window.__CLANKERBEND_TOKEN=${JSON.stringify(token)};</script>`;
1277
+ return /<head[^>]*>/i.test(html)
1278
+ ? html.replace(/<head[^>]*>/i, (match) => `${match}\n ${script}`)
1279
+ : `${script}\n${html}`;
1280
+ }
1281
+
1282
+ function escapeHtmlAttribute(value) {
1283
+ return String(value)
1284
+ .replace(/&/g, "&amp;")
1285
+ .replace(/"/g, "&quot;")
1286
+ .replace(/</g, "&lt;");
1287
+ }
1288
+
1137
1289
  function requirePermission(app, permission) {
1138
1290
  if (app.permissions?.[permission] === false) {
1139
1291
  throw httpError(403, "forbidden", `${app.appId} lacks ${permission}`);