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
@@ -0,0 +1,360 @@
1
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ export const ACCOUNT_REGISTRY_VERSION = 1;
6
+ export const PRIMARY_ACCOUNT_ID = "primary";
7
+ export const MAX_ACCOUNT_PROFILES = 20;
8
+
9
+ export function primaryCodexHome() {
10
+ return process.env.CODEX_HOME ? resolve(process.env.CODEX_HOME) : join(homedir(), ".codex");
11
+ }
12
+
13
+ export class CodexAccountRegistry {
14
+ constructor(options = {}) {
15
+ if (!options.root) throw new Error("account registry root is required");
16
+ if (!options.primaryElectronProfile) throw new Error("primary electron profile is required");
17
+ this.root = resolve(options.root);
18
+ this.registryPath = options.registryPath || join(this.root, "accounts.json");
19
+ this.accountsDir = options.accountsDir || join(this.root, "accounts");
20
+ this.deletedDir = options.deletedDir || join(this.root, "deleted-accounts");
21
+ this.primaryCodexHome = resolve(options.primaryCodexHome || primaryCodexHome());
22
+ this.primaryElectronProfile = resolve(options.primaryElectronProfile);
23
+ this.maxAccounts = options.maxAccounts || MAX_ACCOUNT_PROFILES;
24
+ this.registry = this.load();
25
+ }
26
+
27
+ list() {
28
+ this.ensurePrimary();
29
+ return {
30
+ version: ACCOUNT_REGISTRY_VERSION,
31
+ clankerbendDefaultAccountId: this.registry.clankerbendDefaultAccountId || PRIMARY_ACCOUNT_ID,
32
+ maxAccounts: this.maxAccounts,
33
+ accounts: this.registry.accounts.map((account) => this.publicAccount(account)),
34
+ backups: this.registry.backups || [],
35
+ deletedAccounts: this.registry.deletedAccounts || []
36
+ };
37
+ }
38
+
39
+ get(id) {
40
+ this.ensurePrimary();
41
+ const account = this.registry.accounts.find((candidate) => candidate.id === id);
42
+ if (!account) throw new Error(`unknown Codex account profile: ${id}`);
43
+ return account;
44
+ }
45
+
46
+ getDefault() {
47
+ const id = this.registry.clankerbendDefaultAccountId || PRIMARY_ACCOUNT_ID;
48
+ return this.registry.accounts.find((account) => account.id === id) || this.get(PRIMARY_ACCOUNT_ID);
49
+ }
50
+
51
+ createManaged(input = {}) {
52
+ this.ensurePrimary();
53
+ if (this.registry.accounts.length >= this.maxAccounts) {
54
+ throw new Error(`Codex account profile limit reached (${this.maxAccounts})`);
55
+ }
56
+ const id = input.id
57
+ ? safeAccountId(input.id)
58
+ : uniqueAccountId(this.registry.accounts, accountIdFromLabel(input.label || "account"));
59
+ if (input.id && this.registry.accounts.some((account) => account.id === id)) {
60
+ throw new Error(`Codex account profile already exists: ${id}`);
61
+ }
62
+ const accountRoot = join(this.accountsDir, id);
63
+ const codexHome = join(accountRoot, "codex-home");
64
+ const electronProfile = join(accountRoot, "electron-profile");
65
+ mkdirSync(codexHome, { recursive: true, mode: 0o700 });
66
+ mkdirSync(electronProfile, { recursive: true, mode: 0o700 });
67
+ writeManagedConfig(codexHome, this.primaryCodexHome);
68
+ const account = {
69
+ id,
70
+ kind: "managed",
71
+ label: String(input.label || id),
72
+ codexHome,
73
+ electronProfile,
74
+ createdAt: new Date().toISOString(),
75
+ createdFrom: PRIMARY_ACCOUNT_ID
76
+ };
77
+ this.registry.accounts.push(account);
78
+ this.save();
79
+ return this.publicAccount(account);
80
+ }
81
+
82
+ setDefault(id) {
83
+ this.get(id);
84
+ this.registry.clankerbendDefaultAccountId = id;
85
+ this.save();
86
+ return this.list();
87
+ }
88
+
89
+ adoptAsPrimary(id) {
90
+ const target = this.get(id);
91
+ if (target.kind === "primary") throw new Error("primary account is already the primary Codex home");
92
+ const timestamp = timestampSegment();
93
+ const primary = this.get(PRIMARY_ACCOUNT_ID);
94
+ const oldPrimaryId = uniqueAccountId(this.registry.accounts, `previous-primary-${timestamp}`);
95
+ const backupCodexHome = `${this.primaryCodexHome}.backup_${timestamp}`;
96
+ const oldPrimaryElectronProfile = join(this.accountsDir, oldPrimaryId, "electron-profile");
97
+ const rollbackBackup = {
98
+ id: oldPrimaryId,
99
+ label: `Previous Primary ${timestamp}`,
100
+ codexHome: backupCodexHome,
101
+ createdAt: new Date().toISOString(),
102
+ reason: "adopt-as-primary"
103
+ };
104
+
105
+ if (!existsSync(target.codexHome)) throw new Error(`target CODEX_HOME does not exist: ${target.codexHome}`);
106
+ if (existsSync(backupCodexHome)) throw new Error(`backup path already exists: ${backupCodexHome}`);
107
+
108
+ if (existsSync(this.primaryCodexHome)) movePath(this.primaryCodexHome, backupCodexHome);
109
+ if (existsSync(primary.electronProfile)) movePath(primary.electronProfile, oldPrimaryElectronProfile);
110
+ movePath(target.codexHome, this.primaryCodexHome);
111
+ if (existsSync(target.electronProfile)) movePath(target.electronProfile, primary.electronProfile);
112
+ else mkdirSync(primary.electronProfile, { recursive: true, mode: 0o700 });
113
+
114
+ const oldPrimary = {
115
+ id: oldPrimaryId,
116
+ kind: "managed",
117
+ label: rollbackBackup.label,
118
+ codexHome: backupCodexHome,
119
+ electronProfile: oldPrimaryElectronProfile,
120
+ createdAt: rollbackBackup.createdAt,
121
+ createdFrom: PRIMARY_ACCOUNT_ID,
122
+ backup: true
123
+ };
124
+ primary.lastAdoptedFrom = target.id;
125
+ primary.lastAdoptedAt = new Date().toISOString();
126
+ primary.codexHome = this.primaryCodexHome;
127
+ primary.electronProfile = this.primaryElectronProfile;
128
+ this.registry.accounts = this.registry.accounts
129
+ .filter((account) => account.id !== target.id)
130
+ .map((account) => account.id === PRIMARY_ACCOUNT_ID ? primary : account);
131
+ this.registry.accounts.push(oldPrimary);
132
+ this.registry.backups = [...(this.registry.backups || []), rollbackBackup];
133
+ this.registry.clankerbendDefaultAccountId = PRIMARY_ACCOUNT_ID;
134
+ this.save();
135
+ return {
136
+ adoptedAccountId: id,
137
+ primary: this.publicAccount(primary),
138
+ previousPrimary: this.publicAccount(oldPrimary),
139
+ backup: rollbackBackup
140
+ };
141
+ }
142
+
143
+ rollbackPrimary(input = {}) {
144
+ const backupId = input.backupId || input.accountId;
145
+ if (!backupId) throw new Error("backupId is required");
146
+ const backupAccount = this.get(backupId);
147
+ if (backupAccount.kind === "primary") throw new Error("cannot rollback from primary account");
148
+ if (!existsSync(backupAccount.codexHome)) throw new Error(`backup CODEX_HOME does not exist: ${backupAccount.codexHome}`);
149
+
150
+ const timestamp = timestampSegment();
151
+ const primary = this.get(PRIMARY_ACCOUNT_ID);
152
+ const replacedId = uniqueAccountId(this.registry.accounts, `rollback-replaced-${timestamp}`);
153
+ const replacedCodexHome = `${this.primaryCodexHome}.rollback_replaced_${timestamp}`;
154
+ const replacedElectronProfile = join(this.accountsDir, replacedId, "electron-profile");
155
+
156
+ if (existsSync(this.primaryCodexHome)) movePath(this.primaryCodexHome, replacedCodexHome);
157
+ if (existsSync(primary.electronProfile)) movePath(primary.electronProfile, replacedElectronProfile);
158
+ movePath(backupAccount.codexHome, this.primaryCodexHome);
159
+ if (existsSync(backupAccount.electronProfile)) movePath(backupAccount.electronProfile, primary.electronProfile);
160
+ else mkdirSync(primary.electronProfile, { recursive: true, mode: 0o700 });
161
+
162
+ const replacedAccount = {
163
+ id: replacedId,
164
+ kind: "managed",
165
+ label: `Rollback Replaced ${timestamp}`,
166
+ codexHome: replacedCodexHome,
167
+ electronProfile: replacedElectronProfile,
168
+ createdAt: new Date().toISOString(),
169
+ createdFrom: PRIMARY_ACCOUNT_ID,
170
+ backup: true
171
+ };
172
+ primary.lastRollbackFrom = backupAccount.id;
173
+ primary.lastRollbackAt = new Date().toISOString();
174
+ primary.codexHome = this.primaryCodexHome;
175
+ primary.electronProfile = this.primaryElectronProfile;
176
+ this.registry.accounts = this.registry.accounts
177
+ .filter((account) => account.id !== backupAccount.id)
178
+ .map((account) => account.id === PRIMARY_ACCOUNT_ID ? primary : account);
179
+ this.registry.accounts.push(replacedAccount);
180
+ this.registry.backups = [...(this.registry.backups || []), {
181
+ id: replacedId,
182
+ label: replacedAccount.label,
183
+ codexHome: replacedCodexHome,
184
+ createdAt: replacedAccount.createdAt,
185
+ reason: "rollback-replaced-primary"
186
+ }];
187
+ this.registry.clankerbendDefaultAccountId = PRIMARY_ACCOUNT_ID;
188
+ this.save();
189
+ return {
190
+ restoredAccountId: backupAccount.id,
191
+ primary: this.publicAccount(primary),
192
+ replacedPrimary: this.publicAccount(replacedAccount)
193
+ };
194
+ }
195
+
196
+ deleteManaged(id) {
197
+ const account = this.get(id);
198
+ if (account.kind === "primary") throw new Error("primary account cannot be deleted");
199
+ const timestamp = timestampSegment();
200
+ const deleteRoot = join(this.deletedDir, `${id}_${timestamp}`);
201
+ mkdirSync(deleteRoot, { recursive: true, mode: 0o700 });
202
+ const deleted = {
203
+ id,
204
+ label: account.label,
205
+ deletedAt: new Date().toISOString(),
206
+ originalCodexHome: account.codexHome,
207
+ originalElectronProfile: account.electronProfile,
208
+ deletedRoot: deleteRoot,
209
+ codexHome: null,
210
+ electronProfile: null
211
+ };
212
+ if (existsSync(account.codexHome)) {
213
+ const destination = join(deleteRoot, "codex-home");
214
+ movePath(account.codexHome, destination);
215
+ deleted.codexHome = destination;
216
+ }
217
+ if (existsSync(account.electronProfile)) {
218
+ const destination = join(deleteRoot, "electron-profile");
219
+ movePath(account.electronProfile, destination);
220
+ deleted.electronProfile = destination;
221
+ }
222
+ this.registry.accounts = this.registry.accounts.filter((candidate) => candidate.id !== id);
223
+ if (this.registry.clankerbendDefaultAccountId === id) this.registry.clankerbendDefaultAccountId = PRIMARY_ACCOUNT_ID;
224
+ this.registry.deletedAccounts = [...(this.registry.deletedAccounts || []), deleted];
225
+ this.save();
226
+ return deleted;
227
+ }
228
+
229
+ load() {
230
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
231
+ mkdirSync(this.accountsDir, { recursive: true, mode: 0o700 });
232
+ let registry = null;
233
+ if (existsSync(this.registryPath)) {
234
+ registry = JSON.parse(readFileSync(this.registryPath, "utf8"));
235
+ }
236
+ registry ||= {
237
+ version: ACCOUNT_REGISTRY_VERSION,
238
+ clankerbendDefaultAccountId: PRIMARY_ACCOUNT_ID,
239
+ accounts: [],
240
+ backups: [],
241
+ deletedAccounts: []
242
+ };
243
+ registry.version = ACCOUNT_REGISTRY_VERSION;
244
+ registry.accounts = Array.isArray(registry.accounts) ? registry.accounts : [];
245
+ registry.backups = Array.isArray(registry.backups) ? registry.backups : [];
246
+ registry.deletedAccounts = Array.isArray(registry.deletedAccounts) ? registry.deletedAccounts : [];
247
+ this.registry = registry;
248
+ this.ensurePrimary();
249
+ return this.registry;
250
+ }
251
+
252
+ ensurePrimary() {
253
+ const primary = this.registry.accounts.find((account) => account.id === PRIMARY_ACCOUNT_ID);
254
+ if (primary) {
255
+ primary.kind = "primary";
256
+ primary.label ||= "Primary";
257
+ primary.codexHome = this.primaryCodexHome;
258
+ primary.electronProfile = this.primaryElectronProfile;
259
+ return primary;
260
+ }
261
+ const account = {
262
+ id: PRIMARY_ACCOUNT_ID,
263
+ kind: "primary",
264
+ label: "Primary",
265
+ codexHome: this.primaryCodexHome,
266
+ electronProfile: this.primaryElectronProfile,
267
+ createdAt: new Date().toISOString()
268
+ };
269
+ this.registry.accounts.unshift(account);
270
+ this.registry.clankerbendDefaultAccountId ||= PRIMARY_ACCOUNT_ID;
271
+ this.save();
272
+ return account;
273
+ }
274
+
275
+ save() {
276
+ mkdirSync(dirname(this.registryPath), { recursive: true, mode: 0o700 });
277
+ writeFileSync(this.registryPath, `${JSON.stringify(this.registry, null, 2)}\n`, { mode: 0o600 });
278
+ }
279
+
280
+ publicAccount(account) {
281
+ return {
282
+ id: account.id,
283
+ kind: account.kind,
284
+ label: account.label,
285
+ codexHome: account.codexHome,
286
+ electronProfile: account.electronProfile,
287
+ auth: {
288
+ authJson: existsSync(join(account.codexHome, "auth.json")),
289
+ configToml: existsSync(join(account.codexHome, "config.toml"))
290
+ },
291
+ createdAt: account.createdAt,
292
+ createdFrom: account.createdFrom,
293
+ backup: account.backup || undefined,
294
+ lastSeenAccount: account.lastSeenAccount || undefined,
295
+ lastAdoptedFrom: account.lastAdoptedFrom || undefined,
296
+ lastAdoptedAt: account.lastAdoptedAt || undefined,
297
+ lastRollbackFrom: account.lastRollbackFrom || undefined,
298
+ lastRollbackAt: account.lastRollbackAt || undefined
299
+ };
300
+ }
301
+ }
302
+
303
+ function writeManagedConfig(codexHome, primaryHome) {
304
+ const primaryConfig = join(primaryHome, "config.toml");
305
+ let content = "";
306
+ if (existsSync(primaryConfig)) {
307
+ const match = readFileSync(primaryConfig, "utf8").match(/^\s*cli_auth_credentials_store\s*=.*$/m);
308
+ if (match) content = `${match[0].trim()}\n`;
309
+ }
310
+ if (!content && existsSync(join(primaryHome, "auth.json"))) {
311
+ content = "cli_auth_credentials_store = \"file\"\n";
312
+ }
313
+ if (content) writeFileSync(join(codexHome, "config.toml"), content, { mode: 0o600 });
314
+ }
315
+
316
+ function accountIdFromLabel(label) {
317
+ const id = String(label || "account")
318
+ .trim()
319
+ .toLowerCase()
320
+ .replace(/[^a-z0-9_-]+/g, "-")
321
+ .replace(/^-+|-+$/g, "")
322
+ .slice(0, 40);
323
+ if (!id) return "account";
324
+ return /^[a-z]/.test(id) ? id : `account-${id}`.slice(0, 40);
325
+ }
326
+
327
+ function safeAccountId(id) {
328
+ const value = String(id || "").trim();
329
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(value)) {
330
+ throw new Error("account id must start with a lowercase letter and contain only lowercase letters, numbers, _ or -");
331
+ }
332
+ return value;
333
+ }
334
+
335
+ function uniqueAccountId(accounts, base) {
336
+ let candidate = safeAccountId(base);
337
+ let suffix = 2;
338
+ while (accounts.some((account) => account.id === candidate)) {
339
+ candidate = safeAccountId(`${base}-${suffix}`);
340
+ suffix += 1;
341
+ }
342
+ return candidate;
343
+ }
344
+
345
+ function timestampSegment(date = new Date()) {
346
+ return date.toISOString().replace(/\..*$/, "").replace("T", "_").replace(/:/g, "");
347
+ }
348
+
349
+ function movePath(from, to) {
350
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
351
+ try {
352
+ renameSync(from, to);
353
+ } catch (err) {
354
+ if (err?.code !== "EXDEV") throw err;
355
+ const stat = statSync(from);
356
+ if (stat.isDirectory()) cpSync(from, to, { recursive: true, force: false });
357
+ else cpSync(from, to, { force: false });
358
+ rmSync(from, { recursive: true, force: true });
359
+ }
360
+ }
@@ -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,35 +8,54 @@ import {
8
8
  providersFromRendererBridges,
9
9
  readAppManifest
10
10
  } from "../host/src/app-registry.js";
11
- import { oneWhackRuntimePaths } from "./runtime-paths.mjs";
11
+ import { CodexAccountRegistry, primaryCodexHome } from "./accounts.mjs";
12
+ import { clankerbendRuntimePaths } from "./runtime-paths.mjs";
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
15
  const ROOT_DIR = join(__dirname, "..");
15
16
 
16
17
  export const PROFILE_NAVIGATE = "navigate";
17
- export const VIM_NAV_MANIFEST_PATH = join(ROOT_DIR, "apps/vim-nav/onewhack.manifest.json");
18
- export const STICKY_NOTES_MANIFEST_PATH = join(ROOT_DIR, "apps/sticky-notes/onewhack.manifest.json");
18
+ export const VIM_NAV_MANIFEST_PATH = join(ROOT_DIR, "apps/vim-nav/clankerbend.manifest.json");
19
+ export const STICKY_NOTES_MANIFEST_PATH = join(ROOT_DIR, "apps/sticky-notes/clankerbend.manifest.json");
19
20
  export const CODEX_DESKTOP_RENDERER_BRIDGE_PATH = join(ROOT_DIR, "host/src/codex-desktop-renderer-bridge.js");
20
21
  export const VIM_NAV_APP_ID = readAppManifest(VIM_NAV_MANIFEST_PATH).appId;
21
22
  export const STICKY_NOTES_APP_ID = readAppManifest(STICKY_NOTES_MANIFEST_PATH).appId;
22
23
 
23
24
  export async function navigateProfile(options = {}) {
24
- const runtimePaths = oneWhackRuntimePaths({ stateDir: options.stateDir });
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",
29
42
  description: "Codex Desktop with OneWill Navigate transcript controls.",
30
- hostId: "onewill.onewhack.host",
31
- hostName: "OneWhack Navigate",
43
+ hostId: "onewill.clankerbend.host",
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 || "public"
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);
@@ -44,9 +63,9 @@ export async function navigateProfile(options = {}) {
44
63
  }
45
64
 
46
65
  export function configuredManifestPaths(baseManifestPaths, options = {}) {
47
- const runtimePaths = oneWhackRuntimePaths({ stateDir: options.stateDir });
48
- const configPath = options.registryConfigPath || process.env.ONEWILL_ONEWHACK_REGISTRY_CONFIG || runtimePaths.registryConfigPath;
49
- const profileId = options.registryProfileId || process.env.ONEWILL_ONEWHACK_PROFILE || "default";
66
+ const runtimePaths = clankerbendRuntimePaths({ stateDir: options.stateDir });
67
+ const configPath = options.registryConfigPath || process.env.ONEWILL_CLANKERBEND_REGISTRY_CONFIG || runtimePaths.registryConfigPath;
68
+ const profileId = options.registryProfileId || process.env.ONEWILL_CLANKERBEND_PROFILE || "default";
50
69
  const config = loadRegistryConfig(configPath);
51
70
  return mergeManifestPaths(baseManifestPaths, enabledManifestPathsFromConfig(config, profileId));
52
71
  }
@@ -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(`OneWhack: ${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 OneWhack apps.");
90
- }
91
- console.log("Press Ctrl+C to stop OneWhack and the launched Codex Desktop process.");
106
+ if (host.token) console.log(`Token: ${host.token}`);
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
+ }
@@ -1,20 +1,23 @@
1
1
  import { homedir, platform } from "node:os";
2
2
  import { join } from "node:path";
3
3
 
4
- export function oneWhackStateDir() {
5
- if (process.env.ONEWILL_ONEWHACK_STATE_DIR) return process.env.ONEWILL_ONEWHACK_STATE_DIR;
4
+ export function clankerbendStateDir() {
5
+ if (process.env.ONEWILL_CLANKERBEND_STATE_DIR) return process.env.ONEWILL_CLANKERBEND_STATE_DIR;
6
6
  if (platform() === "darwin") {
7
- return join(homedir(), "Library/Application Support/OneWill/OneWhack");
7
+ return join(homedir(), "Library/Application Support/OneWill/ClankerBend");
8
8
  }
9
- return join(homedir(), ".local/state/onewill/onewhack");
9
+ return join(homedir(), ".local/state/onewill/clankerbend");
10
10
  }
11
11
 
12
- export function oneWhackRuntimePaths(options = {}) {
13
- const root = options.stateDir || oneWhackStateDir();
12
+ export function clankerbendRuntimePaths(options = {}) {
13
+ const root = options.stateDir || clankerbendStateDir();
14
14
  return {
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
  };