clankerbend 0.1.1 → 0.1.3

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,175 @@
1
+ import { createCodexDesktopCdpAdapter } from "../host/src/codex-desktop-cdp-adapter.js";
2
+ import { PRIMARY_ACCOUNT_ID } from "./accounts.mjs";
3
+
4
+ export function createCodexDesktopMuxAdapter(options = {}) {
5
+ return new CodexDesktopMuxAdapter(options);
6
+ }
7
+
8
+ class CodexDesktopMuxAdapter {
9
+ constructor(options = {}) {
10
+ if (!options.accountRegistry) throw new Error("accountRegistry is required");
11
+ this.name = "codex-desktop-mux";
12
+ this.cdp = true;
13
+ this.rendererInjection = true;
14
+ this.rendererFetchToLoopback = false;
15
+ this.accountRegistry = options.accountRegistry;
16
+ this.adapterOptions = options.adapterOptions || {};
17
+ this.providers = options.providers || undefined;
18
+ this.startAccountId = options.startAccountId || null;
19
+ this.host = null;
20
+ this.activeAccountId = null;
21
+ this.activeAdapter = null;
22
+ this.switching = false;
23
+ }
24
+
25
+ async start(host) {
26
+ this.host = host;
27
+ const account = this.startAccountId ? this.accountRegistry.get(this.startAccountId) : this.accountRegistry.getDefault();
28
+ await this.switchTo(account.id, { initial: true });
29
+ }
30
+
31
+ async stop() {
32
+ await this.stopActive();
33
+ }
34
+
35
+ async switchTo(accountId, options = {}) {
36
+ if (this.switching) throw new Error("Codex account switch already in progress");
37
+ this.switching = true;
38
+ try {
39
+ const account = this.accountRegistry.get(accountId);
40
+ if (!options.initial) await this.stopActive();
41
+ this.activeAccountId = account.id;
42
+ this.host.state.codexAccounts = this.accountState();
43
+ this.host.setDesktopStatus({
44
+ cdpStatus: "starting",
45
+ cdpPort: null,
46
+ desktopPid: null,
47
+ target: null,
48
+ error: null
49
+ });
50
+ this.host.updateTranscript({
51
+ anchors: [],
52
+ visibleCount: 0,
53
+ annotationCount: 0,
54
+ scroll: null,
55
+ updatedAt: new Date().toISOString()
56
+ }, { broadcast: false });
57
+ this.activeAdapter = createCodexDesktopCdpAdapter({
58
+ ...this.adapterOptions,
59
+ profileDir: account.electronProfile,
60
+ codexHome: account.codexHome,
61
+ resetProfileDir: false
62
+ });
63
+ await this.activeAdapter.start(this.host);
64
+ this.providers = this.activeAdapter.providers;
65
+ return {
66
+ switched: true,
67
+ activeAccountId: account.id,
68
+ account: this.accountRegistry.publicAccount(account)
69
+ };
70
+ } finally {
71
+ this.switching = false;
72
+ if (this.host) {
73
+ this.host.state.codexAccounts = this.accountState();
74
+ this.host.touchAndBroadcast();
75
+ }
76
+ }
77
+ }
78
+
79
+ async stopActive() {
80
+ const adapter = this.activeAdapter;
81
+ this.activeAdapter = null;
82
+ if (adapter) await adapter.stop?.();
83
+ if (this.host) {
84
+ this.host.setDesktopStatus({
85
+ cdpStatus: "exited",
86
+ cdpPort: null,
87
+ desktopPid: null,
88
+ target: null,
89
+ error: null
90
+ });
91
+ }
92
+ }
93
+
94
+ accountState() {
95
+ return {
96
+ available: true,
97
+ activeAccountId: this.activeAccountId,
98
+ maxRunningDesktopInstances: 1,
99
+ switching: this.switching,
100
+ ...this.accountRegistry.list()
101
+ };
102
+ }
103
+
104
+ async createAccount(input = {}) {
105
+ const account = this.accountRegistry.createManaged(input);
106
+ this.host.state.codexAccounts = this.accountState();
107
+ this.host.touchAndBroadcast();
108
+ return account;
109
+ }
110
+
111
+ async setDefault(accountId) {
112
+ const result = this.accountRegistry.setDefault(accountId);
113
+ this.host.state.codexAccounts = this.accountState();
114
+ this.host.touchAndBroadcast();
115
+ return result;
116
+ }
117
+
118
+ async adoptAsPrimary(accountId) {
119
+ await this.stopActive();
120
+ const result = this.accountRegistry.adoptAsPrimary(accountId);
121
+ const launched = await this.switchTo(PRIMARY_ACCOUNT_ID);
122
+ return { ...result, launched };
123
+ }
124
+
125
+ async rollbackPrimary(input = {}) {
126
+ await this.stopActive();
127
+ const result = this.accountRegistry.rollbackPrimary(input);
128
+ const launched = await this.switchTo(PRIMARY_ACCOUNT_ID);
129
+ return { ...result, launched };
130
+ }
131
+
132
+ async deleteAccount(accountId) {
133
+ const account = this.accountRegistry.get(accountId);
134
+ if (account.kind === "primary") throw new Error("primary account cannot be deleted");
135
+ if (accountId === this.activeAccountId) await this.stopActive();
136
+ const result = this.accountRegistry.deleteManaged(accountId);
137
+ if (accountId === this.activeAccountId) this.activeAccountId = null;
138
+ this.host.state.codexAccounts = this.accountState();
139
+ this.host.touchAndBroadcast();
140
+ return result;
141
+ }
142
+
143
+ active() {
144
+ if (!this.activeAdapter) throw new Error("Codex Desktop is not running");
145
+ return this.activeAdapter;
146
+ }
147
+
148
+ openPanel(...args) {
149
+ return this.active().openPanel(...args);
150
+ }
151
+
152
+ scrollToAnchor(...args) {
153
+ return this.active().scrollToAnchor(...args);
154
+ }
155
+
156
+ highlightAnchor(...args) {
157
+ return this.active().highlightAnchor(...args);
158
+ }
159
+
160
+ highlightRange(...args) {
161
+ return this.active().highlightRange(...args);
162
+ }
163
+
164
+ setComposerDraft(...args) {
165
+ return this.active().setComposerDraft(...args);
166
+ }
167
+
168
+ submitComposer(...args) {
169
+ return this.active().submitComposer(...args);
170
+ }
171
+
172
+ attachFiles(...args) {
173
+ return this.active().attachFiles(...args);
174
+ }
175
+ }
@@ -8,6 +8,7 @@ import {
8
8
  providersFromRendererBridges,
9
9
  readAppManifest
10
10
  } from "../host/src/app-registry.js";
11
+ import { CodexAccountRegistry, primaryCodexHome } from "./accounts.mjs";
11
12
  import { clankerbendRuntimePaths } from "./runtime-paths.mjs";
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -23,6 +24,18 @@ export const STICKY_NOTES_APP_ID = readAppManifest(STICKY_NOTES_MANIFEST_PATH).a
23
24
  export async function navigateProfile(options = {}) {
24
25
  const runtimePaths = clankerbendRuntimePaths({ stateDir: options.stateDir });
25
26
  const runDir = options.runDir || runtimePaths.runDir;
27
+ const registryProfileId = options.registryProfileId || process.env.ONEWILL_CLANKERBEND_PROFILE || "default";
28
+ const primaryElectronProfile = options.runDir ? join(runDir, "codex-profile") : runtimePaths.codexProfileDir;
29
+ const accountStateRoot = options.runDir && !options.stateDir ? runDir : runtimePaths.root;
30
+ const accountRegistry = new CodexAccountRegistry({
31
+ root: accountStateRoot,
32
+ registryPath: join(accountStateRoot, "accounts.json"),
33
+ accountsDir: join(accountStateRoot, "accounts"),
34
+ deletedDir: join(accountStateRoot, "deleted-accounts"),
35
+ primaryCodexHome: options.primaryCodexHome || primaryCodexHome(),
36
+ primaryElectronProfile
37
+ });
38
+ const codexAccount = options.accountId ? accountRegistry.get(options.accountId) : accountRegistry.getDefault();
26
39
  const profile = await loadProfileFromManifests({
27
40
  profileId: PROFILE_NAVIGATE,
28
41
  name: "Navigate",
@@ -31,12 +44,18 @@ export async function navigateProfile(options = {}) {
31
44
  hostName: "ClankerBend Navigate",
32
45
  runDir,
33
46
  runtimePaths,
47
+ accountRegistry,
34
48
  defaultPanelAppId: VIM_NAV_APP_ID,
35
49
  manifestPaths: configuredManifestPaths([VIM_NAV_MANIFEST_PATH, STICKY_NOTES_MANIFEST_PATH], {
36
50
  registryConfigPath: options.registryConfigPath || runtimePaths.registryConfigPath,
37
- registryProfileId: options.registryProfileId
51
+ registryProfileId
38
52
  })
39
53
  });
54
+ profile.launchProfileId = registryProfileId;
55
+ profile.launchProfileName = profileNameFromId(registryProfileId);
56
+ profile.accountRegistry = accountRegistry;
57
+ profile.codexAccount = codexAccount;
58
+ profile.startAccountId = codexAccount.id;
40
59
  const bridge = codexDesktopRendererBridge();
41
60
  profile.rendererBridges = [bridge, ...profile.rendererBridges];
42
61
  profile.providers = providersFromRendererBridges(profile.rendererBridges);
@@ -81,13 +100,18 @@ function codexDesktopRendererBridge() {
81
100
  export function printLaunchStatus(profile, host, options = {}) {
82
101
  const mode = options.mockMode ? "Mock transcript" : "Codex Desktop";
83
102
  console.log("");
84
- console.log(`ClankerBend: ${profile.name}`);
103
+ console.log(`ClankerBend profile: ${profile.launchProfileName || profile.name || "Default"}`);
85
104
  console.log(`Status: ${mode} is running`);
86
- console.log(`Panel: ${host.state.panel.url}`);
87
105
  console.log(`Host: ${host.state.host.url}`);
88
- if (!options.mockMode) {
89
- console.log("Codex Desktop is open. Use the Browser side panel for ClankerBend apps.");
90
- }
106
+ if (host.token) console.log(`Token: ${host.token}`);
91
107
  console.log("Press Ctrl+C to stop ClankerBend and the launched Codex Desktop process.");
92
108
  console.log("");
93
109
  }
110
+
111
+ function profileNameFromId(profileId) {
112
+ return String(profileId || "default")
113
+ .split(/[-_\s]+/)
114
+ .filter(Boolean)
115
+ .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
116
+ .join(" ") || "Default";
117
+ }
@@ -15,6 +15,9 @@ export function clankerbendRuntimePaths(options = {}) {
15
15
  root,
16
16
  runDir: join(root, "run"),
17
17
  registryConfigPath: join(root, "registry.json"),
18
+ accountRegistryPath: join(root, "accounts.json"),
19
+ accountProfilesDir: join(root, "accounts"),
20
+ deletedAccountProfilesDir: join(root, "deleted-accounts"),
18
21
  appInstallDir: join(root, "apps"),
19
22
  codexProfileDir: join(root, "codex-profile")
20
23
  };
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ClankerBendHost, createMockTranscriptAdapter } from "../host/src/index.js";
6
+ import { CodexAccountRegistry, PRIMARY_ACCOUNT_ID } from "./accounts.mjs";
7
+
8
+ const root = mkdtempSync(join(tmpdir(), "clankerbend-accounts-test-"));
9
+
10
+ try {
11
+ const primaryHome = join(root, "primary-codex-home");
12
+ const primaryElectronProfile = join(root, "primary-electron-profile");
13
+ mkdirSync(primaryHome, { recursive: true });
14
+ mkdirSync(primaryElectronProfile, { recursive: true });
15
+ writeFileSync(join(primaryHome, "config.toml"), "model = \"gpt-5.4\"\n");
16
+ writeFileSync(join(primaryHome, "auth.json"), JSON.stringify({ auth_mode: "chatgpt", tokens: {} }));
17
+
18
+ const registry = new CodexAccountRegistry({
19
+ root: join(root, "state"),
20
+ primaryCodexHome: primaryHome,
21
+ primaryElectronProfile
22
+ });
23
+
24
+ const listed = registry.list();
25
+ assert.equal(listed.accounts.length, 1);
26
+ assert.equal(listed.accounts[0].id, PRIMARY_ACCOUNT_ID);
27
+ assert.equal(listed.accounts[0].codexHome, primaryHome);
28
+
29
+ const work = registry.createManaged({ id: "work", label: "Work" });
30
+ assert.equal(work.id, "work");
31
+ assert.equal(existsSync(work.codexHome), true);
32
+ assert.equal(existsSync(work.electronProfile), true);
33
+ assert.equal(readFileSync(join(work.codexHome, "config.toml"), "utf8"), "cli_auth_credentials_store = \"file\"\n");
34
+
35
+ const duplicateLabel = registry.createManaged({ label: "Work" });
36
+ assert.equal(duplicateLabel.id, "work-2");
37
+ const numericLabel = registry.createManaged({ label: "123" });
38
+ assert.equal(numericLabel.id, "account-123");
39
+
40
+ writeFileSync(join(work.codexHome, "auth.json"), JSON.stringify({ auth_mode: "chatgpt", tokens: { account: "work" } }));
41
+ const adoption = registry.adoptAsPrimary("work");
42
+ assert.equal(existsSync(primaryHome), true);
43
+ assert.equal(JSON.parse(readFileSync(join(primaryHome, "auth.json"), "utf8")).tokens.account, "work");
44
+ assert.equal(adoption.previousPrimary.kind, "managed");
45
+ assert.equal(existsSync(adoption.backup.codexHome), true);
46
+ assert.equal(registry.list().accounts.some((account) => account.id === "work"), false);
47
+
48
+ const rollback = registry.rollbackPrimary({ backupId: adoption.previousPrimary.id });
49
+ assert.equal(rollback.restoredAccountId, adoption.previousPrimary.id);
50
+ assert.equal(JSON.parse(readFileSync(join(primaryHome, "auth.json"), "utf8")).tokens.account, undefined);
51
+ assert.equal(registry.list().accounts.some((account) => account.id === rollback.replacedPrimary.id), true);
52
+
53
+ const temp = registry.createManaged({ id: "temp", label: "Temp" });
54
+ const deleted = registry.deleteManaged("temp");
55
+ assert.equal(registry.list().accounts.some((account) => account.id === "temp"), false);
56
+ assert.equal(existsSync(deleted.deletedRoot), true);
57
+ assert.equal(existsSync(deleted.codexHome), true);
58
+
59
+ const fresh = registry.createManaged({ id: "fresh", label: "Fresh" });
60
+ const host = new ClankerBendHost({
61
+ accountRegistry: registry,
62
+ transcriptAdapter: createMockTranscriptAdapter()
63
+ });
64
+ assert.equal(host.publicState().codexAccounts.accounts.find((account) => account.id === "fresh").auth.authJson, false);
65
+ writeFileSync(join(fresh.codexHome, "auth.json"), JSON.stringify({ auth_mode: "chatgpt", tokens: { account: "fresh" } }));
66
+ assert.equal(host.publicState().codexAccounts.accounts.find((account) => account.id === "fresh").auth.authJson, true);
67
+
68
+ console.log("clankerbend account registry tests passed");
69
+ } finally {
70
+ rmSync(root, { recursive: true, force: true });
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clankerbend",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "description": "OneWill ClankerBend protocol host and public reference apps.",
6
6
  "license": "MIT",
package/server.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { pathToFileURL } from "node:url";
2
- import { createCodexDesktopCdpAdapter } from "./host/src/codex-desktop-cdp-adapter.js";
3
2
  import { ClankerBendHost, createMockTranscriptAdapter } from "./host/src/index.js";
3
+ import { createCodexDesktopMuxAdapter } from "./launch/codex-mux-adapter.mjs";
4
4
  import { navigateProfile, printLaunchStatus } from "./launch/profiles.mjs";
5
5
 
6
6
  export async function launchClankerBendCodex(options = {}) {
@@ -10,7 +10,9 @@ export async function launchClankerBendCodex(options = {}) {
10
10
  stateDir: options.stateDir,
11
11
  runDir: options.runDir,
12
12
  registryConfigPath: options.registryConfigPath,
13
- registryProfileId: options.registryProfileId
13
+ registryProfileId: options.registryProfileId,
14
+ accountId: options.accountId,
15
+ primaryCodexHome: options.primaryCodexHome
14
16
  });
15
17
 
16
18
  const host = new ClankerBendHost({
@@ -19,13 +21,18 @@ export async function launchClankerBendCodex(options = {}) {
19
21
  token: options.token || process.env.ONEWILL_CLANKERBEND_TOKEN || undefined,
20
22
  localDevInsecure,
21
23
  runDir: profile.runDir,
24
+ accountRegistry: profile.accountRegistry,
22
25
  transcriptAdapter: mockMode
23
26
  ? createMockTranscriptAdapter({ defaultAppId: profile.defaultPanelAppId, providers: profile.providers })
24
- : createCodexDesktopCdpAdapter({
25
- runDir: profile.runDir,
26
- profileDir: profile.runtimePaths?.codexProfileDir,
27
- rendererBridges: profile.rendererBridges,
28
- providers: profile.providers
27
+ : createCodexDesktopMuxAdapter({
28
+ accountRegistry: profile.accountRegistry,
29
+ startAccountId: profile.startAccountId,
30
+ providers: profile.providers,
31
+ adapterOptions: {
32
+ runDir: profile.runDir,
33
+ rendererBridges: profile.rendererBridges,
34
+ providers: profile.providers
35
+ }
29
36
  })
30
37
  });
31
38